Newer
Older
TIASShot / TIASshot.Tests / CameraBaseSaveIntegrationTests.cs
#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