You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

374 lines
21 KiB

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<Task> 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<string>();
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<string>();
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<TextBox>(window, "ROutputTextBox").Text == "0.00" &&
Find<TextBlock>(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<TextBox>(window, "VOutputTextBox").Text = "5.02";
Find<TextBox>(window, "AOutputTextBox").Text = "118.40";
Invoke(window, "ApplyMeasurementOutputState", Find<TextBox>(window, "VOutputTextBox"), true);
Invoke(window, "ApplyMeasurementOutputState", Find<TextBox>(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<TextBox>(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<TextBox>(window, "RMinTextBox").Text == "4.00" && Find<TextBox>(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<TextBox>(window, "ROutputTextBox").Text == "4.70", "normal resistance UI uses two decimal places");
Invoke(window, "ShowResistanceMeasurement", new BoardMeasurementResult { ResistanceKilohms = 4.705m });
Check(Find<TextBox>(window, "ROutputTextBox").Text == "4.71" &&
(string)Find<TextBox>(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<TextBox>(window, "ROutputTextBox").Text == "999.00" && Find<TextBlock>(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<TextBox>(window, "ROutputTextBox").Text == "1000.00" &&
Find<TextBlock>(window, "RStatusTextBlock").Text.Contains("DB 저장 범위 초과"), "database range overflow is visible with the measured value");
Invoke(window, "ResetMeasurementOutputs");
Check(Find<TextBox>(window, "ROutputTextBox").Text == "0.00" &&
Find<TextBlock>(window, "RStatusTextBlock").Text == "측정 대기", "new measurement resets resistance without claiming a measurement");
Check(Find<TextBox>(window, "VOutputTextBox").Text == "0.00" && Find<TextBox>(window, "AOutputTextBox").Text == "0.00", "new inspection clears old voltage and current");
window.Close();
}
}
private static T Find<T>(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<string> _commands;
private readonly Func<string, string?> _respond;
public FakeInstrument(string name, ConcurrentQueue<string> commands, Func<string, string?> 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();
}
}
}