diff --git a/CLAUDE.md b/CLAUDE.md index f8a888a..0d11690 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_05 フェーズ 2 として層②テストを校正・撮影・切断・UI 連携に広げ,報告台帳の 4 件を赤→緑で修正した +最新: PLAN_05 フェーズ 3a として撮影・保存の信頼性(校正中の例外・閉じるとき・UI 通知の非同期化・保存キューの待ち時間)を直した ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) 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 @@ + + + diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index 62a08a7..0e54a64 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -115,11 +115,22 @@ // 今回の撮影セッションで SaveThread を起動したか(IsSaveThreadStarted で公開する). // 他の 3 つより早く(Shot に入る前に)落とす必要があるため BeginShotRequest でもリセットする. private bool _saveThreadStarted = false; + // 今回の撮影要求の撮影間隔(ms)(単発は 0).StartSaveThread が保存キュー解放待ちの上限の算出に使う. + // BeginShotRequest でリセットし ShotMulti で設定する(Shot と同一の UI スレッド内で完結する). + private int _shotIntervalMs = 0; + + // 直近に通知した校正失敗のメッセージ(同じ失敗を毎フレーム通知しないため.校正完了でクリアする). + // カメラのコールバックスレッド(ProcessCalibrationFrame)だけが読み書きする. + private string _lastCalibrationError = null; + // 直近の校正失敗の理由(例外メッセージ).校正完了まで,チャート検出の表示を + // 「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替えるために使う + // (FormatChartDetectionMessage)._lastCalibrationError と同じスレッドで同時に設定・クリアする. + private string _lastCalibrationFailureReason = null; // SaveThread の起動完了待ちタイムアウト(通常は即座に完了する.無限待機の回避が目的) private static readonly TimeSpan SaveThreadReadyTimeout = TimeSpan.FromSeconds(5); - // 保存キュー解放待ちの無通信タイムアウト - // (撮影も CompleteAdding も来ない異常時にスレッドを残さないための保険) + // 保存キュー解放待ちの無通信タイムアウトの下限 + // (撮影も CompleteAdding も来ない異常時にスレッドを残さないための保険.実際の上限は GetDrainIdleTimeout) private static readonly TimeSpan DrainIdleTimeout = TimeSpan.FromSeconds(30); // 保存キュー解放時の 1 回あたりの取り出し待ち時間(ms)(無通信時間の判定粒度) private const int DrainPollMs = 100; @@ -144,6 +155,12 @@ /// public bool IsSaveThreadStarted => _saveThreadStarted && _saveThread != null && _saveThread.IsAlive; + /// + /// 画像保存スレッド(SaveThread)が生存しているか(今回のセッションかどうかは問わない). + /// アプリを閉じてよいかの判定(Form1.CanClose)に使う.保存中に閉じると未保存の画像が失われる. + /// + public bool IsSaving => _saveThread != null && _saveThread.IsAlive; + // プライベートメンバ readonly float UpdateRate; @@ -162,7 +179,11 @@ var tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef")); var tccXyz = IoUtil.LoadMatFromCsv(Config.GetString("File/TccXyzRef")); _colorCorrector = new ColorCorrector(tccSrgb, tccXyz); - _chartDetector = new ChartDetector(_form.ShowMessage); + // チャート検出の表示は,直前の校正失敗があれば失敗の表示に差し替える(失敗を上書きして気づけなくしない). + // ChartDetector はカメラのコールバックスレッド(ProcessCalibrationFrame)からだけ呼ばれるため, + // 同じスレッドで読み書きする _lastCalibrationFailureReason をロック無しで読んでよい. + _chartDetector = new ChartDetector( + msg => _form.ShowMessage(FormatChartDetectionMessage(_lastCalibrationFailureReason, msg))); } /// @@ -207,7 +228,8 @@ SetInfo("データ名", _form.GetDataName()); SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}"); SetInfo("撮影間隔(ms)", $"{_form.GetMultiShotsInterval()}"); - Shot(_form.GetNumMultiShots(), _form.GetMultiShotsInterval()); + _shotIntervalMs = _form.GetMultiShotsInterval(); + Shot(_form.GetNumMultiShots(), _shotIntervalMs); FinishShot("Sound/MultiShotDone"); } @@ -220,6 +242,7 @@ /// private void BeginShotRequest() { _saveThreadStarted = false; + _shotIntervalMs = 0; // 単発撮影の間隔(ShotMulti は Shot の直前に設定し直す) } /// @@ -299,7 +322,8 @@ /// finally では未消費の Mat を解放し,撮影ボタンを必ず再有効化する(UI 固着の防止). /// /// - protected void SaveThread(int numImages) { + /// 保存キュー解放待ちの無通信時間の上限(GetDrainIdleTimeout) + protected void SaveThread(int numImages, TimeSpan drainIdleTimeout) { // 消費対象は起動時点の _shots に固定する(次回撮影が _shots を差し替えても影響を受けない). var shots = _shots; @@ -333,7 +357,7 @@ // 【重要】順序は DrainShots → ShowError → EnableShots(true) を維持すること. // モーダル表示中は撮影ボタンが無効のままなので Shot への再入(=Join)が起きず, // Invoke のデッドロックを避けられる. - DrainShots(shots); + DrainShots(shots, drainIdleTimeout); if (saveError != null) { NotifySaveFailed(saveError); } @@ -378,13 +402,15 @@ /// 【重要】上限は「総経過時間」ではなく「無通信時間」で判定する.撮影枚数・間隔は UI から /// 任意に設定できるため(例: 60 枚 × 1000ms),総経過時間で打ち切ると正常な長時間連写の /// 途中で誤って待機を終え,未消費 Mat のリークと撮影中の EnableShots(true) を招く. + /// 無通信時間の上限は撮影間隔に応じて決める(GetDrainIdleTimeout.撮影間隔が長いと 1 枚ごとの無通信が長くなる). /// /// 対象の保存キュー - private void DrainShots(BlockingCollection shots) { + /// 無通信時間の上限 + private void DrainShots(BlockingCollection shots, TimeSpan idleTimeout) { var idle = Stopwatch.StartNew(); try { while (!shots.IsCompleted) { - if (idle.Elapsed > DrainIdleTimeout) { + if (idle.Elapsed > idleTimeout) { Debug.WriteLine("警告: 保存キューの解放待機がタイムアウトしました"); break; } @@ -401,34 +427,108 @@ } /// + /// 保存キュー解放待ち(DrainShots)の無通信時間の上限を求める. + /// 「30 秒」と「撮影間隔の 2 倍」の大きい方とする(撮影間隔が 30 秒を超える連写でも, + /// 次の 1 枚を待っている間に打ち切らないため).単発撮影・間隔 0 以下は 30 秒. + /// + /// 撮影間隔(ms).単発撮影は 0 + /// 無通信時間の上限 + internal static TimeSpan GetDrainIdleTimeout(int intervalMs) { + var byInterval = TimeSpan.FromMilliseconds(2.0 * intervalMs); + return byInterval > DrainIdleTimeout ? byInterval : DrainIdleTimeout; + } + + /// /// 校正フレーム処理の共通骨格(テンプレートメソッド) /// imgt が用意された後の校正/検出フローを担う. /// ゲイン反映(ApplyWhiteBalance)・収束時ログ出力(LogCalibrationConverged)・ /// 校正情報記録(RecordCalibrationInfo)は派生クラスに委譲する. /// 呼び出し順序は元コードに合わせ「LogCalibrationConverged → CalcTcc → RecordCalibrationInfo」. + /// カメラのコールバックから呼ばれるため,ここを例外境界とする(例外をコールバックへ伝えると + /// SDK のコールバックスレッドやプレビューが止まる).校正中の例外は HandleCalibrationFailure で + /// 通知し,チャート検出からやり直せる状態へ戻す. /// /// 処理対象 Mat(前処理済み) protected void ProcessCalibrationFrame(Mat imgt) { - if (_calibrating > 0) { - var whitePatch = Cv2.Mean(imgt, _chartMasks[12]); - Debug.WriteLine($"White patch R {whitePatch.Val2:.00} G {whitePatch.Val1:.00} B {whitePatch.Val0:.00}"); - if (_calibrating % Config.GetInt("Calib/UpdateInterval") == 0) { - ApplyWhiteBalance(whitePatch); - } - _calibrating--; - if (_calibrating == 0) { - LogCalibrationConverged(); - CalcTcc(imgt); - RecordCalibrationInfo(imgt); + try { + if (_calibrating > 0) { + var whitePatch = Cv2.Mean(imgt, _chartMasks[12]); + Debug.WriteLine($"White patch R {whitePatch.Val2:.00} G {whitePatch.Val1:.00} B {whitePatch.Val0:.00}"); + if (_calibrating % Config.GetInt("Calib/UpdateInterval") == 0) { + ApplyWhiteBalance(whitePatch); + } + _calibrating--; + if (_calibrating == 0) { + LogCalibrationConverged(); + CalcTcc(imgt); + RecordCalibrationInfo(imgt); #if MEMMONITOR - PreviewProfiler.MarkEvent("CALIB_END"); + PreviewProfiler.MarkEvent("CALIB_END"); #endif + } } + + if (!_calibrated && _calibrating == 0) { + DetectChart(imgt); + } + } catch (Exception ex) { + HandleCalibrationFailure(ex); + } + } + + /// + /// 校正中の例外の後始末(ProcessCalibrationFrame の例外境界から呼ぶ). + /// 未校正・校正フレーム数 0・チャートマスク解放の状態へ戻し,次のフレームからチャート検出をやり直させる. + /// 変換行列算出の後(RecordCalibrationInfo 等)で失敗して撮影ボタンを有効化済みの場合は無効に戻す. + /// 通知は「校正に失敗しました(理由)」とし,直前に通知した内容と同じなら繰り返さない + /// (設定不備等で毎フレーム同じ例外が起きる場合に通知とログが毎フレーム出続けないため). + /// + /// 校正中に発生した例外 + private void HandleCalibrationFailure(Exception ex) { + var wasCalibrated = _calibrated; + _calibrated = false; + _calibrating = 0; + ReleaseChartMasks(); + if (wasCalibrated) { + _form.EnableShots(false); } - if (!_calibrated && _calibrating == 0) { - DetectChart(imgt); - } + var message = FormatCalibrationFailure(ex); + _lastCalibrationFailureReason = ex.Message; // 校正完了までチャート検出の表示を差し替える + if (!ShouldNotifyCalibrationFailure(_lastCalibrationError, message)) return; + _lastCalibrationError = message; + Debug.WriteLine($"校正に失敗しました: {ex}"); + _form.ShowMessage(message); + } + + /// 校正失敗の通知文(理由として例外メッセージを含める) + /// 校正中に発生した例外 + internal static string FormatCalibrationFailure(Exception ex) { + return $"校正に失敗しました({ex.Message}).チャートの検出からやり直します"; + } + + /// + /// チャート検出(ChartDetector)の表示文を決める.直前の校正失敗が無ければ検出側の表示をそのまま返し, + /// あれば次に校正が完了するまで「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替える + /// (検出中の表示が毎フレーム失敗の通知を上書きし,恒常的な失敗に気づけなくなるのを防ぐ). + /// ただしチャートが上下逆の表示は設置し直しの案内のため差し替えない. + /// + /// 直前の校正失敗の理由(失敗が無い・校正完了後は null) + /// ChartDetector が出そうとした表示文 + /// 実際に表示する文 + internal static string FormatChartDetectionMessage(string lastFailureReason, string detectionMessage) { + if (lastFailureReason == null) return detectionMessage; + if (detectionMessage == ChartDetector.UpsideDownMessage) return detectionMessage; + return $"校正に失敗しました({lastFailureReason}).チャートの検出からやり直しています"; + } + + /// + /// 校正失敗を通知するか(直前に通知した内容と同じなら通知しない). + /// + /// 直前に通知した校正失敗の通知文(未通知・校正完了後は null) + /// 今回の通知文 + internal static bool ShouldNotifyCalibrationFailure(string lastNotified, string message) { + return !string.Equals(lastNotified, message, StringComparison.Ordinal); } /// @@ -489,7 +589,8 @@ // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする _saveThreadReady.Reset(); - var thread = new Thread(() => SaveThread(numImages)); + var drainIdleTimeout = GetDrainIdleTimeout(_shotIntervalMs); + var thread = new Thread(() => SaveThread(numImages, drainIdleTimeout)); _saveThread = thread; // 参照を保持(次回の完了待機用) thread.Start(); // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない) @@ -666,6 +767,7 @@ /// protected void CalcTcc(Mat img) { + bool imagesSaved; // 変換行列の計算(tccRgb・imgRois はここで所有し,使用後に確実に解放する) using (var tccRgb = GetTccRgb(img)) using (var imgRois = GetTccRoisImage(img)) { @@ -673,17 +775,29 @@ // データ保存 SetSaveFolder("校正"); SetInfo("校正データ", _saveFolder); - IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccImage")), img); - IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois); + // 校正画像は記録用であり,保存に失敗しても変換行列(メモリ上)は有効なため校正は続ける. + // Cv2.ImWrite は失敗を false で返すだけなので(失敗のログは IoUtil.SaveImage が出す), + // 戻り値を見て完了表示で警告する. + var tccSaved = IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccImage")), img); + var roisSaved = IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois); + imagesSaved = tccSaved && roisSaved; // 変換行列の計算・保存は ColorCorrector に委譲する(tccRgb の所有権は渡さない) _colorCorrector.CalcTcc(tccRgb, _saveFolder, IoUtil.SaveMatToCsv); } IoUtil.PlaySound(Config.GetString("Sound/CalibDone")); - _form.ShowMessage("自動校正完了"); + _form.ShowMessage(FormatCalibrationDone(imagesSaved)); _form.EnableShots(); _calibrated = true; + _lastCalibrationError = null; // 次に失敗したときは改めて通知する + _lastCalibrationFailureReason = null; // チャート検出の表示を従来どおりに戻す + } + + /// 自動校正完了の表示文(校正画像の保存に失敗した場合は警告を添える) + /// 校正画像(TccImage・TccRoisImage)を両方とも保存できたか + internal static string FormatCalibrationDone(bool imagesSaved) { + return imagesSaved ? "自動校正完了" : "自動校正完了(校正画像の保存に失敗)"; } /// diff --git a/TIASshot/ChartDetection/ChartDetector.cs b/TIASshot/ChartDetection/ChartDetector.cs index eb4f2c1..3a80c03 100644 --- a/TIASshot/ChartDetection/ChartDetector.cs +++ b/TIASshot/ChartDetection/ChartDetector.cs @@ -25,6 +25,9 @@ // 状態表示(呼び出し側が提供) readonly Action _showMessage; + /// チャートが上下逆のときの表示(設置し直しの案内.CameraBase が校正失敗後も差し替えずに出す) + internal const string UpsideDownMessage = "舌診チャートが上下逆方向です"; + public ChartDetector(Action showMessage) { _showMessage = showMessage; // ArucoDetector は辞書を内部にコピーして保持するため,元の Dictionary はここで解放してよい @@ -65,7 +68,7 @@ } if (ptsPict.Count < 8) return null; if (y40 > y41) { - _showMessage("舌診チャートが上下逆方向です"); + _showMessage(UpsideDownMessage); return null; } diff --git a/TIASshot/LightSource.cs b/TIASshot/LightSource.cs index 910c910..f72725d 100644 --- a/TIASshot/LightSource.cs +++ b/TIASshot/LightSource.cs @@ -60,6 +60,25 @@ _serial.Write(str); } + /// + /// 光源を消してポートを閉じる(アプリ終了時に呼ぶ).開いていなければ何もしない. + /// 終了処理を止めないよう,消灯・クローズの失敗は捕捉してログのみ残す + /// (消灯に失敗してもポートは閉じる). + /// + public void Close() { + if (!_serial.IsOpen) return; + try { + TurnOff(); + } catch (Exception ex) { + Debug.WriteLine($"光源の消灯に失敗しました: {ex}"); + } + try { + _serial.Close(); + } catch (Exception ex) { + Debug.WriteLine($"光源のポートを閉じられませんでした: {ex}"); + } + } + public void SetLight() { byte[] buf = new byte[] { 0x02, 0x44, 0x41, 0x58, 0x32, 0x3A, 0x44, 0x45, 0x46, 0x20, 0x33, 0x30, 0x37, 0x32, 0x03, 0x30, 0x34, 0x04 }; diff --git a/TIASshot/SelfTest/SelfTestCamera.cs b/TIASshot/SelfTest/SelfTestCamera.cs index 062b302..3d311e0 100644 --- a/TIASshot/SelfTest/SelfTestCamera.cs +++ b/TIASshot/SelfTest/SelfTestCamera.cs @@ -31,6 +31,8 @@ private double _gainR = 1.0; private Thread _previewThread; + // 切断時にプレビュースレッドの終了を待つ上限 + private static readonly TimeSpan DisconnectJoinTimeout = TimeSpan.FromSeconds(3); private volatile bool _stopPreview; private int _frameIndex; private int _framesDelivered; @@ -79,12 +81,20 @@ } /// - /// 切断.プレビュースレッドに停止を要求し,二重バッファの Bitmap を解放する. - /// 【前提】プレビュースレッドは UI スレッドへの Invoke で待つことがあるため, - /// 呼び出し元は UI スレッドのメッセージを回しながら IsPreviewRunning が false になるのを待ってから呼ぶこと. + /// 切断.プレビュースレッドに停止を要求して終了を待ち(実カメラの SDK がストリーム停止で + /// コールバックの終了を待つのと同じ),二重バッファの Bitmap とチャートマスクを解放する. + /// プレビュースレッドから UI スレッドへの呼び出し(ShowMessage / EnableShots)は待たない投稿のため, + /// UI スレッドから呼んでも相互待機にならない.アプリを閉じる処理(Form1_FormClosing)からも呼ばれる. + /// 待機には上限を設け,超えた場合はログを残して解放へ進む. /// public override void Disconnect() { _stopPreview = true; + var thread = _previewThread; + if (thread != null && thread != Thread.CurrentThread && thread.IsAlive) { + if (!thread.Join(DisconnectJoinTimeout)) { + Debug.WriteLine("警告: SelfTestCamera の切断でプレビュースレッドの終了待ちがタイムアウトしました"); + } + } for (var i = 0; i < _bmps.Length; i++) { _bmps[i]?.Dispose(); _bmps[i] = null; diff --git a/TIASshot/UI/Form1.SelfTest.cs b/TIASshot/UI/Form1.SelfTest.cs index c4803a2..b551b23 100644 --- a/TIASshot/UI/Form1.SelfTest.cs +++ b/TIASshot/UI/Form1.SelfTest.cs @@ -122,6 +122,50 @@ txtMultiShotsInterval.Text = intervalMs; } + // 閉じるときの後始末(ShutdownOnClose)が通過した段階の記録(古い順._selfTestLogLock で保護) + private readonly List _selfTestCloseSteps = new List(); + + /// + /// 閉じるときの後始末の対象にするカメラを設定する(Form1_Load の代わり.ヘッドレス Form1 は _camera を持たないため). + /// 閉じてよいかの判定(保存中か)と,後始末でのカメラ切断に使われる. + /// + internal void SelfTestAttachCamera(CameraBase camera) { + _camera = camera; + } + + /// + /// ユーザーが閉じようとしたときの処理(Form1_FormClosing)を呼ぶ(UI スレッドで呼ぶこと). + /// ヘッドレス Form1 では FormClosing イベントが接続されていない(InitializeComponent を呼ばない)ため,この口から呼ぶ. + /// 閉じると決まった場合は後始末(閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源)まで行う. + /// フォームの破棄(Dispose)は行わない(ハーネスの後始末に任せる). + /// + /// 閉じると決まったら true,取り消されたら(保存中)false + internal bool SelfTestRequestClose() { + var e = new FormClosingEventArgs(CloseReason.UserClosing, false); + Form1_FormClosing(this, e); + return !e.Cancel; + } + + /// 閉じ始めたか(以降 ShowMessage / EnableShots は何もしない) + internal bool SelfTestIsClosing => _isClosing; + + /// 本モニタの再描画タイマーが動作中か(閉じるときの後始末で停止される) + internal bool SelfTestRefreshTimerRunning => _mainRefreshTimer.Enabled; + + /// + /// 閉じるときの後始末が通過した段階(古い順のコピー."closing" → "timer" → "camera" → "light"). + /// カメラ切断が例外になった場合は "camera" が抜ける. + /// + internal IReadOnlyList SelfTestCloseSteps { + get { lock (_selfTestLogLock) return _selfTestCloseSteps.ToArray(); } + } + + partial void OnCloseStep(string step) { + lock (_selfTestLogLock) { + _selfTestCloseSteps.Add(step); + } + } + /// ShowError のヘッドレス版(モーダルダイアログを出すと無人実行が止まるため記録のみ行う) private void SelfTestRecordError(string msg) { Debug.WriteLine($"[SELFTEST] ShowError: {msg}"); diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index ba043a2..3667e62 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -35,6 +35,15 @@ private const int MainRefreshIntervalMs = 33; // ≒30fps(PreviewMonitor と同じ値) private readonly System.Windows.Forms.Timer _mainRefreshTimer = new System.Windows.Forms.Timer(); + // アプリを閉じ始めたか(Form1_FormClosing で閉じると決めた時点で立てる). + // 立った後は,カメラのコールバック・保存スレッド由来の UI 呼び出し(ShowMessage / EnableShots)を何もしない. + // 切断(SDK のストリーム停止)がコールバックの終了を待つ間に,コールバック側が UI スレッドを待つと + // 相互待機でハングするため.別スレッドから読むため volatile とする. + private volatile bool _isClosing = false; + + /// 保存中に閉じようとしたときの表示 + internal const string SavingOnCloseMessage = "保存中です.保存完了後に閉じてください"; + /// /// コンストラクタ @@ -121,12 +130,51 @@ /// /// private void Form1_FormClosing(object sender, FormClosingEventArgs e) { - _mainRefreshTimer.Stop(); - if (_camera == null) return; - _camera.Disconnect(); + if (!CanClose(_camera != null && _camera.IsSaving)) { + // 保存中に閉じると未保存の画像が失われるため,閉じるのを取り消して知らせる + e.Cancel = true; + ShowMessage(SavingOnCloseMessage); + return; + } + ShutdownOnClose(); } /// + /// アプリを閉じてよいか(保存中は閉じない). + /// + /// 画像保存スレッドが動作中か(CameraBase.IsSaving) + /// 閉じてよければ true + internal static bool CanClose(bool isSaving) { + return !isSaving; + } + + /// + /// 閉じるときの後始末(Form1_FormClosing で閉じると決めた後に呼ぶ). + /// 【重要】順序は「閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源を消してポートを閉じる」を守ること. + /// フラグを先に立てることで,切断がコールバックの終了を待つ間にコールバック側が UI 呼び出しで + /// UI スレッドを待つことがなくなる(ハングの防止).カメラの切断が例外になっても光源は閉じる. + /// + private void ShutdownOnClose() { + _isClosing = true; + OnCloseStep("closing"); + _mainRefreshTimer.Stop(); + OnCloseStep("timer"); + try { + _camera?.Disconnect(); + OnCloseStep("camera"); + } finally { + _light.Close(); + OnCloseStep("light"); + } + } + + /// + /// 閉じるときの後始末の各段階を通過したことの記録(SELFTEST でのみ実装.Release では呼び出しごと消える). + /// + /// 段階名(closing / timer / camera / light) + partial void OnCloseStep(string step); + + /// /// 画像撮影1枚 /// /// @@ -271,8 +319,12 @@ /// /// public void ShowMessage(string msg) { + // UI スレッド外(カメラのコールバック・保存スレッド)からの呼び出しは UI スレッドの処理を待たずに投稿する + // (BeginInvoke.撮影中で UI スレッドが塞がっていても保存が進み,画像がキューに溜まり続けないため). + // 閉じ始めた後・ハンドルが無い/破棄済みのときは何もしない. + if (!CanUpdateUi()) return; if (InvokeRequired) { - Invoke((MethodInvoker)delegate { ShowMessage(msg); }); + PostToUi(delegate { ShowMessage(msg); }); return; } #if SELFTEST @@ -343,8 +395,11 @@ /// /// public void EnableShots(bool enable = true) { + // UI スレッド外からの呼び出しは ShowMessage と同じく待たずに投稿する(同じスレッドから投稿した + // ShowMessage・ShowError との順序は保たれる).閉じ始めた後・ハンドルが無い/破棄済みのときは何もしない. + if (!CanUpdateUi()) return; if (InvokeRequired) { - Invoke((MethodInvoker)delegate { EnableShots(enable); }); + PostToUi(delegate { EnableShots(enable); }); return; } btnShotOne.Enabled = enable; @@ -352,6 +407,29 @@ } /// + /// 通知系の UI 呼び出し(ShowMessage / EnableShots)を行ってよいか. + /// 閉じ始めた後(_isClosing)・破棄中/破棄済み・ハンドル未生成のときは false. + /// + private bool CanUpdateUi() { + return !_isClosing && !IsDisposed && !Disposing && IsHandleCreated; + } + + /// + /// UI スレッドへ処理を投稿する(完了を待たない). + /// 判定(CanUpdateUi)の直後にハンドルが破棄されると BeginInvoke が例外を投げるため, + /// 捕捉してログのみ残す(コールバック・保存スレッドへ例外を伝えない). + /// + /// UI スレッドで実行する処理 + private void PostToUi(MethodInvoker action) { + try { + BeginInvoke(action); + } catch (InvalidOperationException ex) { + // ObjectDisposedException(InvalidOperationException の派生)を含む + Debug.WriteLine($"UI スレッドへの投稿に失敗しました(フォームの終了中): {ex.Message}"); + } + } + + /// /// データフォルダを開く /// /// diff --git "a/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" "b/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" index ce7226a..c97b203 100644 --- "a/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" +++ "b/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" @@ -101,6 +101,26 @@ - 例: ROI とフレームサイズの照合,ホワイトバランスのゲイン算出,フレームバッファのサイズ検査,光源ポートを開けなかったときの扱い,例外を通知に変える処理 - SDK を呼ぶ部分は「受け取って渡すだけ」の薄いアダプタに残す - 完了条件: 切り出した関数に層①のテストがあり,切り出し直後に実機確認を 1 回行う +- 進め方: 量が多いため 2 回に分ける.**3a** で束 B の修正(下記の方針)を行い,修正で触る箇所の判断ロジックはその場で切り出す.**3b** で残りの切り出し(ROI の照合・フレームバッファのサイズ検査・光源ポートを開けなかったときの扱い等)を行う + +#### 束 B の方針 (Decisions for Shot/Save Reliability) + +2026-10-06 にユーザーと合意した. + +| 論点 | 方針 | +| --- | --- | +| 校正中の例外(`CalcTcc`・`ProcessCalibrationFrame`) | カメラのコールバックへ例外を伝えない.捕捉してログを残し,「校正に失敗しました」と通知し,チャート検出からやり直せる状態(未校正・マスク解放)に戻す.通知がチャート検出の進捗表示ですぐ上書きされないよう,次に校正が完了するまで検出中の表示を「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替えて出し続ける(エラーダイアログはカメラのコールバックを止めるため使わない) | +| 校正画像(TccImage・TccRoisImage)の保存失敗 | 変換行列はメモリ上で有効なので校正済みのままにし,「自動校正完了(校正画像の保存に失敗)」のように警告を表示する | +| アプリを閉じるとき(`Form1_FormClosing`) | 保存中なら閉じるのを取り消して「保存中」と知らせる(閉じる理由は区別しない.Windows のシャットダウン時も取り消し,待つか強制終了するかはユーザーに選ばせる).閉じ始めたらカメラのコールバックから UI への呼び出しを行わない(ハングの防止).光源を消してポートを閉じる | +| 保存スレッドから UI への通知 | 進捗表示などは UI スレッドを待たない非同期の呼び出しにする(撮影中でも保存が進み,画像がキューに溜まり続けない) | +| 保存キューの待ち時間(`DrainShots`) | 固定 30 秒をやめ,「30 秒」と「撮影間隔の 2 倍」の大きい方にする | + +- **3a の状態: 完了(2026-10-06)** + - 上表の方針をすべて実装した.判断(閉じてよいか,待ち時間,通知文の組み立て)は `Form1.CanClose`・`CameraBase.GetDrainIdleTimeout`・`FormatCalibrationFailure`・`ShouldNotifyCalibrationFailure`・`FormatChartDetectionMessage`・`FormatCalibrationDone` として切り出し,層①でテストした + - 層②の新規・書き換えテストは修正前のコードで 14 件が赤になることを確認した.層②は計 36 件で 1 回あたり約 21〜26 秒(目安 30 秒に近づいてきたので,次に層②を増やすときはテスト用設定の見直しを先に検討する) + - 保存中の切断テストは,通知の非同期化で「保存スレッドが UI を待つ間は必ず生きている」前提が崩れたため,保存スレッドを意図的に止めておく形に書き直した + - 実機でしか確かめられない残り: 保存中・プレビュー中・校正中に閉じたときの挙動(SDK のストリーム停止との待ち合い),光源の消灯とポートの解放,撮影と保存が並行するようになった後の連写間隔の精度 +- **3b(未着手)**: ROI とフレームサイズの照合,フレームバッファのサイズ検査,光源ポートを開けなかったときの扱い(`/auto-audit` 報告台帳の該当項目) ### フェーズ 4: 実機録画データによる回帰 diff --git "a/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" "b/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" index 9123db6..8062629 100644 --- "a/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" +++ "b/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" @@ -33,7 +33,10 @@ | `CameraBaseCalibrationIntegrationTests`(切断) | 校正後・校正中の切断で例外にならず,チャートマスクが解放される | | `CameraBaseShotIntegrationTests`(撮影ループ) | 連続撮影で枚数分のファイルと撮影ログ(間隔どおりの時刻).枚数 0 で例外にならずボタンが戻る.フレーム取得の失敗で中止・通知され,Info.csv の中止行は理由にカンマ・改行・引用符を含んでも 3 列に収まる | | `CameraBaseShotIntegrationTests`(保存キュー・切断) | 保存失敗時に撮影した全フレームが解放される.前回のキューに残った画像が解放される.保存中の切断で例外にならず保存が完了する | -| `Form1UiIntegrationTests` | データ名・連続撮影の枚数・間隔をワーカースレッドから読んでも UI の入力どおりの値が返る(数値でない入力を含む).ShowMessage・EnableShots が UI に反映される | +| `Form1UiIntegrationTests` | データ名・連続撮影の枚数・間隔をワーカースレッドから読んでも UI の入力どおりの値が返る(数値でない入力を含む).ShowMessage・EnableShots が UI に反映され,ワーカーからの呼び出しは UI スレッドを待たずに戻る.閉じ始めた後・ハンドル未生成では何もしない | +| `CameraBaseCalibrationIntegrationTests`(校正の失敗) | 校正中の例外はカメラのコールバックへ伝わらず,失敗の通知はちょうど 1 回.失敗後は検出中の表示に上書きされず失敗の表示が続き,再校正が完了すると「自動校正完了」になる.校正画像の保存に失敗しても校正済みのまま警告を表示する | +| `CameraBaseShotIntegrationTests`(撮影中の保存) | 連続撮影の途中でも保存が進む(4 枚目の取得時点で 2 枚目が保存済み) | +| `Form1CloseIntegrationTests` | 保存中に閉じると取り消されて「保存中」と表示される.保存後は閉じ始め → タイマー → カメラ → 光源の順に後始末される.プレビュー中に閉じてもすぐ戻り例外にならない | - **合格条件**: 全件緑.新しいケースを追加したときは,取り込む前に 3 回連続で緑になることを確認する diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 08a9baf..26ff555 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -182,3 +182,8 @@ - [x] **PLAN_05 フェーズ 2: 層②テストの拡充と報告台帳 4 件の修正** (2026-10-06): 校正の状態遷移・撮影ループ・保存キューの後始末・切断・`Form1` の UI 連携に,正常系と失敗注入の結合テストを広げた.あわせて,ハーネスで再現テストを書けるようになった報告台帳の 4 件(UI スレッド外の取得系メソッド,チャートマスクの解放漏れ,保存キューの取り残し,撮影失敗行のクォート漏れ)を,修正前に赤になることを確かめたうえで直した. - 設計判断: 保存中の切断は,`SaveThread` が完了表示を UI スレッドへ Invoke するまで終われない性質を使い,メッセージを処理する前に切断することでタイミングに依存せず再現した.撮影系は変換次元を 1 つに絞り,層②全体を 30 秒の目安に収めた(全次元だと約 28 秒). - 後段: 方針未決の挙動(`CalcTcc` の例外境界,終了処理,`SaveThread` の同期 Invoke,`DrainShots` の待ち時間)はフェーズ 3(束 B)で方針を決めてから扱う.テスト側に変換次元の取得処理など小さな重複が 3 ファイルに残っている. + +- [x] **PLAN_05 フェーズ 3a: 撮影・保存の信頼性の修正(束 B)** (2026-10-06): 校正中の例外をカメラのコールバックへ伝えずにやり直せるようにし,校正画像の保存失敗は警告にした.保存中は閉じるのを取り消し,閉じるときの後始末(カメラ・光源)の順序を決めた.保存スレッド等から UI への通知を非同期にし,撮影中でも保存が進むようにした.保存キューの待ち時間は撮影間隔に合わせた.方針は PLAN_05「束 B の方針」表. + - 設計判断: 校正失敗の通知がチャート検出の進捗表示ですぐ上書きされる問題は,エラーダイアログ(カメラのコールバックを止めてしまう)ではなく,次に校正が完了するまで検出中の表示を失敗の表示に差し替える方式にした.上下逆の設置案内は,ユーザーが直すための指示なので差し替えない. + - 失敗パターン: 通知を非同期にしたことで,「保存スレッドは UI を待つ間は必ず生きている」ことを前提にしていた保存中の切断テストが速度次第で落ちうる形になった.保存スレッドを意図的に止めておく形に書き直した.非同期化のような変更では,既存テストがタイミングの前提を持っていないかを確認する. + - 後段: 3b(ROI・フレームバッファのサイズ検査・光源ポートの扱い).層②の実行時間が約 21〜26 秒になり目安の 30 秒に近づいている.