#if SELFTEST
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using NUnit.Framework;
using OpenCvSharp;
namespace TIASshot.Tests {
/// <summary>
/// CameraBase の撮影ループ(ShotMulti → RunShotLoop → CaptureShots)・保存キューの後始末(ResetShotQueue・DrainShots)・
/// 保存中の切断の結合ロジックテスト(層②).保存失敗の通知は CameraBaseSaveIntegrationTests(フェーズ 1)で扱う.
///
/// 仕様: SPEC_01「撮影」「撮影失敗時の挙動」「データ保存(撮影保存ファイル)」・testing.md「層②」
/// - 連続撮影は指定した枚数・間隔で撮影し,`Shot{NO}.png`・`sRGB{CN}_{NO}.jpg`・`Info.csv` を保存する
/// - フレーム取得に失敗した場合,その時点で撮影全体を中止し(残り枚数は撮影しない),中止した旨を通知する.
/// 取得済みの画像は保存し,`Info.csv` に中止した旨と理由を記録する.失敗した 1 枚は成功として記録されない
/// - `Info.csv` の撮影ログ部の列は `Shot,Time(ms),Note`.3 列目は中止した行にのみ理由が入る
/// (CSV として 3 列の構造が保たれること.理由にカンマ・改行・引用符が含まれても列・行が崩れない=RFC 4180)
/// - 撮影ボタンは保存が完了するまで無効のままとなり,保存処理の終了後に再び操作可能になる
/// - 撮影・保存で使った Mat(ネイティブ資源)は残さず解放する(CLAUDE.md・error-handling.md)
///
/// 方針未決のためアサートしない: 枚数 0 の完了表示の有無.
/// PLAN_05「束 B の方針」: 保存スレッドから UI への通知は UI スレッドを待たない(撮影中でも保存が進む).
/// DrainShots の待機上限(30 秒と撮影間隔の 2 倍の大きい方)は 30 秒を超えるため層①(GetDrainIdleTimeout)で確かめる.
///
/// 失敗の注入: CaptureFrame を上書きした派生偽カメラ(<see cref="ProbeCamera"/>)で,指定枚目に
/// CaptureFailedException を投げる/撮影開始時に保存先へ同名フォルダを作り画像保存を失敗させる.
/// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
/// </summary>
[TestFixture]
public class CameraBaseShotIntegrationTests {
// 撮影ログのヘッダ(SPEC_01: Shot,Time(ms),Note)
private static readonly string[] ShotLogHeader = { "Shot", "Time(ms)", "Note" };
// 注入する撮影失敗の理由(カンマ・CRLF・ダブルクォートを含む.CSV の列・行を崩しうる文字)
private const string TrickyReason = "a,b\r\nc\"d";
// ================================================================================
// 撮影ループ(正常系)
// ================================================================================
/// <summary>
/// 連続撮影(3 枚・間隔 0)で,3 枚ぶんの撮影画像・sRGB 変換画像と Info.csv が保存され,
/// 撮影ログに 1〜3 枚目の行があり,撮影ボタンが再び有効になる.
/// </summary>
[Test, Category("Integration")]
public void ShotMulti_3枚間隔0_3枚ぶんのファイルと撮影ログが保存され撮影ボタンが再有効化される() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
h.Calibrate(camera);
h.Form.SelfTestSetMultiShots("3", "0");
return ShootMultiAndObserve(h, camera);
}, ShotOptions());
// Assert
var expected = new List<string> { obs.InfoName };
for (var no = 1; no <= 3; no++) expected.AddRange(obs.ExpectedFilesFor(no));
using (Assert.EnterMultipleScope()) {
Assert.That(obs.SavedFiles, Is.SupersetOf(expected), $"保存されたファイル: [{string.Join(", ", obs.SavedFiles)}]");
Assert.That(obs.ShotLogRows.Select(r => r[0]), Is.EqualTo(new[] { "1", "2", "3" }), "撮影ログに 1〜3 枚目の行があること");
Assert.That(obs.Errors, Is.Empty, "エラー通知が無いこと");
Assert.That(obs.ShotsEnabled, Is.True, "保存完了後に撮影ボタンが再び有効になること");
}
}
/// <summary>
/// 連続撮影(2 枚・間隔 150ms)で,撮影ログの 2 枚目の時刻が 1 枚目から間隔以上あとになる
/// (SPEC_01: 指定した間隔で連続撮影する).
/// </summary>
[Test, Category("Integration")]
public void ShotMulti_2枚間隔150ms_撮影ログの時刻が間隔以上離れる() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
h.Calibrate(camera);
h.Form.SelfTestSetMultiShots("2", "150");
return ShootMultiAndObserve(h, camera);
}, ShotOptions());
// Assert
Assert.That(obs.ShotLogRows.Count, Is.EqualTo(2), "前提: 撮影ログが 2 行あること");
var t1 = long.Parse(obs.ShotLogRows[0][1]);
var t2 = long.Parse(obs.ShotLogRows[1][1]);
Assert.That(t2 - t1, Is.GreaterThanOrEqualTo(150), $"2 枚目は 1 枚目から 150ms 以上あとに撮影されること(1 枚目 {t1}ms,2 枚目 {t2}ms)");
}
/// <summary>
/// 連続撮影の枚数が 0 のとき,例外にならず,撮影ボタンが再び有効になる
/// (枚数 0 の完了表示の有無は仕様未決のためアサートしない).
/// </summary>
[Test, Category("Integration")]
public void ShotMulti_枚数0_例外にならず撮影ボタンが再有効化される() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
h.Calibrate(camera);
h.Form.SelfTestSetMultiShots("0", "0");
return ShootMultiAndObserve(h, camera);
}, ShotOptions());
// Assert
using (Assert.EnterMultipleScope()) {
Assert.That(obs.ShotError, Is.Null, "枚数 0 の撮影が例外にならないこと");
Assert.That(obs.ShotsEnabled, Is.True, "枚数 0 の撮影の後も撮影ボタンが再び有効になること");
}
}
// ================================================================================
// 撮影ループ(失敗注入: フレーム取得の失敗)
// ================================================================================
/// <summary>
/// 連続撮影(3 枚)の 2 枚目でフレーム取得に失敗したとき,撮影を中止して 3 枚目は撮影せず,
/// 取得済みの 1 枚目だけが保存され,中止が理由つきで通知され,撮影ボタンが再び有効になる.
/// </summary>
[Test, Category("Integration")]
public void ShotMulti_2枚目のフレーム取得失敗_中止して取得済みのみ保存し理由を通知し撮影ボタンを再有効化する() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { FailCaptureAt = 2, FailMessage = "注入: フレーム取得失敗" }));
h.Calibrate(camera);
h.Form.SelfTestSetMultiShots("3", "0");
return ShootMultiAndObserve(h, camera);
}, ShotOptions());
// Assert
using (Assert.EnterMultipleScope()) {
Assert.That(obs.ShotError, Is.Null, "フレーム取得の失敗は撮影の例外として呼び出し元へ漏れないこと(中止として扱う)");
Assert.That(obs.CaptureCalls, Is.EqualTo(2), "失敗した 2 枚目で中止し,3 枚目は撮影しないこと");
Assert.That(obs.SavedFiles, Is.SupersetOf(obs.ExpectedFilesFor(1)), "取得済みの 1 枚目は保存されること");
Assert.That(obs.SavedFiles, Has.No.Member(obs.RgbFileFor(2)), "失敗した 2 枚目は保存されないこと");
Assert.That(obs.SavedFiles, Has.No.Member(obs.RgbFileFor(3)), "中止後の 3 枚目は保存されないこと");
Assert.That(obs.Errors, Has.Some.Matches<string>(e => e != null && e.Contains("注入: フレーム取得失敗")),
$"中止した旨が理由つきで通知されること.通知: [{string.Join(" / ", obs.Errors)}]");
Assert.That(obs.ShotsEnabled, Is.True, "中止後も撮影ボタンが再び有効になること");
}
}
/// <summary>
/// フレーム取得失敗の理由にカンマ・改行・ダブルクォートが含まれても,Info.csv の撮影ログは CSV(RFC 4180)として
/// Shot,Time(ms),Note の 3 列構造を保ち,中止行の Note は理由を含む 1 つのフィールドになる.失敗した枚は成功行として記録されない.
/// </summary>
[Test, Category("Integration")]
public void ShotMulti_理由にカンマ改行引用符を含むフレーム取得失敗_InfoCsvの中止行がNoteを含む3列になる() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { FailCaptureAt = 2, FailMessage = TrickyReason }));
h.Calibrate(camera);
h.Form.SelfTestSetMultiShots("3", "0");
return ShootMultiAndObserve(h, camera);
}, ShotOptions());
// Assert
Assert.That(obs.InfoText, Is.Not.Null, "前提: Info.csv が保存されていること");
Assert.That(obs.ShotLogHeaderFound, Is.True, "前提: 撮影ログのヘッダ(Shot,Time(ms),Note)が 1 つの行として解釈できること");
var rows = obs.ShotLogRows;
Assert.That(rows.Count, Is.EqualTo(2),
$"撮影ログは 1 枚目(成功)と 2 枚目(中止)の 2 行であること.解釈結果: [{string.Join(" | ", rows.Select(r => string.Join(" ¦ ", r)))}]");
using (Assert.EnterMultipleScope()) {
Assert.That(rows[0].Count, Is.EqualTo(2), "成功した 1 枚目の行は Shot,Time の 2 フィールドであること");
Assert.That(rows[0][0], Is.EqualTo("1"));
Assert.That(rows[0][1], Does.Match(@"^\d+$"), "1 枚目の Time は整数(ms)であること");
Assert.That(obs.RawShotLines, Has.Some.Matches<string>(l => Regex.IsMatch(l, @"^1,\d+$")), "カンマを含まない 1 枚目の行は従来どおり 1,<time> の形であること");
Assert.That(rows[1].Count, Is.EqualTo(3), "中止した 2 枚目の行は Shot,Time,Note の 3 フィールドであること");
Assert.That(rows[1][0], Is.EqualTo("2"));
Assert.That(rows[1][1], Does.Match(@"^\d+$"), "2 枚目の Time は整数(ms)であること");
Assert.That(rows[1].Count > 2 ? rows[1][2] : "", Does.Contain(TrickyReason), "Note に元の理由がそのまま 1 フィールドとして入ること");
}
}
// ================================================================================
// 保存キューの後始末(SaveThread 例外時・ResetShotQueue)
// ================================================================================
/// <summary>
/// 保存キューを作り直す(ResetShotQueue)とき,前のキューに残っていた Mat は解放される
/// (BlockingCollection の破棄だけでは中身の Mat はリークする).
/// </summary>
[Test, Category("Integration")]
public void ResetShotQueue_前のキューにMatが残っている_残っていたMatが全て解放される() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
var left1 = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(1));
var left2 = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(2));
try {
camera.AddToShotQueue(left1);
camera.AddToShotQueue(left2);
camera.ResetQueue();
return new { d1 = left1.IsDisposed, d2 = left2.IsDisposed };
} finally {
// 解放されていなかった場合(不具合時)の後始末
left1.Dispose();
left2.Dispose();
}
});
// Assert
using (Assert.EnterMultipleScope()) {
Assert.That(obs.d1, Is.True, "残っていた 1 つ目の Mat が解放されること");
Assert.That(obs.d2, Is.True, "残っていた 2 つ目の Mat が解放されること");
}
}
/// <summary>
/// 連続撮影(3 枚)で 1 枚目の画像保存に失敗したとき(保存スレッドが例外で保存ループを抜ける),
/// 撮影した全てのフレーム(保存されなかった 2・3 枚目を含む)が解放され,撮影ボタンが再び有効になる.
/// </summary>
[Test, Category("Integration")]
public void ShotMulti_1枚目の保存失敗_撮影した全フレームが解放され撮影ボタンが再有効化される() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { BlockSaveNo = 1 }));
h.Calibrate(camera);
h.Form.SelfTestSetMultiShots("3", "0");
var o = ShootMultiAndObserve(h, camera);
o.CapturedDisposed = camera.Captured.Count(m => m.IsDisposed);
return o;
}, ShotOptions());
// Assert
Assert.That(obs.Errors, Is.Not.Empty, "前提: 保存失敗が通知されたこと(失敗注入が効いたこと)");
Assert.That(obs.CaptureCalls, Is.EqualTo(3), "前提: 3 枚とも撮影されたこと");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.CapturedDisposed, Is.EqualTo(obs.CaptureCalls), "撮影した全フレームが解放されること(保存されなかったフレームを含む)");
Assert.That(obs.ShotsEnabled, Is.True, "保存失敗後も撮影ボタンが再び有効になること");
}
}
// ================================================================================
// 切断(保存中)
// ================================================================================
/// <summary>
/// 連続撮影の直後(保存スレッドの動作中)に切断しても例外にならず,保存は最後まで行われ,
/// 撮影ボタンが再び有効になり,チャートマスクが解放され,プレビューが停止している.
/// 保存スレッドから UI への通知は待たない投稿(BeginInvoke)のため,撮影から戻った時点で保存スレッドが
/// 生きているかは処理速度次第になる.そこで 1 枚目の保存後の解放(Mat.Dispose)で保存スレッドを止めておき
/// (<see cref="ProbeCamera.HoldSaveAfterNo"/>),保存スレッドが確実に保存中の状態で切断してから止めを外す.
/// </summary>
[Test, Category("Integration")]
public void 切断_保存中_例外にならず保存が完了し撮影ボタンが再有効化されマスクが解放される() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
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);
var savingAtDisconnect = false;
string disconnectError = null;
try {
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);
var o = Observe(h, camera, null);
o.SavingAtDisconnect = savingAtDisconnect;
o.DisconnectError = disconnectError;
o.MaskCount = masks.Count;
o.MasksDisposed = masks.Count(m => m.IsDisposed);
o.PreviewRunning = camera.IsPreviewRunning;
return o;
}, ShotOptions());
// Assert
Assert.That(obs.SavingAtDisconnect, Is.True, "前提: 保存スレッドの動作中(1 枚目の保存後で止めた状態)に切断したこと");
var expected = new List<string>();
for (var no = 1; no <= 3; no++) expected.AddRange(obs.ExpectedFilesFor(no));
using (Assert.EnterMultipleScope()) {
Assert.That(obs.DisconnectError, Is.Null, "保存中の切断が例外にならないこと");
Assert.That(obs.Errors, Is.Empty, $"保存中に切断しても保存は失敗しないこと.通知: [{string.Join(" / ", obs.Errors)}]");
Assert.That(obs.SavedFiles, Is.SupersetOf(expected), "撮影済みの 3 枚は保存されること");
Assert.That(obs.ShotsEnabled, Is.True, "保存完了後に撮影ボタンが再び有効になること");
Assert.That(obs.MasksDisposed, Is.EqualTo(obs.MaskCount), "切断でチャートマスクが全て解放されること");
Assert.That(obs.PreviewRunning, Is.False, "切断後にプレビューが動いていないこと");
}
}
// ================================================================================
// 撮影中の保存の進行(PLAN_05「束 B の方針」: 保存スレッドから UI への通知は UI スレッドを待たない)
// ================================================================================
/// <summary>
/// 連続撮影(4 枚・間隔 0)の撮影中(UI スレッドが撮影ループでメッセージを処理していない間)にも保存が進み,
/// 4 枚目の取得時点で 2 枚目の撮影画像(Shot0002.png)が保存される(画像がキューに溜まり続けない).
/// 保存スレッドの進捗表示が UI スレッドを待つ実装では,1 枚目の保存後の進捗表示で止まり 2 枚目が保存されない.
/// </summary>
[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);
/// <summary>
/// 撮影系テストの実行条件.保存時間の大半は変換次元ごとの sRGB 変換のため,変換次元を 1 つ(4)に絞って
/// 層②全体の実行時間の目安(30 秒)に収める.全次元での保存はフェーズ 1 の CameraBaseSaveIntegrationTests が確認する.
/// </summary>
private static IntegrationOptions ShotOptions() {
return new IntegrationOptions().WithConfig("Calib/ConversionChannels", "4");
}
/// <summary>
/// 撮影ボタン押下(Form1.ExecuteShot)と同じく撮影ボタンを無効化してから連続撮影し,保存スレッドの終了を待って観測値を集める.
/// </summary>
private static ShotObservation ShootMultiAndObserve(IntegrationHarness h, ProbeCamera camera) {
h.Form.SelfTestClearLog();
h.Form.EnableShots(false);
string shotError = null;
try {
camera.ShotMulti();
} catch (Exception ex) {
shotError = ex.ToString();
}
h.WaitSaveFinished(camera);
return Observe(h, camera, shotError);
}
private static ShotObservation Observe(IntegrationHarness h, ProbeCamera camera, string shotError) {
var folder = camera.CurrentSaveFolder;
var obs = new ShotObservation {
ShotError = shotError,
Errors = h.Form.SelfTestErrors.ToList(),
Messages = h.Form.SelfTestMessages.ToList(),
ShotsEnabled = h.Form.SelfTestShotsEnabled,
CaptureCalls = camera.CaptureCalls,
InfoName = Config.GetString("File/Info"),
RgbPattern = Config.GetString("File/RgbImage"),
SrgbPattern = Config.GetString("File/SrgbImage"),
Channels = GetConversionChannels(),
};
if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder)) {
obs.SavedFiles = Directory.GetFiles(folder).Select(Path.GetFileName).ToList();
var info = Path.Combine(folder, obs.InfoName);
if (File.Exists(info)) {
obs.InfoText = File.ReadAllText(info);
ParseShotLog(obs);
}
}
return obs;
}
/// <summary>Info.csv を RFC 4180 として解釈し,撮影ログのヘッダ以降のレコードと生の行を取り出す</summary>
private static void ParseShotLog(ShotObservation obs) {
var records = ParseCsv(obs.InfoText);
var headerIndex = records.FindIndex(r => r.SequenceEqual(ShotLogHeader));
obs.ShotLogHeaderFound = headerIndex >= 0;
if (headerIndex >= 0) {
obs.ShotLogRows = records.Skip(headerIndex + 1).Where(r => !(r.Count == 1 && r[0] == "")).ToList();
}
var lines = obs.InfoText.Split(new[] { "\r\n" }, StringSplitOptions.None).ToList();
var headerLine = lines.IndexOf(string.Join(",", ShotLogHeader));
if (headerLine >= 0) obs.RawShotLines = lines.Skip(headerLine + 1).ToList();
}
/// <summary>
/// RFC 4180 の CSV を解釈する(テスト用の最小実装).
/// フィールド先頭の " から次の単独の " までを 1 フィールドとし("" は " 1 文字),その中のカンマ・改行は区切りにしない.
/// 引用符で始まらないフィールド内の " は文字として扱う.レコード区切りは CRLF または LF.
/// </summary>
internal static List<List<string>> ParseCsv(string text) {
var records = new List<List<string>>();
var record = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
var fieldQuoted = false;
var i = 0;
Action endField = () => {
record.Add(field.ToString());
field.Clear();
fieldQuoted = false;
};
Action endRecord = () => {
endField();
records.Add(record);
record = new List<string>();
};
while (i < text.Length) {
var c = text[i];
if (inQuotes) {
if (c == '"') {
if (i + 1 < text.Length && text[i + 1] == '"') {
field.Append('"');
i += 2;
continue;
}
inQuotes = false;
} else {
field.Append(c);
}
i++;
continue;
}
if (c == '"' && field.Length == 0 && !fieldQuoted) {
inQuotes = true;
fieldQuoted = true;
} else if (c == ',') {
endField();
} else if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') {
endRecord();
i++;
} else if (c == '\n') {
endRecord();
} else {
field.Append(c);
}
i++;
}
if (field.Length > 0 || record.Count > 0 || fieldQuoted) endRecord();
return records;
}
/// <summary>設定の変換次元(Calib/ConversionChannels.SPEC_02: カンマ区切り・4〜17 の範囲のみ有効)</summary>
private static List<int> GetConversionChannels() {
return Config.GetString("Calib/ConversionChannels")
.Split(',')
.Select(x => int.Parse(x.Trim()))
.Where(x => x >= 4 && x <= 17)
.Distinct()
.OrderBy(x => x)
.ToList();
}
/// <summary>STA スレッドで集めた観測値(テストスレッドで検証する)</summary>
private sealed class ShotObservation {
public string ShotError;
public List<string> Errors = new List<string>();
public List<string> Messages = new List<string>();
public bool ShotsEnabled;
public int CaptureCalls;
public int CapturedDisposed;
public string InfoName;
public string RgbPattern;
public string SrgbPattern;
public List<int> Channels = new List<int>();
public List<string> SavedFiles = new List<string>();
public string InfoText;
public bool ShotLogHeaderFound;
public List<List<string>> ShotLogRows = new List<List<string>>();
public List<string> RawShotLines = new List<string>();
public bool SavingAtDisconnect;
public string DisconnectError;
public int MaskCount;
public int MasksDisposed;
public bool PreviewRunning;
public bool? SavedDuringShot;
/// <summary>SPEC_01 のファイル名規則({NO}: 連番 4 桁)での撮影画像名</summary>
public string RgbFileFor(int no) => RgbPattern.Replace("{NO}", $"{no:0000}");
/// <summary>連番 no の撮影画像と sRGB 変換画像(全次元)のファイル名({CN}: 変換次元 2 桁)</summary>
public List<string> ExpectedFilesFor(int no) {
var list = new List<string> { RgbFileFor(no) };
list.AddRange(Channels.Select(ch => SrgbPattern.Replace("{CN}", $"{ch:00}").Replace("{NO}", $"{no:0000}")));
return list;
}
}
/// <summary>
/// 撮影の観測と失敗注入のための偽カメラ.
/// - CaptureFrame の呼び出し回数と返した Mat を記録する(解放の確認用.所有権は従来どおり CameraBase 側)
/// - FailCaptureAt 枚目の CaptureFrame で CaptureFailedException(FailMessage) を投げる
/// - BlockSaveNo 番の撮影画像の保存先に同名フォルダを作り,保存を失敗させる(CameraBaseSaveIntegrationTests と同じ手段)
/// - 保存キューへの直接追加と ResetShotQueue の呼び出し口(ResetShotQueue の後始末の確認用)
/// - HoldSaveAfterNo 枚目の保存後(保存スレッドがその Mat を解放するとき)に保存スレッドを止めておく
/// (止まったら SaveHeld を Set し,ReleaseSave が Set されるまで待つ.保存中の切断を決定的に作るため)
/// </summary>
private sealed class ProbeCamera : SelfTestCamera {
public ProbeCamera(Form1 form, Func<int, Mat> frameFactory) : base(form, frameFactory) { }
/// <summary>CaptureFrame で失敗させる枚目(1 始まり.0 なら失敗させない)</summary>
public int FailCaptureAt { get; set; }
/// <summary>失敗させるときの例外メッセージ</summary>
public string FailMessage { get; set; } = "注入: フレーム取得失敗";
/// <summary>保存を失敗させる撮影画像の連番(1 始まり.0 なら失敗させない)</summary>
public int BlockSaveNo { get; set; }
/// <summary>CaptureFrame の呼び出し回数(失敗した回を含む)</summary>
public int CaptureCalls { get; private set; }
/// <summary>CaptureFrame が返した Mat(古い順)</summary>
public List<Mat> Captured { get; } = new List<Mat>();
/// <summary>撮影中に保存の進行を確かめる撮影画像の連番(1 始まり.0 なら確かめない)</summary>
public int WaitSavedNo { get; set; }
/// <summary>保存の進行を確かめる CaptureFrame の回(1 始まり)</summary>
public int WaitSavedAtCapture { get; set; }
/// <summary>保存の進行を待つ上限</summary>
public TimeSpan WaitSavedLimit { get; set; } = TimeSpan.FromSeconds(2);
/// <summary>WaitSavedAtCapture 回目の取得時点で WaitSavedNo 番の撮影画像が保存されたか(確かめていなければ null)</summary>
public bool? SavedDuringShot { get; private set; }
/// <summary>保存後に保存スレッドを止めておく撮影画像の連番(1 始まり.0 なら止めない)</summary>
public int HoldSaveAfterNo { get; set; }
/// <summary>保存スレッドが HoldSaveAfterNo 枚目の保存を終えて止まったときに Set される</summary>
public System.Threading.ManualResetEventSlim SaveHeld { get; } = new System.Threading.ManualResetEventSlim(false);
/// <summary>Set すると止めていた保存スレッドが再開する(テスト側は finally で必ず Set すること)</summary>
public System.Threading.ManualResetEventSlim ReleaseSave { get; } = new System.Threading.ManualResetEventSlim(false);
public void AddToShotQueue(Mat mat) => _shots.Add(mat);
public void ResetQueue() => ResetShotQueue();
protected override void BeginShot() {
base.BeginShot();
if (BlockSaveNo > 0) {
var name = Config.GetString("File/RgbImage").Replace("{NO}", $"{BlockSaveNo:0000}");
Directory.CreateDirectory(Path.Combine(CurrentSaveFolder, name));
}
}
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;
}
}
/// <summary>
/// 解放(Dispose)時に held を Set し,release が Set されるまで(上限つきで)止まる Mat.
/// SaveImages は上書きできないため,保存スレッドが保存後に Mat を解放する箇所で保存スレッドを止める.
/// ファイナライザ経由(disposing=false)では DisposeManaged が呼ばれないため止まらない.
/// </summary>
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