diff --git a/docs/knowledge_base.md b/docs/knowledge_base.md index 92e1e5f..e1256c2 100644 --- a/docs/knowledge_base.md +++ b/docs/knowledge_base.md @@ -44,6 +44,14 @@ ``` - **ADO.NET StoredProcedure 연동:** `SqlCommand.CommandType = CommandType.StoredProcedure` 설정을 사용하여 `@Operator`, `@Password`, 그리고 선택적으로 `@ProcessName` 파라미터를 매핑하여 계정 검증 프로시저를 안전하게 가동합니다. +### 2.3. 항목별 Overload 처리 및 NG 미저장 +- 전압·전류·저항 응답의 `+9.90000000E+37` 등 절댓값 `9.0e37` 이상인 유한 숫자와 `OL`, `O.L`, `OVERLOAD`, `OVLD` 문자열은 해당 항목의 Overload로 처리합니다. +- 해당 항목만 표시·저장 단위의 `999`로 대체하고 다른 항목의 측정과 화면 갱신을 계속합니다. `999`는 전류/저항 단위 변환으로 확대·축소하지 않습니다. 정상 응답 `999 Ohm`은 기존대로 `0.999 kOhm`으로 처리합니다. +- `EquipmentMeasurementResult`의 항목별 Overload 플래그로 NG를 확정합니다. Overload가 없는 정상 측정값은 기존 범위 판정을 적용합니다. 빈 응답·잘못된 문자열·실제 통신 오류는 별도 오류로 남기며 측정값 전체를 0으로 대체하지 않습니다. +- Overload NG도 일반 성능 NG와 동일한 경로로 처리합니다. 화면에는 해당 항목 `999`와 나머지 실측값을 유지하고, 각인·QR 스캔·DB 저장을 생략한 뒤 다음 검사 대기로 복귀합니다. 새 각인 순번을 소비하지 않습니다. +- DB 저장은 QR 검증까지 합격한 OK에만 허용합니다. DatabaseService도 NG를 SQL 연결·변환 전에 차단하므로 기존 OK 레코드는 NG 재검사로 덮어쓰지 않습니다. 현장 스키마의 세 계측 컬럼/SQL 매개변수는 `DECIMAL(5,2)`를 유지합니다. +- Overload 원문과 전체 계측 순서는 `debug` 폴더의 파일에 기록합니다. 이 진단 로그는 DB 결과 저장과 별개로 유지합니다. + --- ## 3. 유지보수 및 릴리즈 가이드 (Release & Maintenance) @@ -91,8 +99,17 @@ - `Sensor.ini` (4251 센서 통신 보드 COM 포트/속도/채널) - `libs/` (LMC 레이저 각인 드라이버 격리 디렉터리: `MarkEzdWrapper.dll` 등) - `logs/` (런타임 가동 시 일자별 자동 생성되는 시스템/각인/통신 이력 로그 폴더) +- `debug/` (오류 발생 시 자동 생성되는 오류별 진단 파일 폴더) - `publish_backup/` (빌드 배포 시 이전 릴리즈 자동 백업 폴더) +### 3.4. 오류 원인 추적용 진단 파일 +- `LoggerService.Error` 호출 시 실행 파일 폴더 아래 `debug/error_yyyyMMdd_HHmmss_fff_.log`를 UTF-8로 생성합니다. 폴더명과 파일명은 영문·숫자를 사용하고 로그 본문의 한글은 유지합니다. 일별 로그에는 해당 파일 경로를 기록합니다. +- 파일에는 발생 시각과 시간대, 프로그램 버전, 실행 환경, 예외 종류·내부 예외·스택 추적, 직전 로그 최대 100건을 기록합니다. 설정 파일 전체나 DB 비밀번호는 진단 정보로 수집하지 않습니다. +- 계측 오류에는 장비 주소·포트, 타임아웃·안정화 시간, 송신 명령, 수신 응답 원문, 전원 차단 명령 전송 결과를 추가합니다. 메인 화면의 계측 오류 기록에는 IC_SN·Lot_No·도안 경로도 포함됩니다. +- 일별 로그는 기존 30일 보관 정책을 유지하며, 오류별 진단 파일은 자동 삭제하지 않습니다. 폴더가 삭제되면 다음 기록 시 재생성합니다. 한쪽 저장에 실패해도 다른 쪽 기록을 시도하며, 진단 파일 저장 실패는 일별 로그에 남깁니다. +- 현장 오류 분석 시 발생 시각에 해당하는 진단 파일과 일별 로그를 함께 확인합니다. 이 기능은 원인 추적용이며 계측 판정·마킹·DB 저장 정책을 변경하지 않습니다. +- 실장비 없는 회귀 검증: `dotnet run --project tests/DiagnosticsRegression/DiagnosticsRegression.csproj` + --- ## 4. 데이터베이스 구성 가이드 (Database Configuration) @@ -110,7 +127,8 @@ - 고유 QR 코드 문자열 `Marking_QR` (NVARCHAR(60)): `IC_SN;도면품번;HKMC품번;각인번호;` 포맷으로 구성되며, 60자 초과 시 절삭합니다. - 공정 정보(`Maker`, `Model`, `Variant_1`, `Variant_2`, `Operator`, `Production_Date`, `Line`, `Lot_No`, `Jig_No`) 및 최종 판정(`Result`)이 통합 적재됩니다. 각 문자열 컬럼은 최대 10자로 초과 시 절삭 처리됩니다. - 3단계에 걸친 전압/전류/저항 컬럼(`PT_Vol_1~3`, `PT_Current_1~3`, `PT_Resistance_1~3`)이 존재하며, 현재는 1번 컬럼들에 최종 계측 수치(소수점 2~3자리 반올림)를 저장하고 2, 3번 컬럼은 `0.00`으로 기본값을 보정하여 저장합니다. - - `Result` 컬럼에는 최종 합격된 **`OK`** 레코드만 저장됩니다. (계측 불합격 또는 QR 검증 실패 등의 **`NG`**인 경우, 각인 순번이 불필요하게 카운트되는 것을 방지하기 위해 DB에 적재하지 않고 저장을 건너뜁니다.) + - `PT_Vol_1`, `PT_Current_1`, `PT_Resistance_1`의 현장 컬럼 규격은 모두 `DECIMAL(5,2)`이며 Overload 대체값 `999.00`을 수용합니다. + - `Result` 컬럼에는 QR 검증까지 최종 합격된 **`OK`** 레코드만 저장합니다. Overload NG, 일반 규격 이탈 NG, QR 검증 실패는 저장하지 않으며 기존 레코드도 변경하지 않습니다. 과거에 생성된 `NG:` QR은 순번 계산에서 제외합니다. - `Lot_No` 컬럼은 `YYMMDDLL` 형식(예: `26080401`)으로 입력되며 최대 **10자**까지 허용됩니다. ### 4.2. 로그인 검증 저장 프로시저 ([CheckOperator]) diff --git a/marking_gui/MainWindow.xaml.cs b/marking_gui/MainWindow.xaml.cs index a445d3a..1d56374 100644 --- a/marking_gui/MainWindow.xaml.cs +++ b/marking_gui/MainWindow.xaml.cs @@ -1120,7 +1120,7 @@ namespace marking_gui LoggerService.Warn($"[재작업 감지] IC S/N ({serial})가 이미 DB에 존재합니다. 작업자 확인 팝업 표시"); MessageBoxResult reworkChoice = MessageBox.Show( - string.Format("입력된 시리얼 번호({0})는 이미 검사 및 각인 이력이 존재하는 재작업 제품입니다.\n\n재작업 각인 및 검사를 계속 진행하시겠습니까?", serial), + string.Format("입력된 시리얼 번호({0})는 이미 검사 이력이 존재하는 재작업 제품입니다.\n\n재검사 및 합격 시 각인을 계속 진행하시겠습니까?", serial), "재작업 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning); @@ -1183,11 +1183,12 @@ namespace marking_gui double finalVoltage = 0.0; double finalCurrent = 0.0; double finalResistance = 0.0; + EquipmentMeasurementResult measurement = null; try { token.ThrowIfCancellationRequested(); - var measurement = await ReadMeasurementFromDeviceAsync(token); + measurement = await ReadMeasurementFromDeviceAsync(token); token.ThrowIfCancellationRequested(); // 계측 완료 후에도 즉시 취소 확인 finalVoltage = measurement.Voltage; finalCurrent = measurement.Current; @@ -1224,9 +1225,9 @@ namespace marking_gui txtResistance.Text = finalResistance.ToString("00.000"); // 합/불 판정 - bool isVoltageOk = finalVoltage >= _limits.MinVoltage && finalVoltage <= _limits.MaxVoltage; - bool isCurrentOk = finalCurrent >= _limits.MinCurrent && finalCurrent <= _limits.MaxCurrent; - bool isResistanceOk = finalResistance >= _limits.MinResistance && finalResistance <= _limits.MaxResistance; + bool isVoltageOk = !measurement.VoltageOverloaded && finalVoltage >= _limits.MinVoltage && finalVoltage <= _limits.MaxVoltage; + bool isCurrentOk = !measurement.CurrentOverloaded && finalCurrent >= _limits.MinCurrent && finalCurrent <= _limits.MaxCurrent; + bool isResistanceOk = !measurement.ResistanceOverloaded && finalResistance >= _limits.MinResistance && finalResistance <= _limits.MaxResistance; bool isFinalPass = isVoltageOk && isCurrentOk && isResistanceOk; // 측정값 색상 표시 (통과 시 검은색, 실패 시 빨간색) @@ -1534,24 +1535,25 @@ namespace marking_gui } else { - // 불합격인 경우 각인 및 DB 저장 생략 (순번 카운트 제외) + // Overload를 포함한 모든 성능 NG는 각인 및 DB 저장을 생략합니다. string markedSerial = GetCurrentMarkedSerial(); string drawingNo = _markingConfig.GetDrawingNoForEzd(_currentEzdFile); string hkmcNo = _markingConfig.GetHkmcNoForEzd(_currentEzdFile); - await SaveAndFinalizeAsync(serial, finalVoltage, finalCurrent, finalResistance, isFinalPass, "NG", markedSerial, drawingNo, hkmcNo, bypassPrevSteps: bypassPrevSteps); + await SaveAndFinalizeAsync(serial, finalVoltage, finalCurrent, finalResistance, isFinalPass, "NG", markedSerial, drawingNo, hkmcNo, + bypassPrevSteps: bypassPrevSteps); } } } - private async Task<(double Voltage, double Current, double Resistance)> ReadMeasurementFromDeviceAsync(CancellationToken token = default) + private async Task ReadMeasurementFromDeviceAsync(CancellationToken token = default) { try { token.ThrowIfCancellationRequested(); // EquipmentMeasurementService 를 통해 실시간 계측값을 획득하는 즉시 GUI에 반영 - var (voltage, current, resistance) = await _equipment.MeasureAsync((v, c, r) => + var measurement = await _equipment.MeasureAsync((v, c, r) => { Dispatcher.Invoke(() => { @@ -1571,7 +1573,7 @@ namespace marking_gui }); }); - return (voltage, current, resistance); + return measurement; } catch (OperationCanceledException) { @@ -1580,21 +1582,10 @@ namespace marking_gui } catch (Exception ex) { - LoggerService.Error("[계측기] 측정 중 오류 발생", ex); - // 장비 오류 시 UI에 오류 메시지 표시 후 기본값 반환 - Dispatcher.Invoke(() => - { - txtSystemStatus.Text = "계측기 오류: " + ex.Message; - txtSystemStatus.Foreground = new SolidColorBrush(Color.FromRgb(239, 68, 68)); - - if (txtErrorMessage != null) - { - txtErrorMessage.Text = "계측기 오류: " + ex.Message; - if (borderErrorBanner != null) - borderErrorBanner.Visibility = System.Windows.Visibility.Visible; - } - }); - return (0.00, 0.00, 0.00); + LoggerService.Error("[계측기] 측정 중 오류 발생", ex, + $"IC_SN={txtSerial.Text}, Lot_No={_loginInfo?.LotNo}, 도안={_currentEzdFile}"); + // 실제 통신/파싱 오류는 호출자가 처리하고, 이미 표시한 정상 계측값은 유지합니다. + throw; } } @@ -1629,7 +1620,7 @@ namespace marking_gui return string.Format("{0}{1:D4}", lotNo, seq); } - private async Task SaveAndFinalizeAsync(string serial, double voltage, double current, double resistance, bool isFinalPass, string result, string markedSerial, string drawingNo = "", string hkmcNo = "", bool bypassPrevSteps = false) + private async Task SaveAndFinalizeAsync(string serial, double voltage, double current, double resistance, bool isFinalPass, string result, string markedSerial, string drawingNo = "", string hkmcNo = "", bool bypassPrevSteps = false) { // 종료 시간 기록 및 UI 갱신 _testEndTime = DateTime.Now; @@ -1639,7 +1630,6 @@ namespace marking_gui if (!isOk) { - // === [NG 결과 처리 - DB 미저장 (순번 카운트 제외)] === LoggerService.Warn($"[검사 NG] S/N: {serial}, 결과: NG (isFinalPass: {isFinalPass}, result: {result}) -> DB 저장 생략 (순번 카운트 제외)"); txtFinalResult.Text = "NG"; @@ -1677,7 +1667,7 @@ namespace marking_gui return; } - // === [OK 결과 처리 - DB 저장 (순번 카운트)] === + // QR 검증까지 합격한 OK 결과만 저장합니다. if (txtProgressStatus != null) { txtProgressStatus.Text = "결과 데이터베이스 저장 중..."; diff --git a/marking_gui/Models/EquipmentMeasurementResult.cs b/marking_gui/Models/EquipmentMeasurementResult.cs new file mode 100644 index 0000000..9fdbe57 --- /dev/null +++ b/marking_gui/Models/EquipmentMeasurementResult.cs @@ -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; + } + } +} diff --git a/marking_gui/Services/DatabaseService.cs b/marking_gui/Services/DatabaseService.cs index aecce06..43abe06 100644 --- a/marking_gui/Services/DatabaseService.cs +++ b/marking_gui/Services/DatabaseService.cs @@ -269,7 +269,7 @@ namespace marking_gui.Services /// /// 최종 성능 검출 값을 데이터베이스에 저장합니다. - /// 전압과 전류 수치는 3,2 포맷(DECIMAL(3, 2) 등)에 대응하기 위해 소수점 이하 2자리로 반올림하여 처리합니다. + /// 세 계측값은 현장 테이블의 DECIMAL(5, 2)에 맞춰 저장합니다 (Overload: 999.00). /// public bool SaveProductResult( string serial, @@ -292,10 +292,17 @@ namespace marking_gui.Services string drawingNo = "", string hkmcNo = "") { - // 전압과 전류, 저항을 소수점 이하 2자리(3,2 포맷 규격)로 반올림 처리 - decimal roundedVoltage = Math.Round((decimal)voltage, 2); - decimal roundedCurrent = Math.Round((decimal)current, 2); - decimal roundedResistance = Math.Round((decimal)resistance, 3); + // Overload NG follows the same no-save rule as every other NG, even for direct callers. + if (result != "PASS" && result != "OK") + { + LoggerService.Info($"[DB 저장 생략] S/N: {serial}, 결과: {result} (NG는 저장하지 않음)"); + return false; + } + + var measurementParameters = CreateMeasurementParameters(voltage, current, resistance); + decimal roundedVoltage = (decimal)measurementParameters[0].Value; + decimal roundedCurrent = (decimal)measurementParameters[1].Value; + decimal roundedResistance = (decimal)measurementParameters[2].Value; // IC_SN 및 PCB_Barcode 컬럼 규격(NVARCHAR(30), NOT NULL) 방어 처리 if (string.IsNullOrWhiteSpace(serial)) @@ -306,7 +313,7 @@ namespace marking_gui.Services { serial = serial.Substring(0, 30); } - string safeResult = (result == "PASS" || result == "OK") ? "OK" : "NG"; + string safeResult = "OK"; // DB 컬럼 길이 한계에 맞춰서 문자열 안전하게 자르기 (Truncate) string safeMaker = string.IsNullOrEmpty(maker) ? "" : (maker.Length > 10 ? maker.Substring(0, 10) : maker); @@ -329,7 +336,7 @@ namespace marking_gui.Services // 오프라인 모드인 경우 실제 DB 저장을 생략하고 정상 통과 처리 if (_offlineMode) { - if (!string.IsNullOrWhiteSpace(safeLotNo) && !string.IsNullOrWhiteSpace(markedSerial) && markedSerial.Length >= 4) + if (safeResult == "OK" && !string.IsNullOrWhiteSpace(safeLotNo) && !string.IsNullOrWhiteSpace(markedSerial) && markedSerial.Length >= 4) { string seqStr = markedSerial.Substring(markedSerial.Length - 4); if (int.TryParse(seqStr, out int seqNum)) @@ -396,9 +403,7 @@ namespace marking_gui.Services cmd.Parameters.AddWithValue("@LotNo", safeLotNo); cmd.Parameters.AddWithValue("@JigNo", safeJigNo); - cmd.Parameters.Add(new System.Data.SqlClient.SqlParameter("@Vol1", System.Data.SqlDbType.Decimal) { Precision = 4, Scale = 2, Value = roundedVoltage }); - cmd.Parameters.Add(new System.Data.SqlClient.SqlParameter("@Cur1", System.Data.SqlDbType.Decimal) { Precision = 4, Scale = 2, Value = roundedCurrent }); - cmd.Parameters.Add(new System.Data.SqlClient.SqlParameter("@Res1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 3, Value = roundedResistance }); + cmd.Parameters.AddRange(measurementParameters); cmd.Parameters.AddWithValue("@Result", safeResult); conn.Open(); @@ -420,6 +425,17 @@ namespace marking_gui.Services throw; // 예외 원인을 상위로 전파하여 UI 상태창에 구체적인 오류 원인(ex.Message)이 그대로 노출되도록 함 } } + + internal static System.Data.SqlClient.SqlParameter[] CreateMeasurementParameters(double voltage, double current, double resistance) + { + return new[] + { + new System.Data.SqlClient.SqlParameter("@Vol1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 2, Value = Math.Round((decimal)voltage, 2) }, + new System.Data.SqlClient.SqlParameter("@Cur1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 2, Value = Math.Round((decimal)current, 2) }, + new System.Data.SqlClient.SqlParameter("@Res1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 2, Value = Math.Round((decimal)resistance, 2) } + }; + } + /// /// 지정한 LotNo로 기존에 저장된 제품들의 각인 순번 중 최대값을 구하여 다음 각인 일련번호(MAX + 1)를 구합니다. /// 동일 시료 재검사 시 DB 행이 MERGE UPDATE 되어 레코드 수(COUNT)가 증가하지 않더라도 순번은 지속 증가합니다. @@ -452,7 +468,8 @@ namespace marking_gui.Services ), 0) FROM [dbo].[Marking] WITH (NOLOCK) WHERE [Lot_No] = @LotNo - AND [Marking_QR] IS NOT NULL + AND [Marking_QR] IS NOT NULL + AND [Marking_QR] NOT LIKE N'NG:%' AND LEN(RTRIM([Marking_QR])) >= 4;"; using (var conn = new System.Data.SqlClient.SqlConnection(_connectionString)) diff --git a/marking_gui/Services/EquipmentMeasurementService.cs b/marking_gui/Services/EquipmentMeasurementService.cs index f6a2e65..c9b5755 100644 --- a/marking_gui/Services/EquipmentMeasurementService.cs +++ b/marking_gui/Services/EquipmentMeasurementService.cs @@ -5,6 +5,7 @@ using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using marking_gui.Models; namespace marking_gui.Services { @@ -149,16 +150,22 @@ namespace marking_gui.Services /// /// 전압 DMM + 저항 DMM + 전류 DMM(또는 PSU) 독립 계측기 3-장비 측정 시퀀스. /// - public async Task<(double Voltage, double Current, double Resistance)> MeasureAsync(Action onUpdate = null) + public async Task MeasureAsync(Action onUpdate = null) { var log = new StringBuilder(); - IScpiClient psuClient = GetPsuClient(); - IScpiClient voltageClient = GetVoltageClient(); - IScpiClient resistanceClient = GetResistanceDmmClient(); - IScpiClient currentDmmClient = GetCurrentDmmClient(); + IScpiClient psuClient = null; + Exception measurementError = null; + bool hasOverload = false; try { + log.AppendLine($"측정 시작: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff zzz}, Timeout={_timeoutMs}ms, Settle={_settleMs}ms"); + log.AppendLine($"PSU={_psu.Host}:{_psu.Port}, VoltageDMM={_voltageDmm.Host}:{_voltageDmm.Port}, ResistanceDMM={(_resistanceDmm.IsDisabled ? _voltageDmm.Host : _resistanceDmm.Host)}:{(_resistanceDmm.IsDisabled ? _voltageDmm.Port : _resistanceDmm.Port)}"); + psuClient = GetPsuClient(); + IScpiClient voltageClient = GetVoltageClient(); + IScpiClient resistanceClient = GetResistanceDmmClient(); + IScpiClient currentDmmClient = GetCurrentDmmClient(); + if (psuClient == null) throw new InvalidOperationException("PSU 파워서플라이 장치가 설정되지 않았거나 비활성화 상태입니다."); if (voltageClient == null) @@ -184,8 +191,10 @@ namespace marking_gui.Services string voltageReadCmd = !string.IsNullOrWhiteSpace(_voltageDmm.ReadCommand) ? _voltageDmm.ReadCommand : "MEAS:VOLT:DC?"; log.AppendLine($"> 전압 DMM SEND: {voltageReadCmd}"); string vResponse = await voltageClient.QueryAsync(voltageReadCmd); - log.AppendLine($" 응답: {vResponse.Trim()}"); - double voltage = (double)ExtractFirstDecimal(vResponse, "VOLTAGE"); + log.AppendLine($" 응답 원문: [{vResponse}]"); + var voltageReading = ParseMeasurement(vResponse, "VOLTAGE"); + double voltage = voltageReading.Value; + hasOverload |= voltageReading.Overloaded; // 실시간 전압 업데이트 onUpdate?.Invoke(voltage, null, null); @@ -201,9 +210,10 @@ namespace marking_gui.Services string resReadCmd = !string.IsNullOrWhiteSpace(_resistanceDmm.ReadCommand) ? _resistanceDmm.ReadCommand : _resistanceReadCommand; log.AppendLine($"> 저항 DMM SEND: {resReadCmd}"); string rResponse = await (resistanceClient ?? voltageClient).QueryAsync(resReadCmd); - log.AppendLine($" 응답: {rResponse.Trim()}"); - double rawResistance = (double)ExtractFirstDecimal(rResponse, "RESISTANCE"); - double resistance = rawResistance / 1000.0; // Ohms -> kOhms 변환 (kΩ 단위 표출) + log.AppendLine($" 응답 원문: [{rResponse}]"); + var resistanceReading = ParseMeasurement(rResponse, "RESISTANCE", 0.001); // Ohm -> kOhm + double resistance = resistanceReading.Value; + hasOverload |= resistanceReading.Overloaded; // 실시간 저항 업데이트 onUpdate?.Invoke(voltage, null, resistance); @@ -213,14 +223,21 @@ namespace marking_gui.Services string currentReadCmd = !string.IsNullOrWhiteSpace(_psu.ReadCommand) ? _psu.ReadCommand : "MEAS:CURR? (@1)"; log.AppendLine($"> PSU SEND: {currentReadCmd}"); string aResponse = await psuClient.QueryAsync(currentReadCmd); - log.AppendLine($" 응답: {aResponse.Trim()}"); - double rawCurrent = (double)ExtractFirstDecimal(aResponse, "CURRENT"); - double current = rawCurrent * 1000.0; // Amperes -> mA 변환 + log.AppendLine($" 응답 원문: [{aResponse}]"); + var currentReading = ParseMeasurement(aResponse, "CURRENT", 1000.0); // A -> mA + double current = currentReading.Value; + hasOverload |= currentReading.Overloaded; // 실시간 전류 및 최종 업데이트 onUpdate?.Invoke(voltage, current, resistance); - return (voltage, current, resistance); + return new EquipmentMeasurementResult(voltage, current, resistance, + voltageReading.Overloaded, currentReading.Overloaded, resistanceReading.Overloaded); + } + catch (Exception ex) + { + measurementError = ex; + throw; } finally { @@ -232,13 +249,20 @@ namespace marking_gui.Services string psuCleanup = !string.IsNullOrWhiteSpace(_psu.CleanupCommand) ? _psu.CleanupCommand : "OUTP OFF, (@1)"; log.AppendLine($"> PSU CLEANUP: {psuCleanup}"); await SendCommandListAsync(psuClient, psuCleanup); + log.AppendLine("> PSU CLEANUP 명령 전송 완료"); } catch (Exception ex) { log.AppendLine($"> PSU OUTP OFF FAILED: {ex.Message}"); + LoggerService.Error("[Equipment] PSU 전원 차단 명령 전송 실패", ex, log.ToString()); } } - LoggerService.Info("[Equipment] 독립 계측기 측정 시퀀스 로그:\n" + log); + if (measurementError != null) + LoggerService.Error("[Equipment] 계측 시퀀스 실패", measurementError, log.ToString()); + else if (hasOverload) + LoggerService.Error("[Equipment] Overload 항목을 999로 기록하고 나머지 계측 완료", context: log.ToString()); + else + LoggerService.Info("[Equipment] 독립 계측기 측정 시퀀스 로그:\n" + log); } } @@ -322,7 +346,8 @@ namespace marking_gui.Services } catch (Exception ex) { - LoggerService.Warn($"[Equipment] ResetAsync 실패 (PSU 통신 오류): {ex.Message}"); + LoggerService.Error("[Equipment] ResetAsync 실패 (PSU 통신 오류)", ex, + $"PSU={_psu.Host}:{_psu.Port}, CleanupCommand={_psu.CleanupCommand}"); } } @@ -344,10 +369,10 @@ namespace marking_gui.Services var client = label == "VOLTAGE" ? GetVoltageClient() : GetPsuClient(); if (client == null) throw new InvalidOperationException($"{label} 장치 클라이언트를 생성할 수 없습니다."); - return (double)await ReadChannelWithClientAsync(label, ch, client, log); + return await ReadChannelWithClientAsync(label, ch, client, log); } - private static async Task ReadChannelWithClientAsync( + private static async Task ReadChannelWithClientAsync( string label, ScpiChannelSettings ch, IScpiClient client, @@ -364,9 +389,9 @@ namespace marking_gui.Services log.AppendLine($"> {label} READ: {ch.ReadCommand}"); string response = await client.QueryAsync(ch.ReadCommand); - log.AppendLine($" 응답: {response.Trim()}"); + log.AppendLine($" 응답 원문: [{response}]"); - return ExtractFirstDecimal(response, label); + return ParseMeasurement(response, label).Value; } private IScpiClient CreateClient(string label, ScpiChannelSettings ch) @@ -395,19 +420,38 @@ namespace marking_gui.Services } } - private static decimal ExtractFirstDecimal(string response, string label) + private static (double Value, bool Overloaded) ParseMeasurement(string response, string label, double scale = 1.0) { if (string.IsNullOrWhiteSpace(response)) throw new InvalidOperationException($"[{label}] 계측기 응답이 비어있습니다 (응답 수신 실패)."); + string text = response.Trim(); + if (text.Equals("OL", StringComparison.OrdinalIgnoreCase) || + text.Equals("O.L", StringComparison.OrdinalIgnoreCase) || + text.Equals("OVERLOAD", StringComparison.OrdinalIgnoreCase) || + text.Equals("OVLD", StringComparison.OrdinalIgnoreCase)) + { + LoggerService.Warn($"[Equipment] {label} Overload -> 999, 원본 응답: [{response}]"); + return (EquipmentMeasurementResult.OverloadValue, true); + } + var match = Regex.Match(response, @"[-+]?\d+(?:\.\d+)?(?:[Ee][-+]?\d+)?"); if (!match.Success) throw new InvalidOperationException($"[{label}] 수치 파싱 실패. 원본 응답: [{response.Trim()}]"); - if (!decimal.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal val)) + if (!double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double val) || !double.IsFinite(val)) throw new InvalidOperationException($"[{label}] 수치 변환 실패 (값: '{match.Value}'). 원본 응답: [{response.Trim()}]"); - return val; + if (Math.Abs(val) >= 9.0e37) + { + LoggerService.Warn($"[Equipment] {label} Overload -> 999, 원본 응답: [{response}]"); + return (EquipmentMeasurementResult.OverloadValue, true); + } + + double scaled = val * scale; + if (!double.IsFinite(scaled)) + throw new InvalidOperationException($"[{label}] 단위 변환 실패. 원본 응답: [{response.Trim()}]"); + return (scaled, false); } // ── INI 파서 ──────────────────────────────────────────────────── diff --git a/marking_gui/Services/LoggerService.cs b/marking_gui/Services/LoggerService.cs index c3399e7..7b07b89 100644 --- a/marking_gui/Services/LoggerService.cs +++ b/marking_gui/Services/LoggerService.cs @@ -1,12 +1,18 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; +using System.Text; namespace marking_gui.Services { public static class LoggerService { private static readonly string LogDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + private static readonly string DiagnosticDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "debug"); private static readonly object LockObj = new object(); + private static readonly Queue RecentLogs = new Queue(); + private const int RecentLogLimit = 100; static LoggerService() { @@ -66,24 +72,68 @@ namespace marking_gui.Services WriteLog("WARN", message); } - public static void Error(string message, Exception ex = null) + public static void Error(string message, Exception ex = null, string context = null) { string detail = message; if (ex != null) { - detail += string.Format("\n[Exception] {0}\n[StackTrace]\n{1}", ex.Message, ex.StackTrace); + // ToString preserves exception types, inner exceptions and their stack traces. + detail += Environment.NewLine + ex; } - WriteLog("ERROR", detail); + WriteLog("ERROR", detail, context); } - private static void WriteLog(string level, string message) + private static void WriteLog(string level, string message, string context = null) { lock (LockObj) { + DateTime now = DateTime.Now; + string logLine = string.Format("[{0}] [{1}] {2}", now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"), level, message) + Environment.NewLine; + RecentLogs.Enqueue(logLine); + while (RecentLogs.Count > RecentLogLimit) RecentLogs.Dequeue(); + + // Keep diagnostic and daily writes independent so either can survive a failure in the other. + if (level == "ERROR") + { + try + { + Directory.CreateDirectory(DiagnosticDirectory); + string diagnosticFile = Path.Combine(DiagnosticDirectory, + $"error_{now:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.log"); + var report = new StringBuilder(); + report.AppendLine($"발생 시각: {now:yyyy-MM-dd HH:mm:ss.fff zzz}"); + report.AppendLine($"프로그램 버전: {typeof(LoggerService).Assembly.GetName().Version}"); + report.AppendLine($"실행 경로: {AppDomain.CurrentDomain.BaseDirectory}"); + report.AppendLine($"실행 환경: {RuntimeInformation.FrameworkDescription}, {RuntimeInformation.OSDescription}, {RuntimeInformation.ProcessArchitecture}"); + report.AppendLine($"프로세스 ID: {Environment.ProcessId}, 스레드 ID: {Environment.CurrentManagedThreadId}"); + report.AppendLine(); + report.AppendLine("[오류 상세]"); + report.AppendLine(message); + if (!string.IsNullOrWhiteSpace(context)) + { + report.AppendLine(); + report.AppendLine("[진단 정보]"); + report.AppendLine(context); + } + report.AppendLine(); + report.AppendLine($"[직전 로그 - 최대 {RecentLogLimit}건]"); + foreach (string recent in RecentLogs) report.Append(recent); + File.WriteAllText(diagnosticFile, report.ToString(), Encoding.UTF8); + logLine += "[진단 파일] " + diagnosticFile + Environment.NewLine; + } + catch (Exception ex) + { + logLine += "[진단 파일 저장 실패] " + ex.Message + Environment.NewLine; + System.Diagnostics.Debug.WriteLine("Writing diagnostic failed: " + ex.Message); + } + } + try { - string logFile = Path.Combine(LogDirectory, DateTime.Now.ToString("yyyy-MM-dd") + ".log"); - string logLine = string.Format("[{0}] [{1}] {2}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), level, message) + Environment.NewLine; + Directory.CreateDirectory(LogDirectory); + string logFile = Path.Combine(LogDirectory, now.ToString("yyyy-MM-dd") + ".log"); + if (!string.IsNullOrWhiteSpace(context)) + logLine += "[진단 정보] " + context + Environment.NewLine; File.AppendAllText(logFile, logLine); } catch (Exception ex) diff --git a/sql/database_setup.sql b/sql/database_setup.sql index d0fe5b8..469ec6b 100644 --- a/sql/database_setup.sql +++ b/sql/database_setup.sql @@ -74,9 +74,9 @@ BEGIN [Line] NVARCHAR(10) NOT NULL, [Lot_No] NVARCHAR(8) NOT NULL, [Jig_No] NVARCHAR(10) NOT NULL, - [PT_Vol_1] DECIMAL(4, 2) NOT NULL, - [PT_Current_1] DECIMAL(4, 2) NOT NULL, - [PT_Resistance_1] DECIMAL(5, 3) NOT NULL, + [PT_Vol_1] DECIMAL(5, 2) NOT NULL, + [PT_Current_1] DECIMAL(5, 2) NOT NULL, + [PT_Resistance_1] DECIMAL(5, 2) NOT NULL, [Result] NVARCHAR(5) NOT NULL, [Spare_1] NVARCHAR(30) NULL, [Spare_2] NVARCHAR(30) NULL, diff --git a/tests/DiagnosticsRegression/DiagnosticsRegression.csproj b/tests/DiagnosticsRegression/DiagnosticsRegression.csproj new file mode 100644 index 0000000..60ee592 --- /dev/null +++ b/tests/DiagnosticsRegression/DiagnosticsRegression.csproj @@ -0,0 +1,19 @@ + + + Exe + net9.0-windows + true + 10.0 + false + + + + + + + + + + + + diff --git a/tests/DiagnosticsRegression/Program.cs b/tests/DiagnosticsRegression/Program.cs new file mode 100644 index 0000000..119a990 --- /dev/null +++ b/tests/DiagnosticsRegression/Program.cs @@ -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(); + 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 Commands { get; } = new List(); + 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 QueryAsync(string command) { Commands.Add(command); return Task.FromResult(_response); } + public void Dispose() { } + } +} diff --git a/tests/DiagnosticsRegression/README.md b/tests/DiagnosticsRegression/README.md new file mode 100644 index 0000000..91f356b --- /dev/null +++ b/tests/DiagnosticsRegression/README.md @@ -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_.log` 형식이며 파일명은 ASCII 문자만 사용합니다. 테스트의 모든 장비 클라이언트는 가짜 구현이며 DB 테스트는 OfflineMode를 먼저 확인합니다.