Browse Source
오버로드 항목만 999로 표시하고 다른 계측값은 유지합니다. 모든 NG의 각인과 DB 저장을 생략하며, 영문 파일명의 debug 진단 로그와 회귀 테스트를 추가합니다.master
10 changed files with 516 additions and 72 deletions
@ -0,0 +1,27 @@ |
|||||
|
namespace marking_gui.Models |
||||
|
{ |
||||
|
public sealed class EquipmentMeasurementResult |
||||
|
{ |
||||
|
// Overload is stored in display units, so A/mA and Ohm/kOhm conversion must not scale it.
|
||||
|
public const double OverloadValue = 999.0; |
||||
|
|
||||
|
public double Voltage { get; } |
||||
|
public double Current { get; } |
||||
|
public double Resistance { get; } |
||||
|
public bool VoltageOverloaded { get; } |
||||
|
public bool CurrentOverloaded { get; } |
||||
|
public bool ResistanceOverloaded { get; } |
||||
|
public bool HasOverload => VoltageOverloaded || CurrentOverloaded || ResistanceOverloaded; |
||||
|
|
||||
|
public EquipmentMeasurementResult(double voltage, double current, double resistance, |
||||
|
bool voltageOverloaded = false, bool currentOverloaded = false, bool resistanceOverloaded = false) |
||||
|
{ |
||||
|
Voltage = voltage; |
||||
|
Current = current; |
||||
|
Resistance = resistance; |
||||
|
VoltageOverloaded = voltageOverloaded; |
||||
|
CurrentOverloaded = currentOverloaded; |
||||
|
ResistanceOverloaded = resistanceOverloaded; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
<PropertyGroup> |
||||
|
<OutputType>Exe</OutputType> |
||||
|
<TargetFramework>net9.0-windows</TargetFramework> |
||||
|
<UseWPF>true</UseWPF> |
||||
|
<LangVersion>10.0</LangVersion> |
||||
|
<EnableNETAnalyzers>false</EnableNETAnalyzers> |
||||
|
</PropertyGroup> |
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" /> |
||||
|
<Compile Include="../../marking_gui/Models/EquipmentMeasurementResult.cs" Link="EquipmentMeasurementResult.cs" /> |
||||
|
<Compile Include="../../marking_gui/Models/Product.cs" Link="Product.cs" /> |
||||
|
<Compile Include="../../marking_gui/Services/DatabaseService.cs" Link="DatabaseService.cs" /> |
||||
|
<Compile Include="../../marking_gui/Services/LoggerService.cs" Link="LoggerService.cs" /> |
||||
|
<Compile Include="../../marking_gui/Services/EquipmentMeasurementService.cs" Link="EquipmentMeasurementService.cs" /> |
||||
|
<Compile Include="../../marking_gui/Services/IScpiClient.cs" Link="IScpiClient.cs" /> |
||||
|
<Compile Include="../../marking_gui/Services/TcpScpiClient.cs" Link="TcpScpiClient.cs" /> |
||||
|
</ItemGroup> |
||||
|
</Project> |
||||
@ -0,0 +1,268 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Reflection; |
||||
|
using System.Threading.Tasks; |
||||
|
using marking_gui.Services; |
||||
|
using marking_gui.Models; |
||||
|
|
||||
|
internal static class Program |
||||
|
{ |
||||
|
private static readonly string DiagnosticPath = Path.Combine(AppContext.BaseDirectory, "debug"); |
||||
|
private static readonly string DailyPath = Path.Combine(AppContext.BaseDirectory, "Logs"); |
||||
|
private static readonly string FixturePath = Path.Combine(AppContext.BaseDirectory, "fixtures", Guid.NewGuid().ToString("N")); |
||||
|
|
||||
|
private static async Task Main() |
||||
|
{ |
||||
|
Directory.CreateDirectory(FixturePath); |
||||
|
TestErrorReport(); |
||||
|
TestDirectoryRecreation(); |
||||
|
TestIndependentWrites(); |
||||
|
await TestConcurrentErrors(); |
||||
|
TestBoundedHistory(); |
||||
|
await TestMeasurementFailure(false); |
||||
|
await TestMeasurementFailure(true); |
||||
|
await TestNormalMeasurement(); |
||||
|
await TestOverloadCombinations(); |
||||
|
await TestOverloadFormats(); |
||||
|
await TestInvalidResponses(); |
||||
|
TestDatabaseValues(); |
||||
|
TestOfflineNgSequence(); |
||||
|
Console.WriteLine("PASS: diagnostics, 7 overload combinations, overload formats, invalid responses, DB parameters and NG no-save/sequence (no physical equipment/DB used)."); |
||||
|
Console.WriteLine("Diagnostic samples: " + DiagnosticPath); |
||||
|
} |
||||
|
|
||||
|
private static void TestErrorReport() |
||||
|
{ |
||||
|
string token = Guid.NewGuid().ToString("N"); |
||||
|
LoggerService.Info("검사 시작 " + token); |
||||
|
Exception failure; |
||||
|
try { throw new InvalidOperationException("outer " + token, new FormatException("inner 原因")); } |
||||
|
catch (Exception ex) { failure = ex; } |
||||
|
string report = CaptureReport(() => LoggerService.Error("측정 실패 " + token, failure, "원문=+9.90000000E+37")); |
||||
|
Require(report.Contains("검사 시작 " + token), "Recent context is missing."); |
||||
|
Require(report.Contains("FormatException: inner 原因"), "Inner exception/UTF-8 was lost."); |
||||
|
Require(report.Contains("Program.TestErrorReport"), "Stack trace is missing."); |
||||
|
Require(report.Contains("원문=+9.90000000E+37"), "Raw measurement context is missing."); |
||||
|
Require(File.ReadAllText(DailyFile()).Contains("[진단 파일]"), "Daily log must point to a diagnostic file."); |
||||
|
CaptureReport(() => LoggerService.Error("Exception 객체 없는 오류")); |
||||
|
} |
||||
|
|
||||
|
private static void TestDirectoryRecreation() |
||||
|
{ |
||||
|
Directory.Move(DiagnosticPath, Path.Combine(FixturePath, "previous-diagnostics")); |
||||
|
Directory.Move(DailyPath, Path.Combine(FixturePath, "previous-daily")); |
||||
|
string report = CaptureReport(() => LoggerService.Error("폴더 자동 재생성")); |
||||
|
Require(report.Contains("폴더 자동 재생성") && File.Exists(DailyFile()), "Deleted directories were not recreated."); |
||||
|
} |
||||
|
|
||||
|
private static void TestIndependentWrites() |
||||
|
{ |
||||
|
Directory.Move(DailyPath, Path.Combine(FixturePath, "daily-block-test")); |
||||
|
File.WriteAllText(DailyPath, "test-owned blocker"); |
||||
|
try { CaptureReport(() => LoggerService.Error("일별 로그 저장 불가")); } |
||||
|
finally { File.Delete(DailyPath); } |
||||
|
|
||||
|
Directory.Move(DiagnosticPath, Path.Combine(FixturePath, "diagnostic-block-test")); |
||||
|
File.WriteAllText(DiagnosticPath, "test-owned blocker"); |
||||
|
try |
||||
|
{ |
||||
|
LoggerService.Error("진단 폴더 저장 불가"); |
||||
|
Require(File.ReadAllText(DailyFile()).Contains("[진단 파일 저장 실패]"), "Diagnostic failure was not recorded in the daily log."); |
||||
|
} |
||||
|
finally { File.Delete(DiagnosticPath); } |
||||
|
} |
||||
|
|
||||
|
private static async Task TestConcurrentErrors() |
||||
|
{ |
||||
|
Directory.CreateDirectory(DiagnosticPath); |
||||
|
int before = Directory.GetFiles(DiagnosticPath, "*.log").Length; |
||||
|
await Task.WhenAll(Enumerable.Range(0, 24).Select(i => Task.Run(() => LoggerService.Error("concurrent " + i)))); |
||||
|
Require(Directory.GetFiles(DiagnosticPath, "*.log").Length == before + 24, "Concurrent reports were overwritten or lost."); |
||||
|
} |
||||
|
|
||||
|
private static void TestBoundedHistory() |
||||
|
{ |
||||
|
string oldToken = Guid.NewGuid().ToString("N"); |
||||
|
LoggerService.Info(oldToken); |
||||
|
for (int i = 0; i < 105; i++) LoggerService.Info("history " + i); |
||||
|
string report = CaptureReport(() => LoggerService.Error("bounded history")); |
||||
|
Require(!report.Contains(oldToken) && report.Contains("history 104"), "Recent history is not bounded correctly."); |
||||
|
} |
||||
|
|
||||
|
private static async Task TestMeasurementFailure(bool cleanupFails) |
||||
|
{ |
||||
|
var psu = new FakeClient("0.008", cleanupFails); |
||||
|
using var service = CreateEquipment(psu, new FakeClient("4.5"), new FakeClient("invalid-response")); |
||||
|
var before = Reports(); |
||||
|
Exception failure = null; |
||||
|
try { await service.MeasureAsync(); } |
||||
|
catch (Exception ex) { failure = ex; } |
||||
|
Require(failure is InvalidOperationException && failure.Message.Contains("RESISTANCE"), "Original parsing exception was changed/lost."); |
||||
|
Require(psu.Commands.Contains("OUTP OFF, (@1)"), "PSU cleanup was not attempted."); |
||||
|
string[] reports = Reports().Except(before).Select(File.ReadAllText).ToArray(); |
||||
|
string report = reports.Single(r => r.Contains("[오류 상세]" + Environment.NewLine + "[Equipment] 계측 시퀀스 실패")); |
||||
|
Require(report.Contains("invalid-response") && report.Contains("MEAS:RES?") && report.Contains("test-resistance:5025"), "Report lost the endpoint, command or raw response."); |
||||
|
Require(report.Contains(cleanupFails ? "PSU OUTP OFF FAILED" : "PSU CLEANUP 명령 전송 완료"), "Cleanup outcome is missing."); |
||||
|
if (cleanupFails) |
||||
|
Require(reports.Any(r => r.Contains("IOException: simulated cleanup failure")), "Cleanup failure diagnostic is missing."); |
||||
|
} |
||||
|
|
||||
|
private static async Task TestNormalMeasurement() |
||||
|
{ |
||||
|
var psu = new FakeClient("0.008"); |
||||
|
using var service = CreateEquipment(psu, new FakeClient("4.5"), new FakeClient("10000")); |
||||
|
var before = Reports(); |
||||
|
var result = await service.MeasureAsync(); |
||||
|
Require(result.Voltage == 4.5 && result.Current == 8 && result.Resistance == 10, "Normal measurement results changed."); |
||||
|
Require(Reports().Length == before.Length, "Successful measurement created an error report."); |
||||
|
Require(psu.Commands.Contains("OUTP OFF, (@1)"), "Normal PSU cleanup changed."); |
||||
|
} |
||||
|
|
||||
|
private static async Task TestOverloadCombinations() |
||||
|
{ |
||||
|
for (int mask = 1; mask < 8; mask++) |
||||
|
{ |
||||
|
bool voltageOl = (mask & 1) != 0; |
||||
|
bool currentOl = (mask & 2) != 0; |
||||
|
bool resistanceOl = (mask & 4) != 0; |
||||
|
var psu = new FakeClient(currentOl ? "+9.90000000E+37" : "0.008"); |
||||
|
var voltage = new FakeClient(voltageOl ? "+9.90000000E+37" : "4.5"); |
||||
|
var resistance = new FakeClient(resistanceOl ? "+9.90000000E+37" : "10000"); |
||||
|
using var service = CreateEquipment(psu, voltage, resistance); |
||||
|
var updates = new List<(double? Voltage, double? Current, double? Resistance)>(); |
||||
|
var before = Reports(); |
||||
|
var result = await service.MeasureAsync((v, c, r) => updates.Add((v, c, r))); |
||||
|
Require(result.Voltage == (voltageOl ? 999 : 4.5), "Voltage overload changed another reading."); |
||||
|
Require(result.Current == (currentOl ? 999 : 8), "Current overload sentinel was scaled or other current lost."); |
||||
|
Require(result.Resistance == (resistanceOl ? 999 : 10), "Resistance overload sentinel was scaled or normal resistance lost."); |
||||
|
Require(result.VoltageOverloaded == voltageOl && result.CurrentOverloaded == currentOl && result.ResistanceOverloaded == resistanceOl, |
||||
|
"Per-channel overload flags are incorrect."); |
||||
|
Require(result.HasOverload && updates.Count == 3, "Overload interrupted the remaining readings/UI updates."); |
||||
|
Require(updates[2].Voltage == result.Voltage && updates[2].Current == result.Current && updates[2].Resistance == result.Resistance, |
||||
|
"UI update does not preserve all final readings."); |
||||
|
Require(psu.Commands.Contains("MEAS:CURR? (@1)") && psu.Commands.Contains("OUTP OFF, (@1)"), "Overload skipped current measurement or cleanup."); |
||||
|
Require(resistance.Commands.Contains("MEAS:RES?"), "Voltage overload skipped resistance."); |
||||
|
string report = Reports().Except(before).Select(File.ReadAllText).Single(); |
||||
|
Require(report.Contains("Overload 항목을 999") && report.Contains("+9.90000000E+37"), "Overload diagnostic is missing."); |
||||
|
var parameters = DatabaseService.CreateMeasurementParameters(result.Voltage, result.Current, result.Resistance); |
||||
|
Require((decimal)parameters[0].Value == (decimal)result.Voltage && |
||||
|
(decimal)parameters[1].Value == (decimal)result.Current && |
||||
|
(decimal)parameters[2].Value == (decimal)result.Resistance, "DB parameters changed the measured values."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static async Task TestOverloadFormats() |
||||
|
{ |
||||
|
foreach (string response in new[] { "OL", " o.l\r\n", "OVERLOAD", "ovld", "-9.90000000E+37" }) |
||||
|
{ |
||||
|
using var service = CreateEquipment(new FakeClient(response), new FakeClient(response), new FakeClient(response)); |
||||
|
var result = await service.MeasureAsync(); |
||||
|
Require(result.Voltage == 999 && result.Current == 999 && result.Resistance == 999 && result.HasOverload, |
||||
|
"Overload response was not recognized: " + response); |
||||
|
} |
||||
|
using var normalService = CreateEquipment(new FakeClient("0"), new FakeClient("0"), new FakeClient("999")); |
||||
|
var normal = await normalService.MeasureAsync(); |
||||
|
Require(normal.Voltage == 0 && normal.Current == 0 && normal.Resistance == 0.999 && !normal.HasOverload, |
||||
|
"Real zero/999 Ohm measurement was mistaken for overload."); |
||||
|
} |
||||
|
|
||||
|
private static async Task TestInvalidResponses() |
||||
|
{ |
||||
|
foreach (string response in new[] { "", "bad", "NaN", "INF", "1E309" }) |
||||
|
{ |
||||
|
var psu = new FakeClient("0.008"); |
||||
|
using var service = CreateEquipment(psu, new FakeClient("4.5"), new FakeClient(response)); |
||||
|
Exception failure = null; |
||||
|
try { await service.MeasureAsync(); } |
||||
|
catch (Exception ex) { failure = ex; } |
||||
|
Require(failure is InvalidOperationException, "Malformed/empty response must not be silently saved as overload."); |
||||
|
Require(psu.Commands.Contains("OUTP OFF, (@1)"), "Bad response bypassed PSU cleanup."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void TestDatabaseValues() |
||||
|
{ |
||||
|
var parameters = DatabaseService.CreateMeasurementParameters(999, 999, 999); |
||||
|
Require(parameters.All(p => p.Precision == 5 && p.Scale == 2 && (decimal)p.Value == 999m), |
||||
|
"999 does not fit the configured database parameters."); |
||||
|
var normal = DatabaseService.CreateMeasurementParameters(4.567, 8.126, 12.346); |
||||
|
Require((decimal)normal[0].Value == 4.57m && (decimal)normal[1].Value == 8.13m && (decimal)normal[2].Value == 12.35m, |
||||
|
"Normal measurements were not rounded for decimal(5,2)."); |
||||
|
} |
||||
|
|
||||
|
private static void TestOfflineNgSequence() |
||||
|
{ |
||||
|
string settings = Path.Combine(AppContext.BaseDirectory, "Database.ini"); |
||||
|
Require(!File.Exists(settings), "Refusing to overwrite an existing test Database.ini."); |
||||
|
File.WriteAllText(settings, "[Database]\nOfflineMode=true\n"); |
||||
|
try |
||||
|
{ |
||||
|
var database = new DatabaseService(); |
||||
|
Require(database.OfflineMode, "Test database must be offline before saving."); |
||||
|
SaveOffline(database, "NG", "LOT0009"); |
||||
|
Require(database.GetNextMarkingSequence("LOT") == 1, "Overload NG consumed a marking sequence."); |
||||
|
SaveOffline(database, "OK", "LOT0010"); |
||||
|
SaveOffline(database, "NG", "LOT0020"); |
||||
|
Require(database.GetNextMarkingSequence("LOT") == 11, "NG reinspection changed the issued sequence."); |
||||
|
} |
||||
|
finally { File.Delete(settings); } |
||||
|
} |
||||
|
|
||||
|
private static void SaveOffline(DatabaseService database, string result, string serial) |
||||
|
{ |
||||
|
bool saved = database.SaveProductResult("TEST_IC", "maker", "model", "", "", "operator", "line", "LOT", "jig", |
||||
|
result == "NG" ? 999 : 4.5, 8, 10, result, DateTime.Now, DateTime.Now, serial, "pcb"); |
||||
|
Require(saved == (result == "OK"), "Only OK results may be saved; NG must be skipped."); |
||||
|
} |
||||
|
|
||||
|
private static EquipmentMeasurementService CreateEquipment(FakeClient psu, FakeClient voltage, FakeClient resistance) |
||||
|
{ |
||||
|
string settings = Path.Combine(FixturePath, "test-settings.ini"); |
||||
|
File.WriteAllText(settings, "[Equipment]\nSettleMilliseconds=0\nTimeout=100\n[DMM]\nVoltageConnection=LAN\nVoltageHost=test-voltage\n[ResistanceDMM]\nResistanceDMMConnection=LAN\nResistanceDMMHost=test-resistance\n[PowerSupply]\nCurrentConnection=LAN\nCurrentHost=test-psu\n"); |
||||
|
var service = new EquipmentMeasurementService(settings); |
||||
|
SetClient(service, "_psuClient", psu); |
||||
|
SetClient(service, "_voltageDmmClient", voltage); |
||||
|
SetClient(service, "_resistanceDmmClient", resistance); |
||||
|
return service; |
||||
|
} |
||||
|
|
||||
|
private static void SetClient(EquipmentMeasurementService service, string field, IScpiClient client) => |
||||
|
typeof(EquipmentMeasurementService).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(service, client); |
||||
|
|
||||
|
private static string[] Reports() => Directory.Exists(DiagnosticPath) ? Directory.GetFiles(DiagnosticPath, "*.log") : Array.Empty<string>(); |
||||
|
private static string DailyFile() => Path.Combine(DailyPath, DateTime.Now.ToString("yyyy-MM-dd") + ".log"); |
||||
|
|
||||
|
private static string CaptureReport(Action write) |
||||
|
{ |
||||
|
string[] before = Reports(); |
||||
|
write(); |
||||
|
string reportPath = Reports().Except(before).Single(); |
||||
|
string fileName = Path.GetFileName(reportPath); |
||||
|
Require(fileName.StartsWith("error_", StringComparison.Ordinal) && fileName.All(c => c <= 127), |
||||
|
"Diagnostic filenames must use the English prefix and ASCII characters only."); |
||||
|
return File.ReadAllText(reportPath); |
||||
|
} |
||||
|
|
||||
|
private static void Require(bool condition, string message) |
||||
|
{ |
||||
|
if (!condition) throw new Exception(message); |
||||
|
} |
||||
|
|
||||
|
private sealed class FakeClient : IScpiClient |
||||
|
{ |
||||
|
private readonly string _response; |
||||
|
private readonly bool _cleanupFails; |
||||
|
public List<string> Commands { get; } = new List<string>(); |
||||
|
public FakeClient(string response, bool cleanupFails = false) { _response = response; _cleanupFails = cleanupFails; } |
||||
|
public Task SendAsync(string command) |
||||
|
{ |
||||
|
Commands.Add(command); |
||||
|
return _cleanupFails && command.Contains("OFF") |
||||
|
? Task.FromException(new IOException("simulated cleanup failure")) : Task.CompletedTask; |
||||
|
} |
||||
|
public Task<string> QueryAsync(string command) { Commands.Add(command); return Task.FromResult(_response); } |
||||
|
public void Dispose() { } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
# 오류 진단 로그 회귀 검증 |
||||
|
|
||||
|
실장비·DB 연결 없이 운영 소스의 LoggerService, EquipmentMeasurementService, DatabaseService를 직접 컴파일해 검증합니다. 운영 프로젝트와 동일한 SQL 클라이언트와 Windows Desktop 프레임워크를 사용합니다. |
||||
|
|
||||
|
```powershell |
||||
|
dotnet run --project tests/DiagnosticsRegression/DiagnosticsRegression.csproj |
||||
|
``` |
||||
|
|
||||
|
오류 파일 생성, UTF-8/내부 예외/스택 추적/직전 로그, 폴더 재생성, 두 저장 경로의 독립적인 실패 처리, 동시 오류, 로그 이력 상한, 파싱 실패와 전원 차단 실패를 확인합니다. 세 계측 항목의 Overload 7가지 조합과 문자/지수 응답, 정상 계측값·화면 콜백 보존, 999의 단위 변환 방지, DECIMAL(5,2) 저장 매개변수, NG 저장 차단 및 각인 순번 유지를 추가로 검증합니다. |
||||
|
|
||||
|
테스트 로그와 보관한 테스트 폴더는 이 프로젝트의 `bin/Debug/net9.0-windows/` 아래에 생성됩니다. 진단 파일은 `debug/error_yyyyMMdd_HHmmss_fff_<id>.log` 형식이며 파일명은 ASCII 문자만 사용합니다. 테스트의 모든 장비 클라이언트는 가짜 구현이며 DB 테스트는 OfflineMode를 먼저 확인합니다. |
||||
Loading…
Reference in new issue