#if SELFTEST
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NUnit.Framework;
using OpenCvSharp;
namespace TIASshot.Tests {
/// <summary>
/// CameraBase の撮影・保存(ShotOne / ShotMulti → RunShotLoop → SaveThread → SaveImages)の結合ロジックテスト(層②).
///
/// 仕様: SPEC_01「撮影」「撮影失敗時の挙動」「データ保存」
/// - 保存される画像: 元画像 `Shot{NO}.png`,sRGB 変換画像 `sRGB{CN}_{NO}.jpg`(指定した次元数ぶん),撮影情報 `Info.csv`
/// - 保存先: `[SaveFolder]/[日付]/[時刻]-[データ名]/`
/// - 画像の保存に失敗した場合は,保存処理の終了後にダイアログで通知する
/// - 撮影ボタンは保存が完了するまで無効のままとなり,保存処理の終了後に再び操作可能になる
///
/// 失敗の注入: 撮影開始時(BeginShot.保存フォルダ作成後・Info.csv 作成前)に,保存先フォルダ内へ
/// 保存予定の画像ファイルと同名のフォルダ(例 `Shot0001.png\`)を作る.Cv2.ImWrite はファイルを開けないと
/// 例外を出さず false を返すため,IoUtil.SaveImage が false を返す状況になる(前提は
/// <see cref="SaveImage_保存先と同名のフォルダがある_例外を出さずfalseを返す"/> で確認する).
/// 例外ではなく false で注入するのは,戻り値の無視(保存失敗が「全ての画像保存完了」と表示される)を検出するため.
///
/// 観測は Form1.SelfTest.cs のアクセサ(SelfTestErrors・SelfTestMessages・SelfTestShotsEnabled)と保存ファイルで行う.
/// 観測値は STA スレッド上で集めてテストスレッドへ返し,アサーションはテストスレッドで行う.
/// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
/// </summary>
[TestFixture]
public class CameraBaseSaveIntegrationTests {
// SaveThread が全枚数の保存に成功したときの表示(SPEC_01: 保存成功時のみ完了と表示する)
private const string AllSavedMessage = "全ての画像保存完了";
// 保存失敗の通知に含まれるべき語(ダイアログ=ShowError で保存失敗と分かること)
private const string SaveFailedPhrase = "保存に失敗";
// ================================================================================
// 前提: 失敗注入の手段そのものの確認(層①)
// ================================================================================
/// <summary>
/// 保存先パスに同名のフォルダがある場合,IoUtil.SaveImage は例外を出さず false を返す.
/// 以降の結合テストの失敗注入(同名フォルダ)が「false 返却」の経路に乗ることの前提確認.
/// これが赤なら注入方法を見直すこと(例外になる場合は修正前のコードでも失敗経路に乗ってしまう).
/// </summary>
[Test]
public void SaveImage_保存先と同名のフォルダがある_例外を出さずfalseを返す() {
// Arrange
var dir = Path.Combine(Path.GetTempPath(), "TIASshotTests_" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(dir, "Shot0001.png");
Directory.CreateDirectory(path);
try {
using (var img = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(128))) {
var result = true;
// Act
Assert.DoesNotThrow((Action)(() => result = IoUtil.SaveImage(path, img)));
// Assert
Assert.That(result, Is.False, "同名フォルダがあり書き込めない場合は false で報告されること");
}
} finally {
if (Directory.Exists(dir)) Directory.Delete(dir, true);
}
}
// ================================================================================
// 正常系
// ================================================================================
/// <summary>
/// 単発撮影で保存に成功したとき,撮影画像・sRGB 変換画像(全次元)・Info.csv が保存先に作られ,
/// 「全ての画像保存完了」が表示され,エラー通知が無く,撮影ボタンが再び有効になる.
/// </summary>
[Test, Category("Integration")]
public void ShotOne_保存成功_全ファイルが保存され完了表示と撮影ボタン再有効化が行われる() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.CreateChartCamera();
h.Calibrate(camera);
return ShootAndObserve(h, camera, multi: false);
});
// Assert
Assert.That(obs.Channels, Is.Not.Empty, "前提: 変換次元が 1 つ以上設定されていること");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.SaveFolderUnderSaveRoot, Is.True, $"保存先が一時保存先の配下であること: {obs.SaveFolder}");
Assert.That(obs.SavedFiles, Is.SupersetOf(obs.ExpectedFilesForNo1), "撮影画像・sRGB 変換画像・Info.csv が保存されること");
Assert.That(obs.InfoHasShotLogHeader, Is.True, "Info.csv に撮影ログのヘッダ(Shot,Time(ms),Note)があること");
Assert.That(obs.Messages, Has.Member(AllSavedMessage), "保存成功時は「全ての画像保存完了」が表示されること");
Assert.That(obs.Errors, Is.Empty, "保存成功時はエラー通知が無いこと");
Assert.That(obs.ShotsEnabled, Is.True, "保存完了後に撮影ボタンが再び有効になること");
}
}
// ================================================================================
// 失敗注入
// ================================================================================
/// <summary>
/// 単発撮影で撮影画像の保存に失敗したとき(IoUtil.SaveImage が false),
/// 保存失敗が通知され,「全ての画像保存完了」は表示されず,撮影ボタンは再び有効になる.
/// </summary>
[Test, Category("Integration")]
public void ShotOne_撮影画像の保存失敗_保存失敗を通知し完了と表示せず撮影ボタンを再有効化する() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new SaveBlockingCamera(h.Form, h.ChartFrameFactory(), 1)));
h.Calibrate(camera);
return ShootAndObserve(h, camera, multi: false);
});
// Assert
AssertSaveFailureReported(obs);
}
/// <summary>
/// 連続撮影(3 枚)で 2 枚目の撮影画像の保存に失敗したとき,
/// 保存失敗が通知され,「全ての画像保存完了」は表示されず,撮影ボタンは再び有効になる.
/// </summary>
[Test, Category("Integration")]
public void ShotMulti_2枚目の保存失敗_保存失敗を通知し完了と表示せず撮影ボタンを再有効化する() {
// Arrange / Act
var obs = IntegrationHarness.Run(h => {
var camera = h.Connect(h.Track(new SaveBlockingCamera(h.Form, h.ChartFrameFactory(), 2)));
h.Calibrate(camera);
h.Form.SelfTestSetMultiShots("3", "0");
return ShootAndObserve(h, camera, multi: true);
});
// Assert
AssertSaveFailureReported(obs);
}
// ================================================================================
// 共通処理
// ================================================================================
private static void AssertSaveFailureReported(ShotObservation obs) {
Assert.That(obs.InjectedPaths, Is.Not.Empty, "前提: 失敗注入(保存先と同名のフォルダ)が行われたこと");
using (Assert.EnterMultipleScope()) {
Assert.That(obs.Errors, Has.Some.Matches<string>(e => e != null && e.Contains(SaveFailedPhrase)),
$"保存失敗がエラー通知(ShowError)されること.通知: [{string.Join(" / ", obs.Errors)}]");
Assert.That(obs.Messages, Has.No.Member(AllSavedMessage),
$"保存に失敗したのに「{AllSavedMessage}」と表示しないこと.表示履歴: [{string.Join(" / ", obs.Messages)}]");
Assert.That(obs.ShotsEnabled, Is.True, "保存失敗後も撮影ボタンが再び有効になること(UI が固着しない)");
}
}
/// <summary>
/// 撮影ボタン押下(Form1.ExecuteShot)と同じく撮影ボタンを無効化してから撮影し,保存スレッドの終了を待って観測値を集める.
/// 無効化しておかないと,再有効化されたかどうかを観測できない(校正完了で既に有効なため).
/// </summary>
private static ShotObservation ShootAndObserve(IntegrationHarness h, SelfTestCamera camera, bool multi) {
h.Form.SelfTestClearLog();
h.Form.EnableShots(false);
if (multi) {
camera.ShotMulti();
} else {
camera.ShotOne();
}
h.WaitSaveFinished(camera);
var folder = camera.CurrentSaveFolder;
var obs = new ShotObservation {
Errors = h.Form.SelfTestErrors.ToList(),
Messages = h.Form.SelfTestMessages.ToList(),
ShotsEnabled = h.Form.SelfTestShotsEnabled,
SaveFolder = folder,
Channels = GetConversionChannels(),
InjectedPaths = (camera as SaveBlockingCamera)?.InjectedPaths.ToList() ?? new List<string>(),
};
// 期待ファイル名は一時設定が読み込まれている STA スレッド上で組み立てる(SPEC_01「撮影保存ファイル」)
obs.ExpectedFilesForNo1.Add(Config.GetString("File/Info"));
obs.ExpectedFilesForNo1.Add(ShotFileName("File/RgbImage", 0, 1));
obs.ExpectedFilesForNo1.AddRange(obs.Channels.Select(ch => ShotFileName("File/SrgbImage", ch, 1)));
if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder)) {
obs.SaveFolderUnderSaveRoot = IsUnder(folder, h.SaveRoot);
obs.SavedFiles = Directory.GetFiles(folder).Select(Path.GetFileName).ToList();
var info = Path.Combine(folder, Config.GetString("File/Info"));
obs.InfoHasShotLogHeader = File.Exists(info) && File.ReadAllText(info).Contains("Shot,Time(ms),Note");
}
return obs;
}
/// <summary>SPEC_01 のファイル名規則({CN}: 変換次元 2 桁,{NO}: 連番 4 桁)で保存ファイル名を作る</summary>
private static string ShotFileName(string key, int channel, int no) {
return Config.GetString(key).Replace("{CN}", $"{channel:00}").Replace("{NO}", $"{no:0000}");
}
/// <summary>設定の変換次元(Calib/ConversionChannels.SPEC_02: カンマ区切り・4〜17 の範囲のみ有効)</summary>
private static List<int> GetConversionChannels() {
return Config.GetString("Calib/ConversionChannels")
.Split(',')
.Select(x => int.Parse(x.Trim()))
.Where(x => x >= 4 && x <= 17)
.Distinct()
.OrderBy(x => x)
.ToList();
}
private static bool IsUnder(string path, string root) {
var p = Path.GetFullPath(path).TrimEnd('\\') + "\\";
var r = Path.GetFullPath(root).TrimEnd('\\') + "\\";
return p.StartsWith(r, StringComparison.OrdinalIgnoreCase);
}
/// <summary>STA スレッドで集めた観測値(テストスレッドで検証する)</summary>
private sealed class ShotObservation {
public List<string> Errors = new List<string>();
public List<string> Messages = new List<string>();
public bool ShotsEnabled;
public string SaveFolder;
public bool SaveFolderUnderSaveRoot;
public List<string> SavedFiles = new List<string>();
public bool InfoHasShotLogHeader;
public List<int> Channels = new List<int>();
public List<string> InjectedPaths = new List<string>();
public List<string> ExpectedFilesForNo1 = new List<string>();
}
/// <summary>
/// 指定した連番の撮影画像(Shot{NO}.png)を保存できなくする偽カメラ(失敗注入用).
/// 撮影開始時(保存フォルダ作成後)に,保存先フォルダへ同名のフォルダを作る.
/// これにより Cv2.ImWrite が false を返し,IoUtil.SaveImage が false を返す(例外は出ない).
/// </summary>
private sealed class SaveBlockingCamera : SelfTestCamera {
private readonly int[] _blockedNos;
public SaveBlockingCamera(Form1 form, Func<int, Mat> frameFactory, params int[] blockedNos) : base(form, frameFactory) {
_blockedNos = blockedNos;
}
/// <summary>作成した同名フォルダのパス</summary>
public List<string> InjectedPaths { get; } = new List<string>();
protected override void BeginShot() {
base.BeginShot();
foreach (var no in _blockedNos) {
var path = Path.Combine(CurrentSaveFolder, ShotFileName("File/RgbImage", 0, no));
Directory.CreateDirectory(path);
InjectedPaths.Add(path);
}
}
}
}
}
#endif