Browse Source

오버로드 개별 처리 및 debug 진단 로그 추가

오버로드 항목만 999로 표시하고 다른 계측값은 유지합니다. 모든 NG의 각인과 DB 저장을 생략하며, 영문 파일명의 debug 진단 로그와 회귀 테스트를 추가합니다.
master
seuuung 1 week ago
parent
commit
78af1a184a
  1. 20
      docs/knowledge_base.md
  2. 46
      marking_gui/MainWindow.xaml.cs
  3. 27
      marking_gui/Models/EquipmentMeasurementResult.cs
  4. 37
      marking_gui/Services/DatabaseService.cs
  5. 90
      marking_gui/Services/EquipmentMeasurementService.cs
  6. 62
      marking_gui/Services/LoggerService.cs
  7. 6
      sql/database_setup.sql
  8. 19
      tests/DiagnosticsRegression/DiagnosticsRegression.csproj
  9. 268
      tests/DiagnosticsRegression/Program.cs
  10. 11
      tests/DiagnosticsRegression/README.md

20
docs/knowledge_base.md

@ -44,6 +44,14 @@
``` ```
- **ADO.NET StoredProcedure 연동:** `SqlCommand.CommandType = CommandType.StoredProcedure` 설정을 사용하여 `@Operator`, `@Password`, 그리고 선택적으로 `@ProcessName` 파라미터를 매핑하여 계정 검증 프로시저를 안전하게 가동합니다. - **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) ## 3. 유지보수 및 릴리즈 가이드 (Release & Maintenance)
@ -91,8 +99,17 @@
- `Sensor.ini` (4251 센서 통신 보드 COM 포트/속도/채널) - `Sensor.ini` (4251 센서 통신 보드 COM 포트/속도/채널)
- `libs/` (LMC 레이저 각인 드라이버 격리 디렉터리: `MarkEzdWrapper.dll` 등) - `libs/` (LMC 레이저 각인 드라이버 격리 디렉터리: `MarkEzdWrapper.dll` 등)
- `logs/` (런타임 가동 시 일자별 자동 생성되는 시스템/각인/통신 이력 로그 폴더) - `logs/` (런타임 가동 시 일자별 자동 생성되는 시스템/각인/통신 이력 로그 폴더)
- `debug/` (오류 발생 시 자동 생성되는 오류별 진단 파일 폴더)
- `publish_backup/` (빌드 배포 시 이전 릴리즈 자동 백업 폴더) - `publish_backup/` (빌드 배포 시 이전 릴리즈 자동 백업 폴더)
### 3.4. 오류 원인 추적용 진단 파일
- `LoggerService.Error` 호출 시 실행 파일 폴더 아래 `debug/error_yyyyMMdd_HHmmss_fff_<id>.log`를 UTF-8로 생성합니다. 폴더명과 파일명은 영문·숫자를 사용하고 로그 본문의 한글은 유지합니다. 일별 로그에는 해당 파일 경로를 기록합니다.
- 파일에는 발생 시각과 시간대, 프로그램 버전, 실행 환경, 예외 종류·내부 예외·스택 추적, 직전 로그 최대 100건을 기록합니다. 설정 파일 전체나 DB 비밀번호는 진단 정보로 수집하지 않습니다.
- 계측 오류에는 장비 주소·포트, 타임아웃·안정화 시간, 송신 명령, 수신 응답 원문, 전원 차단 명령 전송 결과를 추가합니다. 메인 화면의 계측 오류 기록에는 IC_SN·Lot_No·도안 경로도 포함됩니다.
- 일별 로그는 기존 30일 보관 정책을 유지하며, 오류별 진단 파일은 자동 삭제하지 않습니다. 폴더가 삭제되면 다음 기록 시 재생성합니다. 한쪽 저장에 실패해도 다른 쪽 기록을 시도하며, 진단 파일 저장 실패는 일별 로그에 남깁니다.
- 현장 오류 분석 시 발생 시각에 해당하는 진단 파일과 일별 로그를 함께 확인합니다. 이 기능은 원인 추적용이며 계측 판정·마킹·DB 저장 정책을 변경하지 않습니다.
- 실장비 없는 회귀 검증: `dotnet run --project tests/DiagnosticsRegression/DiagnosticsRegression.csproj`
--- ---
## 4. 데이터베이스 구성 가이드 (Database Configuration) ## 4. 데이터베이스 구성 가이드 (Database Configuration)
@ -110,7 +127,8 @@
- 고유 QR 코드 문자열 `Marking_QR` (NVARCHAR(60)): `IC_SN;도면품번;HKMC품번;각인번호;` 포맷으로 구성되며, 60자 초과 시 절삭합니다. - 고유 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자로 초과 시 절삭 처리됩니다. - 공정 정보(`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`으로 기본값을 보정하여 저장합니다. - 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:<IC_SN>` QR은 순번 계산에서 제외합니다.
- `Lot_No` 컬럼은 `YYMMDDLL` 형식(예: `26080401`)으로 입력되며 최대 **10자**까지 허용됩니다. - `Lot_No` 컬럼은 `YYMMDDLL` 형식(예: `26080401`)으로 입력되며 최대 **10자**까지 허용됩니다.
### 4.2. 로그인 검증 저장 프로시저 ([CheckOperator]) ### 4.2. 로그인 검증 저장 프로시저 ([CheckOperator])

46
marking_gui/MainWindow.xaml.cs

@ -1120,7 +1120,7 @@ namespace marking_gui
LoggerService.Warn($"[재작업 감지] IC S/N ({serial})가 이미 DB에 존재합니다. 작업자 확인 팝업 표시"); LoggerService.Warn($"[재작업 감지] IC S/N ({serial})가 이미 DB에 존재합니다. 작업자 확인 팝업 표시");
MessageBoxResult reworkChoice = MessageBox.Show( MessageBoxResult reworkChoice = MessageBox.Show(
string.Format("입력된 시리얼 번호({0})는 이미 검사 및 각인 이력이 존재하는 재작업 제품입니다.\n\n재작업 각인 및 검사를 계속 진행하시겠습니까?", serial), string.Format("입력된 시리얼 번호({0})는 이미 검사 이력이 존재하는 재작업 제품입니다.\n\n재검사 및 합격 시 각인을 계속 진행하시겠습니까?", serial),
"재작업 확인", "재작업 확인",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Warning); MessageBoxImage.Warning);
@ -1183,11 +1183,12 @@ namespace marking_gui
double finalVoltage = 0.0; double finalVoltage = 0.0;
double finalCurrent = 0.0; double finalCurrent = 0.0;
double finalResistance = 0.0; double finalResistance = 0.0;
EquipmentMeasurementResult measurement = null;
try try
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var measurement = await ReadMeasurementFromDeviceAsync(token); measurement = await ReadMeasurementFromDeviceAsync(token);
token.ThrowIfCancellationRequested(); // 계측 완료 후에도 즉시 취소 확인 token.ThrowIfCancellationRequested(); // 계측 완료 후에도 즉시 취소 확인
finalVoltage = measurement.Voltage; finalVoltage = measurement.Voltage;
finalCurrent = measurement.Current; finalCurrent = measurement.Current;
@ -1224,9 +1225,9 @@ namespace marking_gui
txtResistance.Text = finalResistance.ToString("00.000"); txtResistance.Text = finalResistance.ToString("00.000");
// 합/불 판정 // 합/불 판정
bool isVoltageOk = finalVoltage >= _limits.MinVoltage && finalVoltage <= _limits.MaxVoltage; bool isVoltageOk = !measurement.VoltageOverloaded && finalVoltage >= _limits.MinVoltage && finalVoltage <= _limits.MaxVoltage;
bool isCurrentOk = finalCurrent >= _limits.MinCurrent && finalCurrent <= _limits.MaxCurrent; bool isCurrentOk = !measurement.CurrentOverloaded && finalCurrent >= _limits.MinCurrent && finalCurrent <= _limits.MaxCurrent;
bool isResistanceOk = finalResistance >= _limits.MinResistance && finalResistance <= _limits.MaxResistance; bool isResistanceOk = !measurement.ResistanceOverloaded && finalResistance >= _limits.MinResistance && finalResistance <= _limits.MaxResistance;
bool isFinalPass = isVoltageOk && isCurrentOk && isResistanceOk; bool isFinalPass = isVoltageOk && isCurrentOk && isResistanceOk;
// 측정값 색상 표시 (통과 시 검은색, 실패 시 빨간색) // 측정값 색상 표시 (통과 시 검은색, 실패 시 빨간색)
@ -1534,24 +1535,25 @@ namespace marking_gui
} }
else else
{ {
// 불합격인 경우 각인 및 DB 저장 생략 (순번 카운트 제외) // Overload를 포함한 모든 성능 NG는 각인 및 DB 저장을 생략합니다.
string markedSerial = GetCurrentMarkedSerial(); string markedSerial = GetCurrentMarkedSerial();
string drawingNo = _markingConfig.GetDrawingNoForEzd(_currentEzdFile); string drawingNo = _markingConfig.GetDrawingNoForEzd(_currentEzdFile);
string hkmcNo = _markingConfig.GetHkmcNoForEzd(_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<EquipmentMeasurementResult> ReadMeasurementFromDeviceAsync(CancellationToken token = default)
{ {
try try
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
// EquipmentMeasurementService 를 통해 실시간 계측값을 획득하는 즉시 GUI에 반영 // EquipmentMeasurementService 를 통해 실시간 계측값을 획득하는 즉시 GUI에 반영
var (voltage, current, resistance) = await _equipment.MeasureAsync((v, c, r) => var measurement = await _equipment.MeasureAsync((v, c, r) =>
{ {
Dispatcher.Invoke(() => Dispatcher.Invoke(() =>
{ {
@ -1571,7 +1573,7 @@ namespace marking_gui
}); });
}); });
return (voltage, current, resistance); return measurement;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@ -1580,21 +1582,10 @@ namespace marking_gui
} }
catch (Exception ex) catch (Exception ex)
{ {
LoggerService.Error("[계측기] 측정 중 오류 발생", ex); LoggerService.Error("[계측기] 측정 중 오류 발생", ex,
// 장비 오류 시 UI에 오류 메시지 표시 후 기본값 반환 $"IC_SN={txtSerial.Text}, Lot_No={_loginInfo?.LotNo}, 도안={_currentEzdFile}");
Dispatcher.Invoke(() => // 실제 통신/파싱 오류는 호출자가 처리하고, 이미 표시한 정상 계측값은 유지합니다.
{ throw;
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);
} }
} }
@ -1629,7 +1620,7 @@ namespace marking_gui
return string.Format("{0}{1:D4}", lotNo, seq); 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 갱신 // 종료 시간 기록 및 UI 갱신
_testEndTime = DateTime.Now; _testEndTime = DateTime.Now;
@ -1639,7 +1630,6 @@ namespace marking_gui
if (!isOk) if (!isOk)
{ {
// === [NG 결과 처리 - DB 미저장 (순번 카운트 제외)] ===
LoggerService.Warn($"[검사 NG] S/N: {serial}, 결과: NG (isFinalPass: {isFinalPass}, result: {result}) -> DB 저장 생략 (순번 카운트 제외)"); LoggerService.Warn($"[검사 NG] S/N: {serial}, 결과: NG (isFinalPass: {isFinalPass}, result: {result}) -> DB 저장 생략 (순번 카운트 제외)");
txtFinalResult.Text = "NG"; txtFinalResult.Text = "NG";
@ -1677,7 +1667,7 @@ namespace marking_gui
return; return;
} }
// === [OK 결과 처리 - DB 저장 (순번 카운트)] === // QR 검증까지 합격한 OK 결과만 저장합니다.
if (txtProgressStatus != null) if (txtProgressStatus != null)
{ {
txtProgressStatus.Text = "결과 데이터베이스 저장 중..."; txtProgressStatus.Text = "결과 데이터베이스 저장 중...";

27
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;
}
}
}

37
marking_gui/Services/DatabaseService.cs

@ -269,7 +269,7 @@ namespace marking_gui.Services
/// <summary> /// <summary>
/// 최종 성능 검출 값을 데이터베이스에 저장합니다. /// 최종 성능 검출 값을 데이터베이스에 저장합니다.
/// 전압과 전류 수치는 3,2 포맷(DECIMAL(3, 2) 등)에 대응하기 위해 소수점 이하 2자리로 반올림하여 처리합니다. /// 세 계측값은 현장 테이블의 DECIMAL(5, 2)에 맞춰 저장합니다 (Overload: 999.00).
/// </summary> /// </summary>
public bool SaveProductResult( public bool SaveProductResult(
string serial, string serial,
@ -292,10 +292,17 @@ namespace marking_gui.Services
string drawingNo = "", string drawingNo = "",
string hkmcNo = "") string hkmcNo = "")
{ {
// 전압과 전류, 저항을 소수점 이하 2자리(3,2 포맷 규격)로 반올림 처리 // Overload NG follows the same no-save rule as every other NG, even for direct callers.
decimal roundedVoltage = Math.Round((decimal)voltage, 2); if (result != "PASS" && result != "OK")
decimal roundedCurrent = Math.Round((decimal)current, 2); {
decimal roundedResistance = Math.Round((decimal)resistance, 3); 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) 방어 처리 // IC_SN 및 PCB_Barcode 컬럼 규격(NVARCHAR(30), NOT NULL) 방어 처리
if (string.IsNullOrWhiteSpace(serial)) if (string.IsNullOrWhiteSpace(serial))
@ -306,7 +313,7 @@ namespace marking_gui.Services
{ {
serial = serial.Substring(0, 30); serial = serial.Substring(0, 30);
} }
string safeResult = (result == "PASS" || result == "OK") ? "OK" : "NG"; string safeResult = "OK";
// DB 컬럼 길이 한계에 맞춰서 문자열 안전하게 자르기 (Truncate) // DB 컬럼 길이 한계에 맞춰서 문자열 안전하게 자르기 (Truncate)
string safeMaker = string.IsNullOrEmpty(maker) ? "" : (maker.Length > 10 ? maker.Substring(0, 10) : maker); string safeMaker = string.IsNullOrEmpty(maker) ? "" : (maker.Length > 10 ? maker.Substring(0, 10) : maker);
@ -329,7 +336,7 @@ namespace marking_gui.Services
// 오프라인 모드인 경우 실제 DB 저장을 생략하고 정상 통과 처리 // 오프라인 모드인 경우 실제 DB 저장을 생략하고 정상 통과 처리
if (_offlineMode) 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); string seqStr = markedSerial.Substring(markedSerial.Length - 4);
if (int.TryParse(seqStr, out int seqNum)) if (int.TryParse(seqStr, out int seqNum))
@ -396,9 +403,7 @@ namespace marking_gui.Services
cmd.Parameters.AddWithValue("@LotNo", safeLotNo); cmd.Parameters.AddWithValue("@LotNo", safeLotNo);
cmd.Parameters.AddWithValue("@JigNo", safeJigNo); 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.AddRange(measurementParameters);
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.AddWithValue("@Result", safeResult); cmd.Parameters.AddWithValue("@Result", safeResult);
conn.Open(); conn.Open();
@ -420,6 +425,17 @@ namespace marking_gui.Services
throw; // 예외 원인을 상위로 전파하여 UI 상태창에 구체적인 오류 원인(ex.Message)이 그대로 노출되도록 함 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) }
};
}
/// <summary> /// <summary>
/// 지정한 LotNo로 기존에 저장된 제품들의 각인 순번 중 최대값을 구하여 다음 각인 일련번호(MAX + 1)를 구합니다. /// 지정한 LotNo로 기존에 저장된 제품들의 각인 순번 중 최대값을 구하여 다음 각인 일련번호(MAX + 1)를 구합니다.
/// 동일 시료 재검사 시 DB 행이 MERGE UPDATE 되어 레코드 수(COUNT)가 증가하지 않더라도 순번은 지속 증가합니다. /// 동일 시료 재검사 시 DB 행이 MERGE UPDATE 되어 레코드 수(COUNT)가 증가하지 않더라도 순번은 지속 증가합니다.
@ -453,6 +469,7 @@ namespace marking_gui.Services
FROM [dbo].[Marking] WITH (NOLOCK) FROM [dbo].[Marking] WITH (NOLOCK)
WHERE [Lot_No] = @LotNo 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;"; AND LEN(RTRIM([Marking_QR])) >= 4;";
using (var conn = new System.Data.SqlClient.SqlConnection(_connectionString)) using (var conn = new System.Data.SqlClient.SqlConnection(_connectionString))

90
marking_gui/Services/EquipmentMeasurementService.cs

@ -5,6 +5,7 @@ using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using marking_gui.Models;
namespace marking_gui.Services namespace marking_gui.Services
{ {
@ -149,16 +150,22 @@ namespace marking_gui.Services
/// <summary> /// <summary>
/// 전압 DMM + 저항 DMM + 전류 DMM(또는 PSU) 독립 계측기 3-장비 측정 시퀀스. /// 전압 DMM + 저항 DMM + 전류 DMM(또는 PSU) 독립 계측기 3-장비 측정 시퀀스.
/// </summary> /// </summary>
public async Task<(double Voltage, double Current, double Resistance)> MeasureAsync(Action<double?, double?, double?> onUpdate = null) public async Task<EquipmentMeasurementResult> MeasureAsync(Action<double?, double?, double?> onUpdate = null)
{ {
var log = new StringBuilder(); var log = new StringBuilder();
IScpiClient psuClient = GetPsuClient(); IScpiClient psuClient = null;
IScpiClient voltageClient = GetVoltageClient(); Exception measurementError = null;
IScpiClient resistanceClient = GetResistanceDmmClient(); bool hasOverload = false;
IScpiClient currentDmmClient = GetCurrentDmmClient();
try 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) if (psuClient == null)
throw new InvalidOperationException("PSU 파워서플라이 장치가 설정되지 않았거나 비활성화 상태입니다."); throw new InvalidOperationException("PSU 파워서플라이 장치가 설정되지 않았거나 비활성화 상태입니다.");
if (voltageClient == null) if (voltageClient == null)
@ -184,8 +191,10 @@ namespace marking_gui.Services
string voltageReadCmd = !string.IsNullOrWhiteSpace(_voltageDmm.ReadCommand) ? _voltageDmm.ReadCommand : "MEAS:VOLT:DC?"; string voltageReadCmd = !string.IsNullOrWhiteSpace(_voltageDmm.ReadCommand) ? _voltageDmm.ReadCommand : "MEAS:VOLT:DC?";
log.AppendLine($"> 전압 DMM SEND: {voltageReadCmd}"); log.AppendLine($"> 전압 DMM SEND: {voltageReadCmd}");
string vResponse = await voltageClient.QueryAsync(voltageReadCmd); string vResponse = await voltageClient.QueryAsync(voltageReadCmd);
log.AppendLine($" 응답: {vResponse.Trim()}"); log.AppendLine($" 응답 원문: [{vResponse}]");
double voltage = (double)ExtractFirstDecimal(vResponse, "VOLTAGE"); var voltageReading = ParseMeasurement(vResponse, "VOLTAGE");
double voltage = voltageReading.Value;
hasOverload |= voltageReading.Overloaded;
// 실시간 전압 업데이트 // 실시간 전압 업데이트
onUpdate?.Invoke(voltage, null, null); onUpdate?.Invoke(voltage, null, null);
@ -201,9 +210,10 @@ namespace marking_gui.Services
string resReadCmd = !string.IsNullOrWhiteSpace(_resistanceDmm.ReadCommand) ? _resistanceDmm.ReadCommand : _resistanceReadCommand; string resReadCmd = !string.IsNullOrWhiteSpace(_resistanceDmm.ReadCommand) ? _resistanceDmm.ReadCommand : _resistanceReadCommand;
log.AppendLine($"> 저항 DMM SEND: {resReadCmd}"); log.AppendLine($"> 저항 DMM SEND: {resReadCmd}");
string rResponse = await (resistanceClient ?? voltageClient).QueryAsync(resReadCmd); string rResponse = await (resistanceClient ?? voltageClient).QueryAsync(resReadCmd);
log.AppendLine($" 응답: {rResponse.Trim()}"); log.AppendLine($" 응답 원문: [{rResponse}]");
double rawResistance = (double)ExtractFirstDecimal(rResponse, "RESISTANCE"); var resistanceReading = ParseMeasurement(rResponse, "RESISTANCE", 0.001); // Ohm -> kOhm
double resistance = rawResistance / 1000.0; // Ohms -> kOhms 변환 (kΩ 단위 표출) double resistance = resistanceReading.Value;
hasOverload |= resistanceReading.Overloaded;
// 실시간 저항 업데이트 // 실시간 저항 업데이트
onUpdate?.Invoke(voltage, null, resistance); onUpdate?.Invoke(voltage, null, resistance);
@ -213,14 +223,21 @@ namespace marking_gui.Services
string currentReadCmd = !string.IsNullOrWhiteSpace(_psu.ReadCommand) ? _psu.ReadCommand : "MEAS:CURR? (@1)"; string currentReadCmd = !string.IsNullOrWhiteSpace(_psu.ReadCommand) ? _psu.ReadCommand : "MEAS:CURR? (@1)";
log.AppendLine($"> PSU SEND: {currentReadCmd}"); log.AppendLine($"> PSU SEND: {currentReadCmd}");
string aResponse = await psuClient.QueryAsync(currentReadCmd); string aResponse = await psuClient.QueryAsync(currentReadCmd);
log.AppendLine($" 응답: {aResponse.Trim()}"); log.AppendLine($" 응답 원문: [{aResponse}]");
double rawCurrent = (double)ExtractFirstDecimal(aResponse, "CURRENT"); var currentReading = ParseMeasurement(aResponse, "CURRENT", 1000.0); // A -> mA
double current = rawCurrent * 1000.0; // Amperes -> mA 변환 double current = currentReading.Value;
hasOverload |= currentReading.Overloaded;
// 실시간 전류 및 최종 업데이트 // 실시간 전류 및 최종 업데이트
onUpdate?.Invoke(voltage, current, resistance); 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 finally
{ {
@ -232,13 +249,20 @@ namespace marking_gui.Services
string psuCleanup = !string.IsNullOrWhiteSpace(_psu.CleanupCommand) ? _psu.CleanupCommand : "OUTP OFF, (@1)"; string psuCleanup = !string.IsNullOrWhiteSpace(_psu.CleanupCommand) ? _psu.CleanupCommand : "OUTP OFF, (@1)";
log.AppendLine($"> PSU CLEANUP: {psuCleanup}"); log.AppendLine($"> PSU CLEANUP: {psuCleanup}");
await SendCommandListAsync(psuClient, psuCleanup); await SendCommandListAsync(psuClient, psuCleanup);
log.AppendLine("> PSU CLEANUP 명령 전송 완료");
} }
catch (Exception ex) catch (Exception ex)
{ {
log.AppendLine($"> PSU OUTP OFF FAILED: {ex.Message}"); 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) 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(); var client = label == "VOLTAGE" ? GetVoltageClient() : GetPsuClient();
if (client == null) throw new InvalidOperationException($"{label} 장치 클라이언트를 생성할 수 없습니다."); 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<decimal> ReadChannelWithClientAsync( private static async Task<double> ReadChannelWithClientAsync(
string label, string label,
ScpiChannelSettings ch, ScpiChannelSettings ch,
IScpiClient client, IScpiClient client,
@ -364,9 +389,9 @@ namespace marking_gui.Services
log.AppendLine($"> {label} READ: {ch.ReadCommand}"); log.AppendLine($"> {label} READ: {ch.ReadCommand}");
string response = await client.QueryAsync(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) 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)) if (string.IsNullOrWhiteSpace(response))
throw new InvalidOperationException($"[{label}] 계측기 응답이 비어있습니다 (응답 수신 실패)."); 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+)?"); var match = Regex.Match(response, @"[-+]?\d+(?:\.\d+)?(?:[Ee][-+]?\d+)?");
if (!match.Success) if (!match.Success)
throw new InvalidOperationException($"[{label}] 수치 파싱 실패. 원본 응답: [{response.Trim()}]"); 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()}]"); 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 파서 ──────────────────────────────────────────────────── // ── INI 파서 ────────────────────────────────────────────────────

62
marking_gui/Services/LoggerService.cs

@ -1,12 +1,18 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace marking_gui.Services namespace marking_gui.Services
{ {
public static class LoggerService public static class LoggerService
{ {
private static readonly string LogDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); 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 object LockObj = new object();
private static readonly Queue<string> RecentLogs = new Queue<string>();
private const int RecentLogLimit = 100;
static LoggerService() static LoggerService()
{ {
@ -66,24 +72,68 @@ namespace marking_gui.Services
WriteLog("WARN", message); 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; string detail = message;
if (ex != null) 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) 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 try
{ {
string logFile = Path.Combine(LogDirectory, DateTime.Now.ToString("yyyy-MM-dd") + ".log"); Directory.CreateDirectory(LogDirectory);
string logLine = string.Format("[{0}] [{1}] {2}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), level, message) + Environment.NewLine; string logFile = Path.Combine(LogDirectory, now.ToString("yyyy-MM-dd") + ".log");
if (!string.IsNullOrWhiteSpace(context))
logLine += "[진단 정보] " + context + Environment.NewLine;
File.AppendAllText(logFile, logLine); File.AppendAllText(logFile, logLine);
} }
catch (Exception ex) catch (Exception ex)

6
sql/database_setup.sql

@ -74,9 +74,9 @@ BEGIN
[Line] NVARCHAR(10) NOT NULL, [Line] NVARCHAR(10) NOT NULL,
[Lot_No] NVARCHAR(8) NOT NULL, [Lot_No] NVARCHAR(8) NOT NULL,
[Jig_No] NVARCHAR(10) NOT NULL, [Jig_No] NVARCHAR(10) NOT NULL,
[PT_Vol_1] DECIMAL(4, 2) NOT NULL, [PT_Vol_1] DECIMAL(5, 2) NOT NULL,
[PT_Current_1] DECIMAL(4, 2) NOT NULL, [PT_Current_1] DECIMAL(5, 2) NOT NULL,
[PT_Resistance_1] DECIMAL(5, 3) NOT NULL, [PT_Resistance_1] DECIMAL(5, 2) NOT NULL,
[Result] NVARCHAR(5) NOT NULL, [Result] NVARCHAR(5) NOT NULL,
[Spare_1] NVARCHAR(30) NULL, [Spare_1] NVARCHAR(30) NULL,
[Spare_2] NVARCHAR(30) NULL, [Spare_2] NVARCHAR(30) NULL,

19
tests/DiagnosticsRegression/DiagnosticsRegression.csproj

@ -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>

268
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<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() { }
}
}

11
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_<id>.log` 형식이며 파일명은 ASCII 문자만 사용합니다. 테스트의 모든 장비 클라이언트는 가짜 구현이며 DB 테스트는 OfflineMode를 먼저 확인합니다.
Loading…
Cancel
Save