using System.Collections.Concurrent; using System.Data; using System.Globalization; using System.IO; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using Housing; using Housing.Models; using Housing.Services; using Microsoft.Data.SqlClient; // Offline checks only: no real instruments, login, scanner, or database are started. internal static class Program { private static int _checks; [STAThread] private static int Main(string[] args) { try { var output = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(AppContext.BaseDirectory, "checks")); Directory.CreateDirectory(output); CheckParserAsync().GetAwaiter().GetResult(); CheckJudgementAndSettings(output); CheckDatabaseParameters(); CheckEquipmentAsync().GetAwaiter().GetResult(); RenderAndCheckUi(output); Console.WriteLine($"PASS: {_checks} checks. UI previews: {output}"); return 0; } catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } } private static void Check(bool condition, string description) { if (!condition) throw new InvalidOperationException("FAIL: " + description); _checks++; } private static async Task ExpectFailureAsync(Func action, string description) { try { await action(); } catch (Exception ex) when (ex is InvalidOperationException or TimeoutException) { _checks++; return; } throw new InvalidOperationException("FAIL: " + description); } private static async Task CheckParserAsync() { foreach (var (raw, expected) in new[] { ("+4.700000E+03", 4.7m), (".5", 0.0005m), ("0", 0m), ("999000", 999m) }) { var result = await ScpiMeasurement.ReadResistanceAsync(command => { Check(command == "MEAS:RES?", "default resistance command"); return Task.FromResult(raw); }); Check(result.Value == expected && !result.Overloaded, "Ohm to kOhm: " + raw); } foreach (var raw in new[] { "OL", "o.l", "OVERLOAD", "OVLD", "+9.9E37", "-9.0E37" }) { var result = await ScpiMeasurement.ReadResistanceAsync(_ => Task.FromResult(raw)); Check(result == (999m, true), "unscaled overload marker: " + raw); } foreach (var raw in new[] { "", "bad response", "NaN", "Infinity", "1e999", "ERROR 100", "-113,\"Undefined header\"", "abc4700", "4.7 kOhm" }) await ExpectFailureAsync(async () => { await ScpiMeasurement.ReadResistanceAsync(_ => Task.FromResult(raw)); }, "invalid response must fail"); Check((await ScpiMeasurement.ReadResistanceAsync(_ => Task.FromResult("4700 OHM"))).Value == 4.7m, "base-unit resistance response"); Check(ScpiMeasurement.ParseResponse("5.02 V", "VOLTAGE").Value == 5.02m, "base-unit voltage response"); Check(ScpiMeasurement.ParseResponse(".1184 A", "CURRENT").Value == 0.1184m, "base-unit current response"); await ExpectFailureAsync(() => Task.FromResult(ScpiMeasurement.ParseResponse("0.12 mA", "CURRENT")), "mA must not be interpreted as A"); await ExpectFailureAsync(async () => { await ScpiMeasurement.ReadResistanceAsync(_ => throw new TimeoutException()); }, "communication failure is not overload"); await ScpiMeasurement.ReadResistanceAsync(command => { Check(command == "READ?", "custom resistance command"); return Task.FromResult("4700"); }, "READ?"); } private static InspectionJudgementSettings Criteria() => new() { VMin = 4.5m, VMax = 5.5m, AMin = 0.08m, AMax = 0.15m, RMin = 4m, RMax = 5m }; private static void CheckJudgementAndSettings(string output) { var criteria = Criteria(); Check(criteria.IsPass(5m, 0.12m, 4.7m), "all three measurements pass"); Check(criteria.IsResistancePass(4m) && criteria.IsResistancePass(5m), "inclusive resistance bounds"); Check(!criteria.IsPass(5m, 0.12m, 5.001m), "resistance changes overall result to NG"); criteria.RMax = 4.70m; Check(!criteria.IsResistancePass(4.704m), "raw resistance above a 4.70 kOhm limit remains NG even when it displays as 4.70"); criteria.RMax = 5m; Check(!criteria.IsResistancePass(null), "unmeasured resistance cannot pass"); criteria.RMax = 1000m; Check(criteria.IsResistancePass(999m) && !criteria.IsResistancePass(999m, true), "real 999 kOhm differs from overload"); Check(!criteria.IsResistancePass(999.995m) && ResistanceStorage.ExceedsRange(999.995m), "database overflow is NG even within user limits"); Check(!ResistanceStorage.ExceedsRange(999.99m) && ResistanceStorage.ToDatabaseValue(999.99m) == 999.99m, "database range boundary"); Check(ResistanceStorage.ExceedsRange(999.994m) && ResistanceStorage.ExceedsRange(-999.991m), "raw values outside database range are rejected"); Check(ResistanceStorage.ToDatabaseValue(999.995m) is null && ResistanceStorage.ToDatabaseValue(null) is null, "overflow and missing values become SQL NULL"); criteria.RMin = 10m; criteria.RMax = 1m; Check(!criteria.IsResistancePass(4.7m), "invalid bounds cannot pass"); var path = Path.Combine(output, "settings.ini"); File.WriteAllText(path, "[Judgement]\nVMin=4.5\nVMax=5.5\nAMin=0.08\nAMax=0.15\n"); var legacy = InspectionSettingsStore.Load(path); Check(legacy.RMin is null && legacy.RMax is null && !legacy.IsResistancePass(4.7m), "legacy settings require resistance limits"); InspectionSettingsStore.Save(path, Criteria()); var saved = InspectionSettingsStore.Load(path); Check(saved.RMin == 4m && saved.RMax == 5m && saved.AMax == 0.15m, "settings round trip preserves units"); var precise = Criteria(); precise.RMin = 4.0000000000000000000000000001m; precise.RMax = 4.9999999999999999999999999999m; InspectionSettingsStore.Save(path, precise); var reloaded = InspectionSettingsStore.Load(path); Check(reloaded.RMin == precise.RMin && reloaded.RMax == precise.RMax, "resistance limits preserve all decimal precision"); var hardwarePath = Path.Combine(AppContext.BaseDirectory, "Hardware.ini"); var hardware = EquipmentMeasurementSettings.Load(hardwarePath); Check(hardware.Resistance.Host == "192.168.10.34" && hardware.Voltage.Host == "192.168.10.13", "separate configured instrument addresses"); Check(hardware.Resistance.ReadCommand == "MEAS:RES?" && hardware.Resistance.Port == 5025, "editable resistance command and port"); } private static async Task CheckEquipmentAsync() { foreach (var invalid in new[] { "VoltageDisabled", "CurrentDisabled", "ResistanceDisabled", "ResistanceMissing", "ResistanceHostMissing", "CleanupMissing" }) { var commands = new ConcurrentQueue(); using var voltage = new FakeInstrument("V", commands, _ => "5.02"); using var current = new FakeInstrument("A", commands, _ => "0"); using var resistance = new FakeInstrument("R", commands, _ => "4700"); var settings = new EquipmentMeasurementSettings { PreBoardCommand = "OUTP ON, (@2)", Voltage = voltage.Channel("MEAS:VOLT:DC?"), Current = current.Channel("MEAS:CURR? (@1)"), Resistance = resistance.Channel("MEAS:RES?") }; settings.Current.CleanupCommand = "OUTP OFF, (@1)"; switch (invalid) { case "VoltageDisabled": settings.Voltage.Connection = "Disabled"; break; case "CurrentDisabled": settings.Current.Connection = "Disabled"; break; case "ResistanceDisabled": settings.Resistance.Connection = "Disabled"; break; case "ResistanceMissing": settings.Resistance.Connection = ""; settings.Resistance.Host = ""; break; case "ResistanceHostMissing": settings.Resistance.Host = ""; break; case "CleanupMissing": settings.Current.CleanupCommand = ""; break; } var service = new EquipmentMeasurementService(settings); await ExpectFailureAsync(async () => { await service.RunPreBoardCommandAsync(); }, invalid + " pre-board configuration must fail"); await ExpectFailureAsync(async () => { await service.ReadAsync(); }, invalid + " measurement configuration must fail"); Check(commands.IsEmpty, invalid + " must not issue instrument commands"); } foreach (var raw in new[] { "4700", "OL", "999000", "bad response", "TIMEOUT", "POWER_STILL_ON" }) { var commands = new ConcurrentQueue(); using var voltage = new FakeInstrument("V", commands, command => command == "MEAS:VOLT:DC?" ? "5.02" : null); using var current = new FakeInstrument("A", commands, command => command switch { "MEAS:CURR? (@1)" => "0.1184", "OUTP? (@1)" => raw == "POWER_STILL_ON" ? "1" : "0", _ => null }); using var resistance = new FakeInstrument("R", commands, command => command == "MEAS:RES?" ? raw == "TIMEOUT" ? null : raw : null); var settings = new EquipmentMeasurementSettings { Timeout = 1000, SettleMilliseconds = 0, ResistanceSettleMilliseconds = 0, Voltage = voltage.Channel("MEAS:VOLT:DC?"), Current = current.Channel("MEAS:CURR? (@1)"), Resistance = resistance.Channel("MEAS:RES?") }; settings.Current.SetupCommand = "OUTP ON, (@1)"; settings.Current.CleanupCommand = "OUTP OFF, (@1);OUTP OFF, (@2)"; var service = new EquipmentMeasurementService(settings); if (raw is "bad response" or "TIMEOUT" or "POWER_STILL_ON") { await ExpectFailureAsync(async () => { await service.ReadAsync(); }, "equipment error propagates: " + raw); } else { var measured = await service.ReadAsync(); Check(measured.Voltage == 5.02m && measured.Current == 0.1184m, "existing voltage/current units retained"); Check(measured.ResistanceKilohms == (raw == "4700" ? 4.7m : 999m) && measured.ResistanceOverloaded == (raw == "OL"), "separate resistance response propagated"); Check(measured.RawLog.Contains("CURRENT CLEANUP") && measured.RawLog.Contains("RESISTANCE"), "measurement log includes cleanup and resistance"); } var sequence = commands.ToArray(); Check(!sequence.Any(c => c.Contains("OFF, (@2)")), "protected CH2 is not switched off"); var rIndex = Array.IndexOf(sequence, "R:MEAS:RES?"); if (raw == "POWER_STILL_ON") Check(rIndex < 0, "resistance query blocked while output is on"); else Check(Array.IndexOf(sequence, "V:MEAS:VOLT:DC?") < Array.IndexOf(sequence, "A:MEAS:CURR? (@1)") && Array.IndexOf(sequence, "A:MEAS:CURR? (@1)") < Array.IndexOf(sequence, "A:OUTP OFF, (@1)") && Array.IndexOf(sequence, "A:OUTP OFF, (@1)") < Array.IndexOf(sequence, "A:OUTP? (@1)") && Array.IndexOf(sequence, "A:OUTP? (@1)") < rIndex, "V -> current -> OFF -> confirm -> resistance"); } } private static void CheckDatabaseParameters() { var addResistance = typeof(HousingAssemblyRepository).GetMethod("AddResistance", BindingFlags.NonPublic | BindingFlags.Static)!; foreach (var (input, expected) in new (decimal? Input, object Expected)[] { (null, DBNull.Value), (0m, 0m), (4.704m, 4.70m), (4.705m, 4.71m), (-4.705m, -4.71m), (999m, 999m), (999.99m, 999.99m), (999.994m, DBNull.Value), (999.995m, DBNull.Value), (1000m, DBNull.Value) }) { using var command = new SqlCommand(); addResistance.Invoke(null, new object?[] { command, input }); var parameter = command.Parameters["@PT_Resistor_1"]; Check(parameter.SqlDbType == SqlDbType.Decimal && parameter.Precision == 5 && parameter.Scale == 2, "resistance SQL parameter has decimal(5,2) type"); Check(Equals(parameter.Value, expected), "resistance SQL parameter preserves NULL or rounded kOhm value"); } } private static void RenderAndCheckUi(string output) { var app = new App(); app.InitializeComponent(); foreach (var width in new[] { 1200, 840 }) { var window = new MainWindow { WindowState = WindowState.Normal, Width = width, Height = 800 }; Check(Find(window, "ROutputTextBox").Text == "0.00" && Find(window, "RStatusTextBlock").Text == "측정 대기", "resistance default is zero and marked unmeasured"); var createRecord = typeof(MainWindow).GetMethod("CreateInspectionRecord", BindingFlags.NonPublic | BindingFlags.Instance)!; var record = (HousingAssemblyRecord)createRecord.Invoke(window, new object[] { new BoardMeasurementResult { IcSn = "TEST-IC", ResistanceKilohms = 4.705m }, "OK" })!; Check(record.PtResistor1 == 4.705m, "measured resistance reaches database record in kOhm"); var missingRecord = (HousingAssemblyRecord)createRecord.Invoke(window, new object[] { new BoardMeasurementResult { IcSn = "TEST-IC", ResistanceKilohms = null }, "NG" })!; Check(missingRecord.PtResistor1 is null, "unmeasured resistance remains NULL in database record"); var overflowRecord = (HousingAssemblyRecord)createRecord.Invoke(window, new object[] { new BoardMeasurementResult { IcSn = "TEST-IC", ResistanceKilohms = 1000m }, "NG" })!; Check(overflowRecord.PtResistor1 is null && overflowRecord.Result == "NG", "out-of-range resistance saves NULL and NG"); typeof(MainWindow).GetField("_inspectionSettings", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(window, Criteria()); Invoke(window, "ApplyInspectionSettingsToTextBoxes"); Find(window, "VOutputTextBox").Text = "5.02"; Find(window, "AOutputTextBox").Text = "118.40"; Invoke(window, "ApplyMeasurementOutputState", Find(window, "VOutputTextBox"), true); Invoke(window, "ApplyMeasurementOutputState", Find(window, "AOutputTextBox"), true); Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 4.7m }); Invoke(window, "ShowInspectionResult", true); Invoke(window, "SetProcessStatus", "COMPLETE OK", "HmiRunBrush"); var content = (FrameworkElement)window.Content; content.Width = width; content.Height = 800; content.Measure(new Size(width, 800)); content.Arrange(new Rect(0, 0, width, 800)); content.UpdateLayout(); var outputs = new[] { "VOutputTextBox", "AOutputTextBox", "ROutputTextBox", "ResultOutputTextBox" }.Select(n => Find(window, n)).ToArray(); var positions = outputs.Select(o => o.TranslatePoint(new Point(0, 0), content)).ToArray(); Check(outputs.All(o => o.ActualWidth > 100), "readable output widths at " + width); Check(positions.All(p => Math.Abs(p.Y - positions[0].Y) < 1), "four outputs share baseline at " + width); Check(Enumerable.Range(0, 3).All(i => positions[i].X + outputs[i].ActualWidth < positions[i + 1].X), "no overlap at " + width); Check(Find(window, "RMinTextBox").Text == "4.00" && Find(window, "RMaxTextBox").Text == "5.00", "resistance range visible with two decimal places"); var preciseLimit = typeof(MainWindow).GetMethod("FormatResistanceLimit", BindingFlags.NonPublic | BindingFlags.Static)!; Check((string)preciseLimit.Invoke(null, new object?[] { 4.705m })! == "4.705", "existing precise resistance limits are not silently rounded"); Check(Find(window, "ROutputTextBox").Text == "4.70", "normal resistance UI uses two decimal places"); Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 4.705m }); Check(Find(window, "ROutputTextBox").Text == "4.71" && (string)Find(window, "ROutputTextBox").ToolTip == "4.705 kΩ", "display uses database rounding while tooltip retains raw measurement"); Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 4.7m }); SaveImage(content, Path.Combine(output, $"resistance-{width}.png"), width, 800); Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 999m, ResistanceOverloaded = true }); Invoke(window, "ShowInspectionResult", false); Invoke(window, "SetProcessStatus", "COMPLETE NG", "HmiDangerBrush"); content.UpdateLayout(); Check(Find(window, "ROutputTextBox").Text == "999.00" && Find(window, "RStatusTextBlock").Text.Contains("OL"), "overload remains distinguishable in UI"); SaveImage(content, Path.Combine(output, $"resistance-overload-{width}.png"), width, 800); Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 1000m }); Check(Find(window, "ROutputTextBox").Text == "1000.00" && Find(window, "RStatusTextBlock").Text.Contains("DB 저장 범위 초과"), "database range overflow is visible with the measured value"); Invoke(window, "ResetMeasurementOutputs"); Check(Find(window, "ROutputTextBox").Text == "0.00" && Find(window, "RStatusTextBlock").Text == "측정 대기", "new measurement resets resistance without claiming a measurement"); Check(Find(window, "VOutputTextBox").Text == "0.00" && Find(window, "AOutputTextBox").Text == "0.00", "new inspection clears old voltage and current"); window.Close(); } } private static T Find(MainWindow window, string name) => (T)window.FindName(name); private static void Invoke(MainWindow window, string name, params object[] arguments) => typeof(MainWindow).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(window, arguments); private static void SaveImage(FrameworkElement content, string path, int width, int height) { var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(content); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using var stream = File.Create(path); encoder.Save(stream); } private sealed class FakeInstrument : IDisposable { private readonly TcpListener _listener = new(IPAddress.Loopback, 0); private readonly CancellationTokenSource _stop = new(); private readonly string _name; private readonly ConcurrentQueue _commands; private readonly Func _respond; public FakeInstrument(string name, ConcurrentQueue commands, Func respond) { (_name, _commands, _respond) = (name, commands, respond); _listener.Start(); _ = AcceptAsync(); } public ScpiChannelSettings Channel(string readCommand) => new() { Connection = "TCP", Host = "127.0.0.1", Port = ((IPEndPoint)_listener.LocalEndpoint).Port, ReadCommand = readCommand }; private async Task AcceptAsync() { try { while (!_stop.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(_stop.Token); _ = RespondAsync(client); } } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { } } private async Task RespondAsync(TcpClient client) { using (client) using (var reader = new StreamReader(client.GetStream())) using (var writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { try { while (await reader.ReadLineAsync(_stop.Token) is { } command) { _commands.Enqueue(_name + ":" + command); if (_respond(command) is { } response) await writer.WriteLineAsync(response); } } catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException) { } } } public void Dispose() { _stop.Cancel(); _listener.Stop(); } } }