diff --git a/TIASshot.Tests/CameraBaseCalibrationIntegrationTests.cs b/TIASshot.Tests/CameraBaseCalibrationIntegrationTests.cs index a5ca7d3..b4d877c 100644 --- a/TIASshot.Tests/CameraBaseCalibrationIntegrationTests.cs +++ b/TIASshot.Tests/CameraBaseCalibrationIntegrationTests.cs @@ -19,11 +19,16 @@ /// - キャリブレーション完了後に撮影ボタンが有効になる /// - 算出した変換行列等は保存フォルダに保存される /// - 校正の途中で失敗したときは,再検出できる状態へ戻る + /// - PLAN_05「束 B の方針」: 校正中の例外はカメラのコールバックへ伝えず,「校正に失敗しました」と通知して + /// 未校正・マスク解放に戻す.次に校正が完了するまで検出中の表示を失敗の表示に差し替える. + /// 校正画像の保存に失敗しても校正済みとし,「自動校正完了(校正画像の保存に失敗)」と警告する /// - 切断時はチャートマスク(ネイティブ資源)を解放する(CLAUDE.md: Mat は確実に解放する) /// /// 失敗の注入: /// - チャートの映っていない一様なフレーム(SelfTestChart.CreatePlainFrame)を流す /// - 校正の収束処理(LogCalibrationConverged)で 1 回だけ例外を投げる派生偽カメラ + /// - 校正の更新間隔 0(校正フレームごとに 0 除算の例外.設定不備) + /// - 校正画像(File/TccImage)に 255 文字を超えるファイル名(保存が false で失敗する) /// /// 観測は SelfTestCamera のアクセサ(IsCalibrated・PreviewError・ChartMasksSnapshot・CalibrationFolder)・ /// Form1.SelfTest.cs のアクセサ(SelfTestShotsEnabled)・保存されたファイルで行う. @@ -142,58 +147,56 @@ } /// - /// 校正の途中(収束処理)で例外が起きた後,再びプレビューを流すとチャートを再検出して校正を完了でき, - /// 撮影ボタンが有効になる(途中で失敗したときに再検出できる状態へ戻る). + /// 校正の途中(収束処理)で例外が起きても,例外はカメラのコールバック(プレビュースレッド)へ伝わらず, + /// 「校正に失敗しました」がちょうど 1 回通知され,同じプレビューのままチャートを再検出して校正を完了でき, + /// 撮影ボタンが有効になる(途中で失敗したときに再検出できる状態へ戻る.PLAN_05「束 B の方針」). /// [Test, Category("Integration")] - public void 校正_途中で例外_再びプレビューを流すと再検出して校正が完了する() { + 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; - + h.Form.SelfTestClearLog(); RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true); h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3)); + var messages = h.Form.SelfTestMessages.ToList(); return new { - injectedThrown = ReferenceEquals(firstError, camera.Injected), - firstError = firstError?.ToString(), - calibratedAfterFailure, - enabledAfterFailure, - calibratedAfterRetry = camera.IsCalibrated, - enabledAfterRetry = h.Form.SelfTestShotsEnabled, + camera.ConvergeCalls, + PreviewError = camera.PreviewError?.ToString(), + notices = messages.Count(IsFailureNotice), + noticeHasReason = messages.Where(IsFailureNotice).All(m => m.Contains(camera.Injected.Message)), + calibrated = camera.IsCalibrated, + enabled = h.Form.SelfTestShotsEnabled, + messages, }; }); // Assert - Assert.That(obs.injectedThrown, Is.True, $"前提: 注入した例外で 1 回目の校正が中断されたこと(実際: {obs.firstError ?? "例外なし"})"); + Assert.That(obs.ConvergeCalls, Is.GreaterThanOrEqualTo(2), "前提: 1 回目の収束処理で失敗し,同じプレビューの中で 2 回目の収束処理に到達したこと"); 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, "再校正の完了で撮影ボタンが有効になること"); + Assert.That(obs.PreviewError, Is.Null, "校正中の例外がカメラのコールバック(プレビュースレッド)へ伝わらないこと"); + Assert.That(obs.notices, Is.EqualTo(1), $"「校正に失敗しました」がちょうど 1 回通知されること.表示履歴: [{string.Join(" / ", obs.messages)}]"); + Assert.That(obs.noticeHasReason, Is.True, "失敗の通知に理由(例外メッセージ)が含まれること"); + Assert.That(obs.calibrated, Is.True, "同じプレビューのまま再検出して校正が完了すること"); + Assert.That(obs.enabled, Is.True, "再校正の完了で撮影ボタンが有効になること"); } } /// - /// 校正の途中で例外が起きた後にチャートを再検出したとき,失敗した校正で作られた旧チャートマスクは解放される - /// (差し替えの際に旧マスクを Dispose せずに捨てるとネイティブメモリがリークする). + /// 校正の途中で例外が起きたとき,失敗した校正で作られた旧チャートマスクは全て解放される + /// (未校正・マスク解放の状態へ戻す.マスクを Dispose せずに捨てるとネイティブメモリがリークする). + /// 旧マスクは例外を投げる直前に控える. /// [Test, Category("Integration")] - public void 校正_途中で例外の後に再検出_旧チャートマスクが全て解放される() { + 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 oldMasks = camera.MasksAtFailure ?? new List(); var newMasks = camera.ChartMasksSnapshot.ToList(); return new { - injectedThrown = ReferenceEquals(firstError, camera.Injected), + PreviewError = camera.PreviewError?.ToString(), oldCount = oldMasks.Count, oldDisposed = oldMasks.Count(m => m.IsDisposed), newCount = newMasks.Count, @@ -204,18 +207,167 @@ }); // Assert - Assert.That(obs.injectedThrown, Is.True, "前提: 注入した例外で 1 回目の校正が中断されたこと"); - Assert.That(obs.oldCount, Is.EqualTo(PatchCount), "前提: 1 回目の検出で 24 色票ぶんのマスクが作られたこと"); - Assert.That(obs.calibrated, Is.True, "前提: 2 回目のプレビューで再検出・校正が完了したこと"); + Assert.That(obs.oldCount, Is.EqualTo(PatchCount), "前提: 失敗した校正で 24 色票ぶんのマスクが作られていたこと(注入が効いたこと)"); + Assert.That(obs.calibrated, Is.True, $"前提: 再検出・校正が完了したこと(PreviewError: {obs.PreviewError ?? "なし"})"); 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.oldDisposed, Is.EqualTo(obs.oldCount), "失敗した校正の旧マスクが全て解放されること"); + Assert.That(obs.replaced, Is.True, "再検出のマスクは旧マスクと別のものであること"); + Assert.That(obs.newCount, Is.EqualTo(PatchCount), "再検出で 24 色票ぶんのマスクがあること"); Assert.That(obs.newDisposed, Is.Zero, "使用中の新しいマスクは解放されていないこと"); } } // ================================================================================ + // 校正中の例外(失敗注入: 毎フレーム例外になる設定) + // ================================================================================ + + /// + /// 校正の更新間隔が 0(設定不備.校正フレームごとに 0 除算の例外)のとき,例外はカメラのコールバックへ伝わらず, + /// 同じ失敗を何度繰り返しても「校正に失敗しました」の通知はちょうど 1 回で,エラーダイアログは出ず,校正は完了しない. + /// + [Test, Category("Integration")] + public void 校正_毎フレーム例外_コールバックへ伝わらず失敗の通知はちょうど1回() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var camera = h.CreateChartCamera(); + h.Form.SelfTestClearLog(); + RunPreview(h, camera, AlwaysFailFrames, stopWhenCalibrated: true); + var messages = h.Form.SelfTestMessages.ToList(); + return new { + camera.FramesDelivered, + PreviewError = camera.PreviewError?.ToString(), + notices = messages.Count(IsFailureNotice), + retrying = messages.Count(IsRetryingMessage), + errors = h.Form.SelfTestErrors.ToList(), + calibrated = camera.IsCalibrated, + enabled = h.Form.SelfTestShotsEnabled, + messages, + }; + }, AlwaysFailOptions()); + + // Assert + Assert.That(obs.FramesDelivered, Is.EqualTo(AlwaysFailFrames), "前提: 上限までフレームを流したこと(プレビューが止まらなかったこと)"); + Assert.That(obs.retrying, Is.GreaterThanOrEqualTo(4), $"前提: 失敗の後も検出と失敗を繰り返したこと.表示履歴: [{string.Join(" / ", obs.messages)}]"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.PreviewError, Is.Null, "校正中の例外がカメラのコールバック(プレビュースレッド)へ伝わらないこと"); + Assert.That(obs.notices, Is.EqualTo(1), $"同じ失敗の通知は繰り返さず,ちょうど 1 回であること.表示履歴: [{string.Join(" / ", obs.messages)}]"); + Assert.That(obs.errors, Is.Empty, "エラーダイアログは使わないこと(カメラのコールバックを止めるため)"); + Assert.That(obs.calibrated, Is.False, "失敗し続ける校正は完了扱いにならないこと"); + Assert.That(obs.enabled, Is.False, "校正が完了していないので撮影ボタンは無効のままであること"); + } + } + + /// + /// 校正が失敗した後もチャートを流し続けたとき,次に校正が完了するまで,検出中の表示(「舌診チャートの検出中」等)で + /// 失敗の表示が上書きされず,最後の表示が「校正に失敗しました(理由).チャートの検出からやり直しています」である(案 A). + /// + [Test, Category("Integration")] + public void 校正_失敗後もチャートを流す_検出中の表示に上書きされず失敗の表示が続く() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var camera = h.CreateChartCamera(); + h.Form.SelfTestClearLog(); + RunPreview(h, camera, AlwaysFailFrames, stopWhenCalibrated: true); + var messages = h.Form.SelfTestMessages.ToList(); + var first = messages.FindIndex(m => IsFailureNotice(m)); + return new { + first, + afterFailure = first < 0 ? new List() : messages.Skip(first + 1).ToList(), + last = h.Form.SelfTestMessage, + messages, + }; + }, AlwaysFailOptions()); + + // Assert + Assert.That(obs.first, Is.GreaterThanOrEqualTo(0), $"前提: 校正の失敗が通知されたこと.表示履歴: [{string.Join(" / ", obs.messages)}]"); + Assert.That(obs.afterFailure, Is.Not.Empty, "前提: 失敗の後もチャート検出の表示が出たこと"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.afterFailure, Has.None.Matches(IsPlainDetectionMessage), + $"失敗の後は検出中の表示がそのまま出ない(失敗の表示が上書きされない)こと.失敗後の表示: [{string.Join(" / ", obs.afterFailure)}]"); + Assert.That(obs.last, Does.StartWith("校正に失敗しました("), "最後の表示が校正失敗の表示であること"); + Assert.That(obs.last, Does.EndWith(RetryingSuffix), "最後の表示が「…チャートの検出からやり直しています」であること"); + } + } + + /// + /// 校正の途中で失敗した後,同じプレビューの中で再校正が完了すると,表示は「自動校正完了」になる. + /// 失敗から完了までの間は検出中の表示で失敗の表示が上書きされない. + /// + [Test, Category("Integration")] + public void 校正_失敗後に再校正が完了_表示が自動校正完了になる() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var camera = h.Connect(h.Track(new ConvergeFailOnceCamera(h.Form, h.ChartFrameFactory()))); + h.Form.SelfTestClearLog(); + RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true); + var messages = h.Form.SelfTestMessages.ToList(); + var first = messages.FindIndex(m => IsFailureNotice(m)); + var done = messages.FindIndex(m => m == "自動校正完了"); + return new { + calibrated = camera.IsCalibrated, + first, + done, + between = first >= 0 && done > first ? messages.Skip(first + 1).Take(done - first - 1).ToList() : new List(), + last = h.Form.SelfTestMessage, + messages, + }; + }); + + // Assert + Assert.That(obs.first, Is.GreaterThanOrEqualTo(0), $"前提: 校正の失敗が通知されたこと.表示履歴: [{string.Join(" / ", obs.messages)}]"); + Assert.That(obs.calibrated, Is.True, "前提: 再校正が完了したこと"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.last, Is.EqualTo("自動校正完了"), "再校正が完了したら表示が「自動校正完了」になること"); + Assert.That(obs.done, Is.GreaterThan(obs.first), "「自動校正完了」は失敗の通知より後に出ること"); + Assert.That(obs.between, Has.None.Matches(IsPlainDetectionMessage), + $"失敗から再校正の完了までは検出中の表示で失敗の表示が上書きされないこと.間の表示: [{string.Join(" / ", obs.between)}]"); + } + } + + // ================================================================================ + // 校正画像の保存失敗(失敗注入: 255 文字を超えるファイル名) + // ================================================================================ + + /// + /// 校正画像(TccImage)の保存に失敗しても(ファイル名が長すぎて Cv2.ImWrite が false を返す), + /// 変換行列はメモリ上で有効なため校正済みとなり撮影ボタンが有効になり,表示は「自動校正完了(校正画像の保存に失敗)」になる. + /// 前提(例外ではなく false になること)は IoUtilSaveImageTests で固定している. + /// + [Test, Category("Integration")] + public void 校正_校正画像の保存失敗_校正済みのまま保存失敗の警告を表示する() { + // Arrange + var longName = new string('a', 260) + ".png"; + var options = new IntegrationOptions().WithConfig("File/TccImage", longName); + + // Act + var obs = IntegrationHarness.Run(h => { + var camera = h.CreateChartCamera(); + h.Form.SelfTestClearLog(); + RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true); + h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3)); + var folder = camera.CalibrationFolder; + return new { + PreviewError = camera.PreviewError?.ToString(), + tccSaved = !string.IsNullOrEmpty(folder) && File.Exists(Path.Combine(folder, longName)), + calibrated = camera.IsCalibrated, + enabled = h.Form.SelfTestShotsEnabled, + last = h.Form.SelfTestMessage, + messages = h.Form.SelfTestMessages.ToList(), + }; + }, options); + + // Assert + Assert.That(obs.tccSaved, Is.False, "前提: 校正画像の保存に失敗したこと(注入が効いたこと)"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.PreviewError, Is.Null, "校正画像の保存失敗がカメラのコールバックへ伝わらないこと"); + Assert.That(obs.calibrated, Is.True, "校正画像の保存に失敗しても校正済みとなること"); + Assert.That(obs.enabled, Is.True, "校正画像の保存に失敗しても撮影ボタンが有効になること"); + Assert.That(obs.last, Is.EqualTo("自動校正完了(校正画像の保存に失敗)"), + $"保存失敗の警告つきの完了表示であること.表示履歴: [{string.Join(" / ", obs.messages)}]"); + } + } + + // ================================================================================ // 切断 // ================================================================================ @@ -307,6 +459,32 @@ // 共通処理 // ================================================================================ + // 校正失敗後に検出中の表示を差し替える文の末尾(PLAN_05「束 B の方針」: 「校正に失敗しました(理由).チャートの検出からやり直しています」) + private const string RetryingSuffix = ".チャートの検出からやり直しています"; + + // 毎フレーム例外になる設定で流すフレーム数(固定判定 3 フレームの後,2 フレームごとに検出と失敗を繰り返す) + private const int AlwaysFailFrames = 20; + + /// 校正フレームごとに例外になる設定(更新間隔 0 → 0 除算.設定不備の注入) + private static IntegrationOptions AlwaysFailOptions() { + return new IntegrationOptions().WithConfig("Calib/UpdateInterval", "0"); + } + + /// 校正失敗の通知か(「校正に失敗しました」で始まり,検出中の表示の差し替え文ではないもの) + private static bool IsFailureNotice(string m) { + return m != null && m.StartsWith("校正に失敗しました", StringComparison.Ordinal) && !m.EndsWith(RetryingSuffix, StringComparison.Ordinal); + } + + /// 校正失敗後に検出中の表示を差し替えた文か + private static bool IsRetryingMessage(string m) { + return m != null && m.StartsWith("校正に失敗しました(", StringComparison.Ordinal) && m.EndsWith(RetryingSuffix, StringComparison.Ordinal); + } + + /// チャート検出の進捗表示(差し替えられていないもの)か + private static bool IsPlainDetectionMessage(string m) { + return m == "舌診チャートの検出中" || m == "舌診チャート検出 校正中"; + } + /// プレビューを流し,停止するまでメッセージを処理して待つ(PreviewError は判定しない) private static void RunPreview(IntegrationHarness h, SelfTestCamera camera, int maxFrames, bool stopWhenCalibrated) { camera.StartPreview(maxFrames, 0, stopWhenCalibrated); @@ -347,7 +525,8 @@ /// /// 校正の収束処理(LogCalibrationConverged.CalcTcc の直前に呼ばれる)で 1 回目だけ例外を投げる偽カメラ(失敗注入用). - /// 例外はプレビュースレッドで捕捉され PreviewError に記録される(プレビューは止まる). + /// 新方針(PLAN_05「束 B の方針」)では例外はカメラのコールバックへ伝わらず,同じプレビューのまま再検出される. + /// 例外を投げる直前のチャートマスク(失敗した校正のマスク)を控える(解放の確認用). /// private sealed class ConvergeFailOnceCamera : SelfTestCamera { @@ -355,11 +534,21 @@ public ConvergeFailOnceCamera(Form1 form, Func frameFactory) : base(form, frameFactory) { } - /// 注入した例外(PreviewError と同一インスタンスかで注入の成立を確かめる) + /// 注入した例外 public InvalidOperationException Injected { get; } = new InvalidOperationException("注入: 校正の収束処理で失敗"); + /// 収束処理の呼び出し回数(失敗した回を含む) + public int ConvergeCalls => Volatile.Read(ref _calls); + + /// 例外を投げる直前のチャートマスクの参照のコピー(注入前は null) + public List MasksAtFailure { get; private set; } + protected override void LogCalibrationConverged() { - if (Interlocked.Increment(ref _calls) == 1) throw Injected; + if (Interlocked.Increment(ref _calls) == 1) { + // プレビュースレッド(_chartMasks を書き換える唯一のスレッド)上なので,ここで読んでよい + MasksAtFailure = ChartMasksSnapshot.ToList(); + throw Injected; + } base.LogCalibrationConverged(); } } diff --git a/TIASshot.Tests/CameraBaseReliabilityDecisionTests.cs b/TIASshot.Tests/CameraBaseReliabilityDecisionTests.cs new file mode 100644 index 0000000..49efbfd --- /dev/null +++ b/TIASshot.Tests/CameraBaseReliabilityDecisionTests.cs @@ -0,0 +1,175 @@ +using System; +using NUnit.Framework; + +namespace TIASshot.Tests { + + /// + /// 撮影・保存の信頼性(PLAN_05「束 B の方針」)で CameraBase から切り出した判断関数の単体テスト(層①). + /// + /// 仕様: PLAN_05「束 B の方針」(2026-10-06 ユーザー合意) + /// - 校正中の例外は「校正に失敗しました」と(理由つきで)通知し,同じ失敗を繰り返し通知しない + /// - 次に校正が完了するまで,チャート検出の表示を「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替える. + /// ただしチャートが上下逆の案内(設置し直しの案内)は差し替えない(ユーザー合意済み) + /// - 校正画像の保存に失敗しても校正済みとし,「自動校正完了(校正画像の保存に失敗)」のように警告する + /// - 保存キューの待ち時間は「30 秒」と「撮影間隔の 2 倍」の大きい方 + /// + [TestFixture] + public class CameraBaseReliabilityDecisionTests { + + // 校正失敗後に検出中の表示を差し替える文の末尾(PLAN_05「束 B の方針」) + private const string RetryingSuffix = ".チャートの検出からやり直しています"; + + // ================================================================================ + // FormatCalibrationFailure(校正失敗の通知文) + // ================================================================================ + + /// 校正失敗の通知は「校正に失敗しました」で始まり,理由(例外メッセージ)を含む + [Test] + public void FormatCalibrationFailure_例外_校正に失敗しましたで始まり理由を含む() { + var text = CameraBase.FormatCalibrationFailure(new InvalidOperationException("理由X")); + + using (Assert.EnterMultipleScope()) { + Assert.That(text, Does.StartWith("校正に失敗しました")); + Assert.That(text, Does.Contain("理由X")); + } + } + + /// + /// 失敗の通知文は,検出中の表示を差し替える文(…チャートの検出からやり直しています)とは区別できる + /// (通知は 1 回,差し替えは毎フレームのため,同じ文だと「通知を繰り返さない」の判定が崩れる). + /// + [Test] + public void FormatCalibrationFailure_例外_検出中の差し替え文とは異なる() { + var ex = new InvalidOperationException("理由X"); + + var notice = CameraBase.FormatCalibrationFailure(ex); + var retrying = CameraBase.FormatChartDetectionMessage(ex.Message, "舌診チャートの検出中"); + + Assert.That(notice, Is.Not.EqualTo(retrying)); + } + + // ================================================================================ + // ShouldNotifyCalibrationFailure(同じ失敗を繰り返し通知しない) + // ================================================================================ + + /// 未通知(直前の通知が null)なら通知する + [Test] + public void ShouldNotifyCalibrationFailure_未通知_trueを返す() { + Assert.That(CameraBase.ShouldNotifyCalibrationFailure(null, "校正に失敗しました(A)"), Is.True); + } + + /// 直前に通知した内容と同じなら通知しない(毎フレーム同じ例外が起きても通知が出続けない) + [Test] + public void ShouldNotifyCalibrationFailure_直前と同じ内容_falseを返す() { + Assert.That(CameraBase.ShouldNotifyCalibrationFailure("校正に失敗しました(A)", "校正に失敗しました(A)"), Is.False); + } + + /// 直前と異なる内容(別の理由の失敗)なら通知する + [Test] + public void ShouldNotifyCalibrationFailure_直前と異なる内容_trueを返す() { + Assert.That(CameraBase.ShouldNotifyCalibrationFailure("校正に失敗しました(A)", "校正に失敗しました(B)"), Is.True); + } + + // ================================================================================ + // FormatChartDetectionMessage(失敗表示の持続) + // ================================================================================ + + /// 直前の校正失敗が無ければ,チャート検出の表示はそのまま出す + [Test] + public void FormatChartDetectionMessage_失敗なし_検出側の表示をそのまま返す() { + Assert.That(CameraBase.FormatChartDetectionMessage(null, "舌診チャートの検出中"), Is.EqualTo("舌診チャートの検出中")); + } + + /// 直前の校正失敗があれば,検出中の表示を「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替える + [Test] + public void FormatChartDetectionMessage_失敗あり検出中_失敗の表示に差し替える() { + Assert.That(CameraBase.FormatChartDetectionMessage("理由X", "舌診チャートの検出中"), + Is.EqualTo("校正に失敗しました(理由X)" + RetryingSuffix)); + } + + /// 直前の校正失敗があれば,チャート検出後の「校正中」の表示も差し替える(校正が完了するまで失敗を出し続ける) + [Test] + public void FormatChartDetectionMessage_失敗あり校正中_失敗の表示に差し替える() { + Assert.That(CameraBase.FormatChartDetectionMessage("理由X", "舌診チャート検出 校正中"), + Is.EqualTo("校正に失敗しました(理由X)" + RetryingSuffix)); + } + + /// チャートが上下逆の案内は,直前の校正失敗があっても差し替えない(設置し直しの案内のため.ユーザー合意済み) + [Test] + public void FormatChartDetectionMessage_失敗あり上下逆の案内_差し替えない() { + Assert.That(CameraBase.FormatChartDetectionMessage("理由X", ChartDetector.UpsideDownMessage), Is.EqualTo(ChartDetector.UpsideDownMessage)); + } + + /// 上下逆の案内の文言は従来どおり(ChartDetector の表示の特性化) + [Test] + public void UpsideDownMessage_文言_従来の表示と同じ() { + Assert.That(ChartDetector.UpsideDownMessage, Is.EqualTo("舌診チャートが上下逆方向です")); + } + + // ================================================================================ + // FormatCalibrationDone(校正画像の保存失敗の警告) + // ================================================================================ + + /// 校正画像を保存できたときは従来どおり「自動校正完了」 + [Test] + public void FormatCalibrationDone_保存成功_自動校正完了() { + Assert.That(CameraBase.FormatCalibrationDone(true), Is.EqualTo("自動校正完了")); + } + + /// 校正画像の保存に失敗したときは「自動校正完了(校正画像の保存に失敗)」と警告を添える + [Test] + public void FormatCalibrationDone_保存失敗_保存失敗の警告を添える() { + Assert.That(CameraBase.FormatCalibrationDone(false), Is.EqualTo("自動校正完了(校正画像の保存に失敗)")); + } + + // ================================================================================ + // GetDrainIdleTimeout(保存キューの待ち時間: 30 秒と撮影間隔の 2 倍の大きい方) + // ================================================================================ + + /// 単発撮影(間隔 0)は 30 秒 + [Test] + public void GetDrainIdleTimeout_間隔0_30秒() { + Assert.That(CameraBase.GetDrainIdleTimeout(0), Is.EqualTo(TimeSpan.FromSeconds(30))); + } + + /// 負の間隔(不正値)は 30 秒(下限を割らない) + [Test] + public void GetDrainIdleTimeout_負の間隔_30秒() { + Assert.That(CameraBase.GetDrainIdleTimeout(-1000), Is.EqualTo(TimeSpan.FromSeconds(30))); + } + + /// 間隔 1000ms(2 倍が 30 秒未満)は 30 秒 + [Test] + public void GetDrainIdleTimeout_間隔1000ms_30秒() { + Assert.That(CameraBase.GetDrainIdleTimeout(1000), Is.EqualTo(TimeSpan.FromSeconds(30))); + } + + /// 間隔 15000ms(2 倍がちょうど 30 秒)は 30 秒(境界) + [Test] + public void GetDrainIdleTimeout_間隔15000ms_30秒() { + Assert.That(CameraBase.GetDrainIdleTimeout(15000), Is.EqualTo(TimeSpan.FromSeconds(30))); + } + + /// 間隔 15001ms(2 倍が 30 秒をわずかに超える)は間隔の 2 倍(境界の直後) + [Test] + public void GetDrainIdleTimeout_間隔15001ms_間隔の2倍() { + Assert.That(CameraBase.GetDrainIdleTimeout(15001), Is.EqualTo(TimeSpan.FromMilliseconds(30002))); + } + + /// 間隔 20000ms は間隔の 2 倍(40 秒) + [Test] + public void GetDrainIdleTimeout_間隔20000ms_40秒() { + Assert.That(CameraBase.GetDrainIdleTimeout(20000), Is.EqualTo(TimeSpan.FromSeconds(40))); + } + + /// 間隔 int.MaxValue でも溢れず(負や 30 秒に戻らず),間隔の 2 倍になる + [Test] + public void GetDrainIdleTimeout_間隔intMaxValue_溢れずに間隔の2倍() { + var expected = TimeSpan.FromTicks(2L * int.MaxValue * TimeSpan.TicksPerMillisecond); + + TimeSpan actual = TimeSpan.Zero; + Assert.DoesNotThrow((Action)(() => actual = CameraBase.GetDrainIdleTimeout(int.MaxValue))); + Assert.That(actual, Is.EqualTo(expected)); + } + } +} diff --git a/TIASshot.Tests/CameraBaseShotIntegrationTests.cs b/TIASshot.Tests/CameraBaseShotIntegrationTests.cs index ade0e20..461262d 100644 --- a/TIASshot.Tests/CameraBaseShotIntegrationTests.cs +++ b/TIASshot.Tests/CameraBaseShotIntegrationTests.cs @@ -23,7 +23,9 @@ /// - 撮影ボタンは保存が完了するまで無効のままとなり,保存処理の終了後に再び操作可能になる /// - 撮影・保存で使った Mat(ネイティブ資源)は残さず解放する(CLAUDE.md・error-handling.md) /// - /// 方針未決のためアサートしない: 枚数 0 の完了表示の有無,DrainShots の待機上限,SaveThread の同期 Invoke. + /// 方針未決のためアサートしない: 枚数 0 の完了表示の有無. + /// PLAN_05「束 B の方針」: 保存スレッドから UI への通知は UI スレッドを待たない(撮影中でも保存が進む). + /// DrainShots の待機上限(30 秒と撮影間隔の 2 倍の大きい方)は 30 秒を超えるため層①(GetDrainIdleTimeout)で確かめる. /// /// 失敗の注入: CaptureFrame を上書きした派生偽カメラ()で,指定枚目に /// CaptureFailedException を投げる/撮影開始時に保存先へ同名フォルダを作り画像保存を失敗させる. @@ -237,28 +239,38 @@ /// /// 連続撮影の直後(保存スレッドの動作中)に切断しても例外にならず,保存は最後まで行われ, - /// 撮影ボタンが再び有効になり,チャートマスクが解放される. - /// 保存スレッドは UI スレッドへの Invoke(進捗表示)を伴うため,UI スレッドがメッセージを処理するまでは - /// 必ず生存している.撮影直後にメッセージを処理せずに切断することで「保存中の切断」を決定的に作る. + /// 撮影ボタンが再び有効になり,チャートマスクが解放され,プレビューが停止している. + /// 保存スレッドから UI への通知は待たない投稿(BeginInvoke)のため,撮影から戻った時点で保存スレッドが + /// 生きているかは処理速度次第になる.そこで 1 枚目の保存後の解放(Mat.Dispose)で保存スレッドを止めておき + /// (),保存スレッドが確実に保存中の状態で切断してから止めを外す. /// [Test, Category("Integration")] public void 切断_保存中_例外にならず保存が完了し撮影ボタンが再有効化されマスクが解放される() { // Arrange / Act var obs = IntegrationHarness.Run(h => { - var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()))); + var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { HoldSaveAfterNo = 1 })); h.Calibrate(camera); h.Form.SelfTestSetMultiShots("3", "0"); var masks = camera.ChartMasksSnapshot.ToList(); h.Form.SelfTestClearLog(); h.Form.EnableShots(false); - camera.ShotMulti(); - var savingAtDisconnect = camera.IsSaveThreadStarted; + var savingAtDisconnect = false; string disconnectError = null; try { - camera.Disconnect(); - } catch (Exception ex) { - disconnectError = ex.ToString(); + camera.ShotMulti(); + // 保存スレッドが 1 枚目の保存を終えて止まる(=保存中のまま)まで待つ. + // 保存スレッドは UI スレッドを待たないため,ここでメッセージを処理しなくてよい + var held = camera.SaveHeld.Wait(HoldWaitTimeout); + savingAtDisconnect = held && camera.IsSaveThreadStarted; + try { + camera.Disconnect(); + } catch (Exception ex) { + disconnectError = ex.ToString(); + } + } finally { + // 止めの解放漏れで保存スレッドが残らないよう,どの経路でも外す + camera.ReleaseSave.Set(); } h.WaitSaveFinished(camera); @@ -272,7 +284,7 @@ }, ShotOptions()); // Assert - Assert.That(obs.SavingAtDisconnect, Is.True, "前提: 保存スレッドの動作中に切断したこと"); + Assert.That(obs.SavingAtDisconnect, Is.True, "前提: 保存スレッドの動作中(1 枚目の保存後で止めた状態)に切断したこと"); var expected = new List(); for (var no = 1; no <= 3; no++) expected.AddRange(obs.ExpectedFilesFor(no)); using (Assert.EnterMultipleScope()) { @@ -286,9 +298,45 @@ } // ================================================================================ + // 撮影中の保存の進行(PLAN_05「束 B の方針」: 保存スレッドから UI への通知は UI スレッドを待たない) + // ================================================================================ + + /// + /// 連続撮影(4 枚・間隔 0)の撮影中(UI スレッドが撮影ループでメッセージを処理していない間)にも保存が進み, + /// 4 枚目の取得時点で 2 枚目の撮影画像(Shot0002.png)が保存される(画像がキューに溜まり続けない). + /// 保存スレッドの進捗表示が UI スレッドを待つ実装では,1 枚目の保存後の進捗表示で止まり 2 枚目が保存されない. + /// + [Test, Category("Integration")] + public void ShotMulti_4枚間隔0_撮影中にも保存が進み4枚目の取得時点で2枚目が保存されている() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { + WaitSavedNo = 2, WaitSavedAtCapture = 4, WaitSavedLimit = TimeSpan.FromSeconds(2), + })); + h.Calibrate(camera); + h.Form.SelfTestSetMultiShots("4", "0"); + var o = ShootMultiAndObserve(h, camera); + o.SavedDuringShot = camera.SavedDuringShot; + return o; + }, ShotOptions()); + + // Assert + Assert.That(obs.CaptureCalls, Is.EqualTo(4), "前提: 4 枚とも撮影されたこと"); + Assert.That(obs.SavedDuringShot, Is.Not.Null, "前提: 4 枚目の取得時に保存の進行を確かめたこと"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.SavedDuringShot, Is.True, "撮影中(4 枚目の取得時点)に 2 枚目の撮影画像が保存されていること"); + Assert.That(obs.Errors, Is.Empty, "エラー通知が無いこと"); + Assert.That(obs.ShotsEnabled, Is.True, "保存完了後に撮影ボタンが再び有効になること"); + } + } + + // ================================================================================ // 共通処理 // ================================================================================ + // 保存スレッドが止まるのを待つ上限(層②の 1 件あたりの上限 15 秒に収める) + private static readonly TimeSpan HoldWaitTimeout = TimeSpan.FromSeconds(5); + /// /// 撮影系テストの実行条件.保存時間の大半は変換次元ごとの sRGB 変換のため,変換次元を 1 つ(4)に絞って /// 層②全体の実行時間の目安(30 秒)に収める.全次元での保存はフェーズ 1 の CameraBaseSaveIntegrationTests が確認する. @@ -440,6 +488,7 @@ public int MaskCount; public int MasksDisposed; public bool PreviewRunning; + public bool? SavedDuringShot; /// SPEC_01 のファイル名規則({NO}: 連番 4 桁)での撮影画像名 public string RgbFileFor(int no) => RgbPattern.Replace("{NO}", $"{no:0000}"); @@ -458,6 +507,8 @@ /// - FailCaptureAt 枚目の CaptureFrame で CaptureFailedException(FailMessage) を投げる /// - BlockSaveNo 番の撮影画像の保存先に同名フォルダを作り,保存を失敗させる(CameraBaseSaveIntegrationTests と同じ手段) /// - 保存キューへの直接追加と ResetShotQueue の呼び出し口(ResetShotQueue の後始末の確認用) + /// - HoldSaveAfterNo 枚目の保存後(保存スレッドがその Mat を解放するとき)に保存スレッドを止めておく + /// (止まったら SaveHeld を Set し,ReleaseSave が Set されるまで待つ.保存中の切断を決定的に作るため) /// private sealed class ProbeCamera : SelfTestCamera { @@ -478,6 +529,27 @@ /// CaptureFrame が返した Mat(古い順) public List Captured { get; } = new List(); + /// 撮影中に保存の進行を確かめる撮影画像の連番(1 始まり.0 なら確かめない) + public int WaitSavedNo { get; set; } + + /// 保存の進行を確かめる CaptureFrame の回(1 始まり) + public int WaitSavedAtCapture { get; set; } + + /// 保存の進行を待つ上限 + public TimeSpan WaitSavedLimit { get; set; } = TimeSpan.FromSeconds(2); + + /// WaitSavedAtCapture 回目の取得時点で WaitSavedNo 番の撮影画像が保存されたか(確かめていなければ null) + public bool? SavedDuringShot { get; private set; } + + /// 保存後に保存スレッドを止めておく撮影画像の連番(1 始まり.0 なら止めない) + public int HoldSaveAfterNo { get; set; } + + /// 保存スレッドが HoldSaveAfterNo 枚目の保存を終えて止まったときに Set される + public System.Threading.ManualResetEventSlim SaveHeld { get; } = new System.Threading.ManualResetEventSlim(false); + + /// Set すると止めていた保存スレッドが再開する(テスト側は finally で必ず Set すること) + public System.Threading.ManualResetEventSlim ReleaseSave { get; } = new System.Threading.ManualResetEventSlim(false); + public void AddToShotQueue(Mat mat) => _shots.Add(mat); public void ResetQueue() => ResetShotQueue(); @@ -492,14 +564,57 @@ protected override Mat CaptureFrame() { CaptureCalls++; + if (WaitSavedNo > 0 && CaptureCalls == WaitSavedAtCapture) { + // 撮影ループは UI スレッドで動くため,ここではメッセージを処理せずに待つ(撮影中の UI スレッドと同じ状況) + var path = Path.Combine(CurrentSaveFolder, Config.GetString("File/RgbImage").Replace("{NO}", $"{WaitSavedNo:0000}")); + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!File.Exists(path) && sw.Elapsed < WaitSavedLimit) { + System.Threading.Thread.Yield(); + System.Threading.Thread.SpinWait(1000); + } + SavedDuringShot = File.Exists(path); + } if (FailCaptureAt > 0 && CaptureCalls == FailCaptureAt) { throw new CaptureFailedException(FailMessage); } var frame = base.CaptureFrame(); + if (HoldSaveAfterNo > 0 && CaptureCalls == HoldSaveAfterNo) { + // 保存スレッドは保存後に Mat を解放する(SaveThread の using).解放時に止まる Mat へ差し替える + var holding = new HoldingMat(frame, SaveHeld, ReleaseSave); + frame.Dispose(); + frame = holding; + } Captured.Add(frame); return frame; } } + + /// + /// 解放(Dispose)時に held を Set し,release が Set されるまで(上限つきで)止まる Mat. + /// SaveImages は上書きできないため,保存スレッドが保存後に Mat を解放する箇所で保存スレッドを止める. + /// ファイナライザ経由(disposing=false)では DisposeManaged が呼ばれないため止まらない. + /// + private sealed class HoldingMat : Mat { + // release の待機上限(解放漏れでも保存スレッドを残さない.層②の 1 件あたりの上限 15 秒に収める) + private static readonly TimeSpan ReleaseTimeout = TimeSpan.FromSeconds(10); + private readonly System.Threading.ManualResetEventSlim _held; + private readonly System.Threading.ManualResetEventSlim _release; + + public HoldingMat(Mat src, System.Threading.ManualResetEventSlim held, System.Threading.ManualResetEventSlim release) + : base(src.Rows, src.Cols, src.Type()) { + src.CopyTo(this); + _held = held; + _release = release; + } + + protected override void DisposeManaged() { + _held.Set(); + if (!_release.Wait(ReleaseTimeout)) { + System.Diagnostics.Debug.WriteLine("警告: HoldingMat の解放待ちがタイムアウトしました"); + } + base.DisposeManaged(); + } + } } } #endif diff --git a/TIASshot.Tests/Form1CanCloseTests.cs b/TIASshot.Tests/Form1CanCloseTests.cs new file mode 100644 index 0000000..1ffb383 --- /dev/null +++ b/TIASshot.Tests/Form1CanCloseTests.cs @@ -0,0 +1,26 @@ +using NUnit.Framework; + +namespace TIASshot.Tests { + + /// + /// Form1.CanClose(アプリを閉じてよいかの判定)の単体テスト(層①). + /// + /// 仕様: PLAN_05「束 B の方針」(アプリを閉じるとき) + /// - 保存中なら閉じるのを取り消す(閉じる理由は区別しない) + /// + [TestFixture] + public class Form1CanCloseTests { + + /// 保存中は閉じない(未保存の画像が失われるため) + [Test] + public void CanClose_保存中_falseを返す() { + Assert.That(Form1.CanClose(true), Is.False); + } + + /// 保存中でなければ閉じてよい + [Test] + public void CanClose_保存中でない_trueを返す() { + Assert.That(Form1.CanClose(false), Is.True); + } + } +} diff --git a/TIASshot.Tests/Form1CloseIntegrationTests.cs b/TIASshot.Tests/Form1CloseIntegrationTests.cs new file mode 100644 index 0000000..35603f0 --- /dev/null +++ b/TIASshot.Tests/Form1CloseIntegrationTests.cs @@ -0,0 +1,199 @@ +#if SELFTEST +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + + /// + /// アプリを閉じるとき(Form1_FormClosing → ShutdownOnClose)の結合ロジックテスト(層②). + /// + /// 仕様: PLAN_05「束 B の方針」(アプリを閉じるとき)・testing.md「層②: CameraBase の切断・終了」 + /// - 保存中なら閉じるのを取り消して「保存中」と知らせる(未保存の画像を失わない) + /// - 閉じ始めたらカメラのコールバックから UI への呼び出しを行わない(切断がコールバックの終了を待つ間のハング防止) + /// - 閉じるときは「閉じ始め → 再描画タイマー停止 → カメラ切断 → 光源を消してポートを閉じる」の順に後始末する + /// - 光源が開いていない(ヘッドレスでは実機が無い)場合も例外にならない + /// + /// 観測は Form1.SelfTest.cs のアクセサ(SelfTestRequestClose・SelfTestIsClosing・SelfTestCloseSteps・ + /// SelfTestRefreshTimerRunning・SelfTestMessage)と SelfTestCamera のアクセサで行う. + /// 失敗の注入: 保存スレッドを動作中のまま保持する派生偽カメラ(). + /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class Form1CloseIntegrationTests { + + // 閉じるときの後始末の順序(PLAN_05「束 B の方針」・Form1.ShutdownOnClose) + private static readonly string[] CloseOrder = { "closing", "timer", "camera", "light" }; + + // 閉じる処理がカメラのコールバックを待ってハングしていないとみなす上限 + private static readonly TimeSpan CloseLimit = TimeSpan.FromSeconds(1); + + // ================================================================================ + // 保存中(失敗注入: 保存スレッドが動作中) + // ================================================================================ + + /// + /// 保存中に閉じようとすると,閉じるのが取り消され,「保存中」の表示が出て,後始末(閉じ始め・タイマー停止・切断・光源)は行われない. + /// + [Test, Category("Integration")] + public void 閉じる_保存中_取り消されて保存中と表示され後始末は行わない() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var camera = h.Connect(h.Track(new HeldSaveCamera(h.Form, h.ChartFrameFactory()))); + h.Form.SelfTestAttachCamera(camera); + using (var release = new ManualResetEventSlim(false)) { + try { + camera.HoldSaveThread(release); + var saving = camera.IsSaving; + var closed = h.Form.SelfTestRequestClose(); + return new { + saving, + closed, + message = h.Form.SelfTestMessage, + closing = h.Form.SelfTestIsClosing, + steps = h.Form.SelfTestCloseSteps.ToList(), + timer = h.Form.SelfTestRefreshTimerRunning, + }; + } finally { + release.Set(); + h.PumpUntil(() => !camera.IsSaving, "保存スレッド(保持)の終了", TimeSpan.FromSeconds(3)); + } + } + }); + + // Assert + Assert.That(obs.saving, Is.True, "前提: 保存スレッドの動作中に閉じようとしたこと"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.closed, Is.False, "保存中は閉じるのが取り消されること"); + Assert.That(obs.message, Is.EqualTo(Form1.SavingOnCloseMessage), "保存中である旨が表示されること"); + Assert.That(obs.closing, Is.False, "取り消したときは閉じ始めの状態にならないこと(以降の通知が止まらない)"); + Assert.That(obs.steps, Is.Empty, "取り消したときは後始末を行わないこと"); + Assert.That(obs.timer, Is.True, "取り消したときは再描画タイマーが動いたままであること"); + } + } + + /// + /// 保存が終わった後に閉じようとすると閉じると決まり,後始末が「閉じ始め → タイマー停止 → カメラ切断 → 光源」の順に行われ, + /// 再描画タイマーが止まる.光源は開いていない(ヘッドレスでは実機が無い)が例外にならない. + /// + [Test, Category("Integration")] + public void 閉じる_保存の終了後_閉じると決まり後始末が決められた順に行われる() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var camera = h.Connect(h.Track(new HeldSaveCamera(h.Form, h.ChartFrameFactory()))); + h.Form.SelfTestAttachCamera(camera); + bool cancelledWhileSaving; + using (var release = new ManualResetEventSlim(false)) { + camera.HoldSaveThread(release); + cancelledWhileSaving = !h.Form.SelfTestRequestClose(); + release.Set(); + h.PumpUntil(() => !camera.IsSaving, "保存スレッド(保持)の終了", TimeSpan.FromSeconds(3)); + } + bool closed = false; + string error = null; + try { + closed = h.Form.SelfTestRequestClose(); + } catch (Exception ex) { + error = ex.ToString(); + } + return new { + cancelledWhileSaving, + closed, + error, + closing = h.Form.SelfTestIsClosing, + steps = h.Form.SelfTestCloseSteps.ToList(), + timer = h.Form.SelfTestRefreshTimerRunning, + }; + }); + + // Assert + Assert.That(obs.cancelledWhileSaving, Is.True, "前提: 保存中は取り消されたこと"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.error, Is.Null, "閉じる処理が例外にならないこと(光源が開いていない場合を含む)"); + Assert.That(obs.closed, Is.True, "保存の終了後は閉じると決まること"); + Assert.That(obs.closing, Is.True, "閉じ始めの状態になること"); + Assert.That(obs.steps, Is.EqualTo(CloseOrder), "後始末が「閉じ始め → タイマー停止 → カメラ切断 → 光源」の順に行われること"); + Assert.That(obs.timer, Is.False, "再描画タイマーが止まること"); + } + } + + // ================================================================================ + // ハング防止(カメラのコールバックが UI へ通知し続けている間に閉じる) + // ================================================================================ + + /// + /// チャートのプレビューを流したまま(コールバックが毎フレーム UI へ表示を通知している間に)閉じても, + /// 閉じる処理はコールバックとの相互待機にならずすぐに戻り(1 秒未満),プレビューは止まり,コールバックで例外が出ない. + /// 切断(偽カメラはプレビュースレッドの終了を待つ.実カメラの SDK のストリーム停止と同じ)の間に, + /// コールバックが UI スレッドを待つ実装だと,切断の待機上限まで戻らない. + /// + [Test, Category("Integration")] + public void 閉じる_プレビュー中_すぐに戻りプレビューが止まり例外が出ない() { + // Arrange: 固定判定を成立させない設定にして,毎フレーム「舌診チャートの検出中」を通知し続けさせる + var options = new IntegrationOptions().WithConfig("Calib/ChartSetCount", "100000"); + + // Act + var obs = IntegrationHarness.Run(h => { + var camera = h.CreateChartCamera(); + h.Form.SelfTestAttachCamera(camera); + camera.StartPreview(1000000, 1, false); + h.PumpUntil(() => camera.FramesDelivered >= 5 || !camera.IsPreviewRunning, "プレビューの開始", TimeSpan.FromSeconds(5)); + var runningBeforeClose = camera.IsPreviewRunning; + var sw = Stopwatch.StartNew(); + var closed = h.Form.SelfTestRequestClose(); + var elapsed = sw.Elapsed; + h.PumpUntil(() => !camera.IsPreviewRunning, "プレビューの停止", TimeSpan.FromSeconds(5)); + return new { + runningBeforeClose, + closed, + elapsedMs = elapsed.TotalMilliseconds, + previewRunning = camera.IsPreviewRunning, + PreviewError = camera.PreviewError?.ToString(), + }; + }, options); + + // Assert + Assert.That(obs.runningBeforeClose, Is.True, "前提: プレビューを流したまま閉じたこと"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.closed, Is.True, "保存中でないので閉じると決まること"); + Assert.That(obs.elapsedMs, Is.LessThan(CloseLimit.TotalMilliseconds), "閉じる処理がコールバックとの相互待機にならずすぐに戻ること"); + Assert.That(obs.previewRunning, Is.False, "閉じた後はプレビューが止まっていること"); + Assert.That(obs.PreviewError, Is.Null, "閉じる間にカメラのコールバックで例外が出ないこと"); + } + } + + // ================================================================================ + // 失敗注入用の偽カメラ + // ================================================================================ + + /// + /// 保存スレッド(_saveThread)を,指定のイベントが立つまで動作中のまま保持する偽カメラ(保存中の状況の注入用). + /// 実際の保存は行わない(IsSaving=保存スレッドが生存中,の判定だけを使う). + /// + private sealed class HeldSaveCamera : SelfTestCamera { + + public HeldSaveCamera(Form1 form, Func frameFactory) : base(form, frameFactory) { } + + /// release が立つまで(最大 10 秒)生存する保存スレッドを起動する + public void HoldSaveThread(ManualResetEventSlim release) { + var started = new ManualResetEventSlim(false); + var thread = new Thread(() => { + started.Set(); + release.Wait(TimeSpan.FromSeconds(10)); + }) { + IsBackground = true, + Name = "HeldSaveCamera.Save", + }; + _saveThread = thread; + thread.Start(); + started.Wait(TimeSpan.FromSeconds(3)); + started.Dispose(); + } + } + } +} +#endif diff --git a/TIASshot.Tests/Form1UiIntegrationTests.cs b/TIASshot.Tests/Form1UiIntegrationTests.cs index cdae8e4..aeb75d0 100644 --- a/TIASshot.Tests/Form1UiIntegrationTests.cs +++ b/TIASshot.Tests/Form1UiIntegrationTests.cs @@ -1,5 +1,7 @@ #if SELFTEST using System; +using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -12,6 +14,8 @@ /// SPEC_01「撮影」「データ保存」(データ名は保存フォルダ名 `[時刻]-[データ名]` の一部,連続撮影の枚数・間隔は UI で変更可能). /// - 取得系(GetDataName・GetNumMultiShots・GetMultiShotsInterval)は,どのスレッドから呼んでも UI の入力値を返す /// - 通知系(ShowMessage・EnableShots)は,どのスレッドから呼んでも UI に反映される + /// - PLAN_05「束 B の方針」: UI スレッド外からの通知系は UI スレッドの処理を待たない(撮影中でも保存が進む). + /// 閉じ始めた後・ハンドル未生成のフォームでは何もしない(閉じるときのハング防止) /// /// ワーカースレッドの呼び出しは Task.Run で行い,UI スレッド(ハーネスの STA スレッド)はメッセージを処理しながら /// 完了を待つ(Control.Invoke は UI スレッドがメッセージを処理しないと返らないため). @@ -148,6 +152,158 @@ } // ================================================================================ + // 通知系(UI スレッドを待たない.PLAN_05「束 B の方針」: 保存スレッドから UI への通知) + // ================================================================================ + + /// + /// ワーカースレッドから ShowMessage を呼んだとき,UI スレッドがメッセージを処理していなくても待たずに戻り, + /// その後 UI スレッドが投稿を処理するとステータス表示に反映される. + /// + [Test, Category("Integration")] + public void ShowMessage_ワーカースレッドから呼ぶ_UIスレッドの処理を待たずに戻り後で反映される() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + h.Form.ShowMessage("before"); + var task = Task.Run(() => h.Form.ShowMessage("async-message")); + var returned = WaitWithoutPumping(() => task.IsCompleted, NonBlockingLimit); + // 待っていた場合(不具合時)も後始末できるよう,メッセージを処理して完了させる + h.PumpUntil(() => task.IsCompleted, "ワーカースレッドからの呼び出しの完了", WorkerTimeout); + h.FlushPostedUiCalls(); + return new { returned, Error = task.Exception?.GetBaseException()?.Message, Message = h.Form.SelfTestMessage }; + }); + + // Assert + using (Assert.EnterMultipleScope()) { + Assert.That(obs.returned, Is.True, "UI スレッドがメッセージを処理していなくても ShowMessage が待たずに戻ること"); + Assert.That(obs.Error, Is.Null, "ワーカースレッドからの呼び出しが例外にならないこと"); + Assert.That(obs.Message, Is.EqualTo("async-message"), "UI スレッドが投稿を処理した後はステータス表示に反映されること"); + } + } + + /// + /// ワーカースレッドから EnableShots を呼んだとき,UI スレッドがメッセージを処理していなくても待たずに戻り, + /// その後 UI スレッドが投稿を処理すると撮影ボタンに反映される. + /// + [Test, Category("Integration")] + public void EnableShots_ワーカースレッドから呼ぶ_UIスレッドの処理を待たずに戻り後で反映される() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var before = h.Form.SelfTestShotsEnabled; + var task = Task.Run(() => h.Form.EnableShots(true)); + var returned = WaitWithoutPumping(() => task.IsCompleted, NonBlockingLimit); + h.PumpUntil(() => task.IsCompleted, "ワーカースレッドからの呼び出しの完了", WorkerTimeout); + h.FlushPostedUiCalls(); + return new { before, returned, Error = task.Exception?.GetBaseException()?.Message, enabled = h.Form.SelfTestShotsEnabled }; + }); + + // Assert + Assert.That(obs.before, Is.False, "前提: 開始時(校正前)は撮影ボタンが無効であること"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.returned, Is.True, "UI スレッドがメッセージを処理していなくても EnableShots が待たずに戻ること"); + Assert.That(obs.Error, Is.Null, "ワーカースレッドからの呼び出しが例外にならないこと"); + Assert.That(obs.enabled, Is.True, "UI スレッドが投稿を処理した後は撮影ボタンに反映されること"); + } + } + + // ================================================================================ + // 通知系(失敗注入: 閉じ始めた後・ハンドル未生成) + // ================================================================================ + + /// + /// 閉じ始めた後(Form1_FormClosing で閉じると決めた後)は,UI スレッド・ワーカースレッドのどちらから + /// ShowMessage / EnableShots を呼んでも例外にならず,表示も撮影ボタンも変わらない(閉じるときのハング防止). + /// + [Test, Category("Integration")] + public void ShowMessageとEnableShots_閉じ始めた後_例外にならず表示もボタンも変わらない() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + h.Form.ShowMessage("before-close"); + var closed = h.Form.SelfTestRequestClose(); + var messageBefore = h.Form.SelfTestMessage; + var enabledBefore = h.Form.SelfTestShotsEnabled; + string uiError = null; + try { + h.Form.ShowMessage("after-close-ui"); + h.Form.EnableShots(!enabledBefore); + } catch (Exception ex) { + uiError = ex.ToString(); + } + var worker = CallFromWorker(h, () => { + h.Form.ShowMessage("after-close-worker"); + h.Form.EnableShots(!enabledBefore); + return 0; + }); + h.FlushPostedUiCalls(); + return new { + closed, + closing = h.Form.SelfTestIsClosing, + messageBefore, + enabledBefore, + uiError, + workerError = worker.Error, + message = h.Form.SelfTestMessage, + enabled = h.Form.SelfTestShotsEnabled, + }; + }); + + // Assert + Assert.That(obs.closed, Is.True, "前提: 保存中でないので閉じると決まったこと"); + Assert.That(obs.closing, Is.True, "前提: 閉じ始めの状態であること"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.uiError, Is.Null, "閉じ始めた後の UI スレッドからの呼び出しが例外にならないこと"); + Assert.That(obs.workerError, Is.Null, "閉じ始めた後のワーカースレッドからの呼び出しが例外にならないこと"); + Assert.That(obs.message, Is.EqualTo(obs.messageBefore), "閉じ始めた後は表示が変わらないこと"); + Assert.That(obs.enabled, Is.EqualTo(obs.enabledBefore), "閉じ始めた後は撮影ボタンが変わらないこと"); + } + } + + /// + /// ウィンドウハンドルが未生成のフォームでは,UI スレッド・ワーカースレッドのどちらから ShowMessage / EnableShots を + /// 呼んでも例外にならず,表示も撮影ボタンも変わらない. + /// + [Test, Category("Integration")] + public void ShowMessageとEnableShots_ハンドル未生成_例外にならず表示もボタンも変わらない() { + // Arrange / Act + var obs = IntegrationHarness.Run(h => { + var form = h.AddDisposable(new Form1(Form1.SelfTestHeadless.Instance)); + var handleBefore = form.IsHandleCreated; + var messageBefore = form.SelfTestMessage; + var enabledBefore = form.SelfTestShotsEnabled; + string uiError = null; + try { + form.ShowMessage("no-handle-ui"); + form.EnableShots(!enabledBefore); + } catch (Exception ex) { + uiError = ex.ToString(); + } + var worker = CallFromWorker(h, () => { + form.ShowMessage("no-handle-worker"); + form.EnableShots(!enabledBefore); + return 0; + }); + h.PumpFor(TimeSpan.FromMilliseconds(50)); + return new { + handleBefore, + uiError, + workerError = worker.Error, + messageBefore, + message = form.SelfTestMessage, + enabledBefore, + enabled = form.SelfTestShotsEnabled, + }; + }); + + // Assert + Assert.That(obs.handleBefore, Is.False, "前提: ハンドル未生成のフォームであること"); + using (Assert.EnterMultipleScope()) { + Assert.That(obs.uiError, Is.Null, "ハンドル未生成での UI スレッドからの呼び出しが例外にならないこと"); + Assert.That(obs.workerError, Is.Null, "ハンドル未生成でのワーカースレッドからの呼び出しが例外にならないこと"); + Assert.That(obs.message, Is.EqualTo(obs.messageBefore), "ハンドル未生成では表示が変わらないこと"); + Assert.That(obs.enabled, Is.EqualTo(obs.enabledBefore), "ハンドル未生成では撮影ボタンが変わらないこと"); + } + } + + // ================================================================================ // 共通処理 // ================================================================================ @@ -165,6 +321,23 @@ return new WorkerResult { Value = task.Result }; } + // 「待たずに戻る」とみなす上限(UI スレッドの処理を待つ実装なら,メッセージを処理しない限り戻らない) + private static readonly TimeSpan NonBlockingLimit = TimeSpan.FromSeconds(1); + + /// + /// UI スレッドのメッセージを処理せずに,条件の成立を上限つきで待つ(成立したら true). + /// WaitHandle・Join は STA スレッドで一部のメッセージを処理しうるため使わず,Yield とスピンで待つ. + /// + private static bool WaitWithoutPumping(Func condition, TimeSpan limit) { + var sw = Stopwatch.StartNew(); + while (!condition()) { + if (sw.Elapsed > limit) return false; + Thread.Yield(); + Thread.SpinWait(1000); + } + return true; + } + private sealed class WorkerResult { public T Value; public string Error; diff --git a/TIASshot.Tests/IntegrationHarness.cs b/TIASshot.Tests/IntegrationHarness.cs index 906a6ee..d2bbf1f 100644 --- a/TIASshot.Tests/IntegrationHarness.cs +++ b/TIASshot.Tests/IntegrationHarness.cs @@ -95,6 +95,9 @@ /// 後始末(カメラ停止・フォーム破棄・一時フォルダ削除)に許す時間(本体の上限とは別枠) public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); + // 投稿済みの UI 呼び出しを処理し切るまでの上限(通常は数 ms で終わる.無限待機の回避が目的) + private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(3); + /// /// 層②の既定の設定上書き(1 件 1 秒前後で校正まで終わるよう短縮する). /// 同梱値: ChartSetCount=30・Frames=100・UpdateInterval=5. @@ -251,6 +254,9 @@ /// /// 条件が成立するまでメッセージを処理して待つ.成立しなければ を投げる. /// 待機中に UI スレッドで未処理例外が起きた場合はその例外(を内包した例外)を投げる. + /// 成立後は,それまでに投稿された UI 呼び出し(ワーカースレッドからの ShowMessage / EnableShots の BeginInvoke)を + /// 処理し切ってから戻る().条件の成立(例: 保存スレッドの終了)と, + /// そのスレッドが投稿した通知の反映との間のずれで観測が不安定にならないようにするため. /// /// 成立を待つ条件(STA スレッドで評価する) /// 待つ内容(失敗メッセージに使う.例: "保存スレッドの終了") @@ -267,11 +273,29 @@ $"{what} が {limit.TotalSeconds:0.0} 秒以内に成立しませんでした(開始から {Elapsed.TotalSeconds:0.0} 秒." + $"表示「{Form?.SelfTestMessage}」,最後のエラー通知「{Form?.SelfTestLastError ?? "なし"}」)"); } + FlushPostedUiCalls(); } finally { _currentWait = previous; } } + /// + /// これまでに UI スレッドへ投稿された呼び出し(BeginInvoke)を処理し切るまでメッセージを処理する. + /// 目印の処理を同じキューの末尾に投稿し,それが実行されるまで待つ(キューは投稿順に処理されるため, + /// 目印より前に投稿された通知は全て反映済みになる).フォームが無い・破棄済みなら何もしない. + /// + public void FlushPostedUiCalls() { + var form = Form; + if (form == null || form.IsDisposed || !form.IsHandleCreated) return; + var done = false; + form.BeginInvoke((MethodInvoker)(() => done = true)); + var ok = SelfTestPump.PumpUntil(() => done, FlushTimeout, ThrowIfUiException); + ThrowIfUiException(); + if (!ok) { + throw new TimeoutException($"投稿済みの UI 呼び出しが {FlushTimeout.TotalSeconds:0} 秒以内に処理し切れませんでした(開始から {Elapsed.TotalSeconds:0.0} 秒)"); + } + } + /// 指定時間だけメッセージを処理する(タイマー駆動の表示更新等を反映させる).残り時間を超えては待たない public void PumpFor(TimeSpan duration) { var d = duration < Remaining ? duration : Remaining; @@ -362,8 +386,9 @@ } /// - /// 撮影の保存スレッド(SaveThread)の終了を待つ.終了時点で SaveThread からの通知 - /// (ShowMessage・ShowError・EnableShots の Invoke)は処理済みである. + /// 撮影の保存スレッド(SaveThread)の終了を待つ.戻った時点で SaveThread からの通知 + /// (ShowMessage・EnableShots の BeginInvoke,ShowError の Invoke)は処理済みである + /// ( が投稿済みの UI 呼び出しを処理し切ってから戻るため). /// public void WaitSaveFinished(CameraBase camera, TimeSpan? timeout = null) { PumpUntil(() => !camera.IsSaveThreadStarted, "保存スレッドの終了", timeout); diff --git a/TIASshot.Tests/IoUtilSaveImageTests.cs b/TIASshot.Tests/IoUtilSaveImageTests.cs index 4c56a45..121b706 100644 --- a/TIASshot.Tests/IoUtilSaveImageTests.cs +++ b/TIASshot.Tests/IoUtilSaveImageTests.cs @@ -48,6 +48,26 @@ } /// + /// ファイル名が 255 文字を超える('a'×260+".png")保存は,例外にならず false を返す. + /// 層②の「校正画像の保存失敗」の注入(File/TccImage に長いファイル名を設定)が, + /// 例外ではなく「保存失敗=false」の経路を通ることの前提を固定する. + /// + [Test] + public void SaveImage_255文字を超えるファイル名_例外にならずfalseを返す() { + // Arrange + var path = Path.Combine(_tempFolder, new string('a', 260) + ".png"); + using (var img = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(128))) { + + // Act + var result = true; + Assert.DoesNotThrow((Action)(() => result = IoUtil.SaveImage(path, img)), "長いファイル名でも例外にならないこと"); + + // Assert + Assert.That(result, Is.False, "長いファイル名の保存失敗が false で報告されること"); + } + } + + /// /// 正常なパスへの保存は true を返しファイルが生成される(従来挙動の特性化) /// [Test] diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index db1ef44..bcc7a6b 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -90,6 +90,9 @@ + + +