#if SELFTEST
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using NUnit.Framework;
using OpenCvSharp;
namespace TIASshot.Tests {
/// <summary>
/// CameraBase の自動校正(ProcessCalibrationFrame → DetectChart → 固定判定 → ホワイトバランス → CalcTcc)と,
/// 校正後・校正中の切断の結合ロジックテスト(層②).
///
/// 仕様: SPEC_01「自動キャリブレーション」「撮影」「データ保存(キャリブレーション保存ファイル)」・
/// testing.md「層②: CameraBase の校正」「CameraBase の切断・終了」
/// - プレビュー中に舌診チャートを検出すると自動でキャリブレーションを開始する
/// - キャリブレーション完了後に撮影ボタンが有効になる
/// - 算出した変換行列等は保存フォルダに保存される
/// - 校正の途中で失敗したときは,再検出できる状態へ戻る
/// - 切断時はチャートマスク(ネイティブ資源)を解放する(CLAUDE.md: Mat は確実に解放する)
///
/// 失敗の注入:
/// - チャートの映っていない一様なフレーム(SelfTestChart.CreatePlainFrame)を流す
/// - 校正の収束処理(LogCalibrationConverged)で 1 回だけ例外を投げる派生偽カメラ
///
/// 観測は SelfTestCamera のアクセサ(IsCalibrated・PreviewError・ChartMasksSnapshot・CalibrationFolder)・
/// Form1.SelfTest.cs のアクセサ(SelfTestShotsEnabled)・保存されたファイルで行う.
/// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
/// </summary>
[TestFixture]
public class CameraBaseCalibrationIntegrationTests {
// 校正のプレビューで流すフレーム数の余裕(ハーネスの Calibrate と同じ考え方)
private const int ExtraFrames = 60;
// 舌診チャートの色票数(SPEC_01: 24 色票)
private const int PatchCount = 24;
// ================================================================================
// 校正の状態遷移(正常系)
// ================================================================================
/// <summary>
/// 舌診チャートを流すと校正が完了し,撮影ボタンが無効から有効に変わる.
/// </summary>
[Test, Category("Integration")]
public void 校正_チャートを流す_校正完了で撮影ボタンが無効から有効になる() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.CreateChartCamera();
var enabledBefore = h.Form.SelfTestShotsEnabled;
RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
// CalcTcc → EnableShots は UI スレッドへの Invoke のため,反映をメッセージ処理で待つ
h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3));
return new {
enabledBefore,
camera.IsCalibrated,
PreviewError = camera.PreviewError?.ToString(),
enabledAfter = h.Form.SelfTestShotsEnabled,
};
});
// Assert
Assert.That(obs.enabledBefore, Is.False, "校正前は撮影ボタンが無効であること");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.PreviewError, Is.Null, "校正中に例外が出ないこと");
Assert.That(obs.IsCalibrated, Is.True, "チャートを流すと校正が完了すること");
Assert.That(obs.enabledAfter, Is.True, "校正完了後に撮影ボタンが有効になること");
}
}
/// <summary>
/// 校正が完了すると,SPEC_01「キャリブレーション保存ファイル」に挙げたファイルが校正の保存フォルダに作られる.
/// </summary>
[Test, Category("Integration")]
public void 校正_チャートを流す_キャリブレーション保存ファイルが保存される() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.CreateChartCamera();
h.Calibrate(camera);
var folder = camera.CalibrationFolder;
var channels = GetConversionChannels();
var expected = new List<string> {
Config.GetString("File/TccImage"),
Config.GetString("File/TccRoisImage"),
Config.GetString("File/TccRgbValues"),
};
foreach (var ch in channels) {
expected.Add(WithChannel("File/TccSrgbValues", ch));
expected.Add(WithChannel("File/ConvRgb2Srgb", ch));
expected.Add(WithChannel("File/ConvRgb2Xyz", ch));
expected.Add(WithChannel("File/ConvSrgb2Xyz", ch));
}
var saved = !string.IsNullOrEmpty(folder) && Directory.Exists(folder)
? Directory.GetFiles(folder).Select(Path.GetFileName).ToList()
: new List<string>();
return new { folder, channels, expected, saved, underRoot = folder != null && IsUnder(folder, h.SaveRoot) };
});
// Assert
Assert.That(obs.channels, Is.Not.Empty, "前提: 変換次元が 1 つ以上設定されていること");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.underRoot, Is.True, $"校正の保存先が一時保存先の配下であること: {obs.folder}");
Assert.That(obs.saved, Is.SupersetOf(obs.expected), $"保存されたファイル: [{string.Join(", ", obs.saved)}]");
}
}
// ================================================================================
// 校正の状態遷移(失敗注入)
// ================================================================================
/// <summary>
/// チャートの映っていないフレームだけを流すと,校正は完了せず,撮影ボタンは無効のままである.
/// </summary>
[Test, Category("Integration")]
public void 校正_チャートの映っていないフレーム_校正が完了せず撮影ボタンが無効のまま() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.CreateCamera(i => SelfTestChart.CreatePlainFrame(i, SelfTestChart.FrameWidth, SelfTestChart.FrameHeight));
RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
// 誤って有効化の Invoke が積まれていた場合に反映されるよう,少しメッセージを処理してから観測する
h.PumpFor(TimeSpan.FromMilliseconds(100));
return new {
camera.FramesDelivered,
camera.IsCalibrated,
PreviewError = camera.PreviewError?.ToString(),
Masks = camera.ChartMasksSnapshot.Count,
enabled = h.Form.SelfTestShotsEnabled,
};
});
// Assert
Assert.That(obs.FramesDelivered, Is.EqualTo(MaxCalibrationFramesForFastConfig()), "前提: 上限までフレームを流したこと");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.PreviewError, Is.Null, "チャートが無くても例外にならないこと");
Assert.That(obs.IsCalibrated, Is.False, "チャートが無ければ校正は完了しないこと");
Assert.That(obs.Masks, Is.Zero, "チャートが無ければマスクは作られないこと");
Assert.That(obs.enabled, Is.False, "校正が完了していないので撮影ボタンは無効のままであること");
}
}
/// <summary>
/// 校正の途中(収束処理)で例外が起きた後,再びプレビューを流すとチャートを再検出して校正を完了でき,
/// 撮影ボタンが有効になる(途中で失敗したときに再検出できる状態へ戻る).
/// </summary>
[Test, Category("Integration")]
public void 校正_途中で例外_再びプレビューを流すと再検出して校正が完了する() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ConvergeFailOnceCamera(h.Form, h.ChartFrameFactory())));
RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
var firstError = camera.PreviewError;
var calibratedAfterFailure = camera.IsCalibrated;
var enabledAfterFailure = h.Form.SelfTestShotsEnabled;
RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3));
return new {
injectedThrown = ReferenceEquals(firstError, camera.Injected),
firstError = firstError?.ToString(),
calibratedAfterFailure,
enabledAfterFailure,
calibratedAfterRetry = camera.IsCalibrated,
enabledAfterRetry = h.Form.SelfTestShotsEnabled,
};
});
// Assert
Assert.That(obs.injectedThrown, Is.True, $"前提: 注入した例外で 1 回目の校正が中断されたこと(実際: {obs.firstError ?? "例外なし"})");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.calibratedAfterFailure, Is.False, "途中で失敗した校正は完了扱いにならないこと");
Assert.That(obs.enabledAfterFailure, Is.False, "途中で失敗した校正では撮影ボタンが有効にならないこと");
Assert.That(obs.calibratedAfterRetry, Is.True, "再びプレビューを流すと校正が完了すること");
Assert.That(obs.enabledAfterRetry, Is.True, "再校正の完了で撮影ボタンが有効になること");
}
}
/// <summary>
/// 校正の途中で例外が起きた後にチャートを再検出したとき,失敗した校正で作られた旧チャートマスクは解放される
/// (差し替えの際に旧マスクを Dispose せずに捨てるとネイティブメモリがリークする).
/// </summary>
[Test, Category("Integration")]
public void 校正_途中で例外の後に再検出_旧チャートマスクが全て解放される() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ConvergeFailOnceCamera(h.Form, h.ChartFrameFactory())));
RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
var firstError = camera.PreviewError;
var oldMasks = camera.ChartMasksSnapshot.ToList();
RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
var newMasks = camera.ChartMasksSnapshot.ToList();
return new {
injectedThrown = ReferenceEquals(firstError, camera.Injected),
oldCount = oldMasks.Count,
oldDisposed = oldMasks.Count(m => m.IsDisposed),
newCount = newMasks.Count,
newDisposed = newMasks.Count(m => m.IsDisposed),
replaced = oldMasks.All(o => !newMasks.Any(n => ReferenceEquals(n, o))),
calibrated = camera.IsCalibrated,
};
});
// Assert
Assert.That(obs.injectedThrown, Is.True, "前提: 注入した例外で 1 回目の校正が中断されたこと");
Assert.That(obs.oldCount, Is.EqualTo(PatchCount), "前提: 1 回目の検出で 24 色票ぶんのマスクが作られたこと");
Assert.That(obs.calibrated, Is.True, "前提: 2 回目のプレビューで再検出・校正が完了したこと");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.replaced, Is.True, "再検出でマスクが新しいものに差し替わること");
Assert.That(obs.oldDisposed, Is.EqualTo(obs.oldCount), "差し替えられた旧マスクが全て解放されること");
Assert.That(obs.newCount, Is.EqualTo(PatchCount), "新しいマスクが 24 色票ぶんあること");
Assert.That(obs.newDisposed, Is.Zero, "使用中の新しいマスクは解放されていないこと");
}
}
// ================================================================================
// 切断
// ================================================================================
/// <summary>
/// 校正完了後に切断すると,チャートマスクが全て解放され,カメラの保持するマスクが空になる.
/// </summary>
[Test, Category("Integration")]
public void 切断_校正完了後_チャートマスクが全て解放され空になる() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.CreateChartCamera();
h.Calibrate(camera);
var masks = camera.ChartMasksSnapshot.ToList();
Exception error = null;
try {
camera.Disconnect();
} catch (Exception ex) {
error = ex;
}
return new {
count = masks.Count,
disposed = masks.Count(m => m.IsDisposed),
remaining = camera.ChartMasksSnapshot.Count,
error = error?.ToString(),
};
});
// Assert
Assert.That(obs.count, Is.EqualTo(PatchCount), "前提: 校正で 24 色票ぶんのマスクが作られたこと");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.error, Is.Null, "切断が例外にならないこと");
Assert.That(obs.disposed, Is.EqualTo(obs.count), "切断でマスクが全て解放されること");
Assert.That(obs.remaining, Is.Zero, "切断後はマスクを保持しないこと");
}
}
/// <summary>
/// 校正の途中(チャート検出後・変換行列算出前)にプレビューを止めて切断しても例外にならず,
/// チャートマスクが全て解放され,校正完了扱い・撮影ボタン有効化にはならない.
/// 校正フレーム数を大きくして,校正中の状態(IsCalibrating)を確実に作ってから切断する.
/// </summary>
[Test, Category("Integration")]
public void 切断_校正中_例外にならずチャートマスクが解放され撮影ボタンは無効のまま() {
// Arrange
var options = new IntegrationOptions().WithConfig("Calib/Frames", "100000");
// Act
var obs = IntegrationHarness.Run(h => {
var camera = h.CreateChartCamera();
camera.StartPreview(1000000, 1, false);
h.PumpUntil(() => camera.IsCalibrating || !camera.IsPreviewRunning, "チャート検出(校正の開始)", TimeSpan.FromSeconds(5));
var calibratingAtStop = camera.IsCalibrating;
camera.RequestStopPreview();
h.PumpUntil(() => !camera.IsPreviewRunning, "プレビューの停止", TimeSpan.FromSeconds(3));
var masks = camera.ChartMasksSnapshot.ToList();
Exception error = null;
try {
camera.Disconnect();
} catch (Exception ex) {
error = ex;
}
h.PumpFor(TimeSpan.FromMilliseconds(100));
return new {
calibratingAtStop,
PreviewError = camera.PreviewError?.ToString(),
count = masks.Count,
disposed = masks.Count(m => m.IsDisposed),
remaining = camera.ChartMasksSnapshot.Count,
error = error?.ToString(),
camera.IsCalibrated,
enabled = h.Form.SelfTestShotsEnabled,
};
}, options);
// Assert
Assert.That(obs.calibratingAtStop, Is.True, "前提: 校正中(チャート検出後)に停止したこと");
Assert.That(obs.count, Is.EqualTo(PatchCount), "前提: 24 色票ぶんのマスクがあること");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.PreviewError, Is.Null, "校正中に停止してもプレビューで例外が出ないこと");
Assert.That(obs.error, Is.Null, "校正中の切断が例外にならないこと");
Assert.That(obs.disposed, Is.EqualTo(obs.count), "切断でマスクが全て解放されること");
Assert.That(obs.remaining, Is.Zero, "切断後はマスクを保持しないこと");
Assert.That(obs.IsCalibrated, Is.False, "校正の途中で切断したら校正完了扱いにならないこと");
Assert.That(obs.enabled, Is.False, "校正の途中で切断したら撮影ボタンは無効のままであること");
}
}
// ================================================================================
// 共通処理
// ================================================================================
/// <summary>プレビューを流し,停止するまでメッセージを処理して待つ(PreviewError は判定しない)</summary>
private static void RunPreview(IntegrationHarness h, SelfTestCamera camera, int maxFrames, bool stopWhenCalibrated) {
camera.StartPreview(maxFrames, 0, stopWhenCalibrated);
h.PumpUntil(() => !camera.IsPreviewRunning, "プレビューの終了");
}
/// <summary>校正に必要なフレーム数+余裕(一時設定が読み込まれている STA スレッドで呼ぶ)</summary>
private static int MaxCalibrationFrames() {
return Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + ExtraFrames;
}
/// <summary>ハーネスの既定設定(FastConfig)での MaxCalibrationFrames(テストスレッドで期待値に使う)</summary>
private static int MaxCalibrationFramesForFastConfig() {
return int.Parse(IntegrationHarness.FastConfig["Calib/ChartSetCount"])
+ int.Parse(IntegrationHarness.FastConfig["Calib/Frames"]) + ExtraFrames;
}
private static string WithChannel(string key, int channel) {
return Config.GetString(key).Replace("{CN}", $"{channel:00}");
}
/// <summary>設定の変換次元(Calib/ConversionChannels.SPEC_02: カンマ区切り・4〜17 の範囲のみ有効)</summary>
private static List<int> GetConversionChannels() {
return Config.GetString("Calib/ConversionChannels")
.Split(',')
.Select(x => int.Parse(x.Trim()))
.Where(x => x >= 4 && x <= 17)
.Distinct()
.OrderBy(x => x)
.ToList();
}
private static bool IsUnder(string path, string root) {
var p = Path.GetFullPath(path).TrimEnd('\\') + "\\";
var r = Path.GetFullPath(root).TrimEnd('\\') + "\\";
return p.StartsWith(r, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 校正の収束処理(LogCalibrationConverged.CalcTcc の直前に呼ばれる)で 1 回目だけ例外を投げる偽カメラ(失敗注入用).
/// 例外はプレビュースレッドで捕捉され PreviewError に記録される(プレビューは止まる).
/// </summary>
private sealed class ConvergeFailOnceCamera : SelfTestCamera {
private int _calls;
public ConvergeFailOnceCamera(Form1 form, Func<int, Mat> frameFactory) : base(form, frameFactory) { }
/// <summary>注入した例外(PreviewError と同一インスタンスかで注入の成立を確かめる)</summary>
public InvalidOperationException Injected { get; } = new InvalidOperationException("注入: 校正の収束処理で失敗");
protected override void LogCalibrationConverged() {
if (Interlocked.Increment(ref _calls) == 1) throw Injected;
base.LogCalibrationConverged();
}
}
}
}
#endif