Newer
Older
TIASShot / TIASshot.Tests / Form1UiIntegrationTests.cs
#if SELFTEST
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;

namespace TIASshot.Tests {

    /// <summary>
    /// Form1 の UI 連携メソッドを UI スレッド外(ワーカースレッド)から呼んだときの結合ロジックテスト(層②).
    ///
    /// 仕様: testing.md「層②: UI/Form1 の UI 連携メソッド」(UI スレッド外から呼んだときのマーシャリング)・
    /// SPEC_01「撮影」「データ保存」(データ名は保存フォルダ名 `[時刻]-[データ名]` の一部,連続撮影の枚数・間隔は UI で変更可能).
    ///   - 取得系(GetDataName・GetNumMultiShots・GetMultiShotsInterval)は,どのスレッドから呼んでも UI の入力値を返す
    ///   - 通知系(ShowMessage・EnableShots)は,どのスレッドから呼んでも UI に反映される
    ///   - PLAN_05「束 B の方針」: UI スレッド外からの通知系は UI スレッドの処理を待たない(撮影中でも保存が進む).
    ///     閉じ始めた後・ハンドル未生成のフォームでは何もしない(閉じるときのハング防止)
    ///
    /// ワーカースレッドの呼び出しは Task.Run で行い,UI スレッド(ハーネスの STA スレッド)はメッセージを処理しながら
    /// 完了を待つ(Control.Invoke は UI スレッドがメッセージを処理しないと返らないため).
    /// 観測値は STA スレッド上で集めてテストスレッドへ返し,アサーションはテストスレッドで行う.
    /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
    /// </summary>
    [TestFixture]
    public class Form1UiIntegrationTests {

        // ワーカースレッドの呼び出し 1 回に許す時間(Invoke が UI スレッドで処理されるまで)
        private static readonly TimeSpan WorkerTimeout = TimeSpan.FromSeconds(5);

        // ================================================================================
        // 取得系(正常系)
        // ================================================================================

        /// <summary>
        /// ワーカースレッドから GetDataName を呼んだとき,UI に入力されたデータ名が返る(null にならない).
        /// 撮影(ShotOne / ShotMulti)は保存フォルダ名にデータ名を使うため,null だと保存先が誤る.
        /// </summary>
        [Test, Category("Integration")]
        public void GetDataName_ワーカースレッドから呼ぶ_UIに入力したデータ名を返す() {
            // Arrange / Act
            var result = IntegrationHarness.Run(h => {
                h.Form.SelfTestSetDataName("abc");
                return CallFromWorker(h, () => h.Form.GetDataName());
            });

            // Assert
            Assert.That(result.Error, Is.Null, $"ワーカースレッドからの呼び出しが例外にならないこと: {result.Error}");
            Assert.That(result.Value, Is.EqualTo("abc"));
        }

        /// <summary>ワーカースレッドから GetNumMultiShots を呼んだとき,UI に入力した枚数が返る</summary>
        [Test, Category("Integration")]
        public void GetNumMultiShots_ワーカースレッドから呼ぶ_UIに入力した枚数を返す() {
            // Arrange / Act
            var result = IntegrationHarness.Run(h => {
                h.Form.SelfTestSetMultiShots("7", "250");
                return CallFromWorker(h, () => h.Form.GetNumMultiShots());
            });

            // Assert
            Assert.That(result.Error, Is.Null, $"ワーカースレッドからの呼び出しが例外にならないこと: {result.Error}");
            Assert.That(result.Value, Is.EqualTo(7));
        }

        /// <summary>ワーカースレッドから GetMultiShotsInterval を呼んだとき,UI に入力した間隔が返る</summary>
        [Test, Category("Integration")]
        public void GetMultiShotsInterval_ワーカースレッドから呼ぶ_UIに入力した間隔を返す() {
            // Arrange / Act
            var result = IntegrationHarness.Run(h => {
                h.Form.SelfTestSetMultiShots("7", "250");
                return CallFromWorker(h, () => h.Form.GetMultiShotsInterval());
            });

            // Assert
            Assert.That(result.Error, Is.Null, $"ワーカースレッドからの呼び出しが例外にならないこと: {result.Error}");
            Assert.That(result.Value, Is.EqualTo(250));
        }

        // ================================================================================
        // 取得系(失敗注入: 数値として解釈できない入力)
        // ================================================================================

        /// <summary>
        /// 枚数・間隔の入力欄に数値でない文字列があるとき,ワーカースレッドから呼んでも例外にならず,
        /// UI スレッドから呼んだ場合と同じ値を返す(既定値そのものは仕様に定めがないためアサートしない).
        /// </summary>
        [Test, Category("Integration")]
        public void GetNumMultiShotsとGetMultiShotsInterval_数値でない入力をワーカースレッドから読む_UIスレッドと同じ値を返す() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                h.Form.SelfTestSetMultiShots("abc", "");
                return new {
                    UiNum = h.Form.GetNumMultiShots(),
                    UiInterval = h.Form.GetMultiShotsInterval(),
                    WorkerNum = CallFromWorker(h, () => h.Form.GetNumMultiShots()),
                    WorkerInterval = CallFromWorker(h, () => h.Form.GetMultiShotsInterval()),
                };
            });

            // Assert
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.WorkerNum.Error, Is.Null, $"枚数: ワーカースレッドからの呼び出しが例外にならないこと: {obs.WorkerNum.Error}");
                Assert.That(obs.WorkerInterval.Error, Is.Null, $"間隔: ワーカースレッドからの呼び出しが例外にならないこと: {obs.WorkerInterval.Error}");
                Assert.That(obs.WorkerNum.Value, Is.EqualTo(obs.UiNum), "枚数: UI スレッドから読んだ値と同じであること");
                Assert.That(obs.WorkerInterval.Value, Is.EqualTo(obs.UiInterval), "間隔: UI スレッドから読んだ値と同じであること");
            }
        }

        // ================================================================================
        // 通知系
        // ================================================================================

        /// <summary>ワーカースレッドから ShowMessage を呼んだとき,ステータス表示に反映される</summary>
        [Test, Category("Integration")]
        public void ShowMessage_ワーカースレッドから呼ぶ_ステータス表示に反映される() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var call = CallFromWorker(h, () => { h.Form.ShowMessage("worker-message"); return 0; });
                return new { call.Error, Message = h.Form.SelfTestMessage };
            });

            // Assert
            Assert.That(obs.Error, Is.Null, $"ワーカースレッドからの呼び出しが例外にならないこと: {obs.Error}");
            Assert.That(obs.Message, Is.EqualTo("worker-message"));
        }

        /// <summary>
        /// ワーカースレッドから EnableShots(true) / EnableShots(false) を呼んだとき,撮影ボタンの有効・無効に反映される
        /// (SaveThread が保存完了後に撮影ボタンを再有効化する経路).
        /// </summary>
        [Test, Category("Integration")]
        public void EnableShots_ワーカースレッドから呼ぶ_撮影ボタンの有効無効に反映される() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var before = h.Form.SelfTestShotsEnabled;
                var enable = CallFromWorker(h, () => { h.Form.EnableShots(true); return 0; });
                var afterEnable = h.Form.SelfTestShotsEnabled;
                var disable = CallFromWorker(h, () => { h.Form.EnableShots(false); return 0; });
                var afterDisable = h.Form.SelfTestShotsEnabled;
                return new { before, enable, afterEnable, disable, afterDisable };
            });

            // Assert
            Assert.That(obs.before, Is.False, "前提: 開始時(校正前)は撮影ボタンが無効であること");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.enable.Error, Is.Null, $"有効化の呼び出しが例外にならないこと: {obs.enable.Error}");
                Assert.That(obs.disable.Error, Is.Null, $"無効化の呼び出しが例外にならないこと: {obs.disable.Error}");
                Assert.That(obs.afterEnable, Is.True, "EnableShots(true) で撮影ボタンが有効になること");
                Assert.That(obs.afterDisable, Is.False, "EnableShots(false) で撮影ボタンが無効になること");
            }
        }

        // ================================================================================
        // 通知系(UI スレッドを待たない.PLAN_05「束 B の方針」: 保存スレッドから UI への通知)
        // ================================================================================

        /// <summary>
        /// ワーカースレッドから ShowMessage を呼んだとき,UI スレッドがメッセージを処理していなくても待たずに戻り,
        /// その後 UI スレッドが投稿を処理するとステータス表示に反映される.
        /// </summary>
        [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 スレッドが投稿を処理した後はステータス表示に反映されること");
            }
        }

        /// <summary>
        /// ワーカースレッドから EnableShots を呼んだとき,UI スレッドがメッセージを処理していなくても待たずに戻り,
        /// その後 UI スレッドが投稿を処理すると撮影ボタンに反映される.
        /// </summary>
        [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 スレッドが投稿を処理した後は撮影ボタンに反映されること");
            }
        }

        // ================================================================================
        // 通知系(失敗注入: 閉じ始めた後・ハンドル未生成)
        // ================================================================================

        /// <summary>
        /// 閉じ始めた後(Form1_FormClosing で閉じると決めた後)は,UI スレッド・ワーカースレッドのどちらから
        /// ShowMessage / EnableShots を呼んでも例外にならず,表示も撮影ボタンも変わらない(閉じるときのハング防止).
        /// </summary>
        [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), "閉じ始めた後は撮影ボタンが変わらないこと");
            }
        }

        /// <summary>
        /// ウィンドウハンドルが未生成のフォームでは,UI スレッド・ワーカースレッドのどちらから ShowMessage / EnableShots を
        /// 呼んでも例外にならず,表示も撮影ボタンも変わらない.
        /// </summary>
        [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), "ハンドル未生成では撮影ボタンが変わらないこと");
            }
        }

        // ================================================================================
        // 共通処理
        // ================================================================================

        /// <summary>
        /// 関数をワーカースレッド(Task.Run)で実行し,UI スレッドのメッセージを処理しながら完了を待つ.
        /// 例外は戻り値の Error に入れて返す(テストスレッドでアサートするため).
        /// </summary>
        private static WorkerResult<T> CallFromWorker<T>(IntegrationHarness h, Func<T> func) {
            var task = Task.Run(func);
            h.PumpUntil(() => task.IsCompleted, "ワーカースレッドからの呼び出しの完了", WorkerTimeout);
            if (task.IsFaulted) {
                var ex = task.Exception?.GetBaseException();
                return new WorkerResult<T> { Error = $"{ex?.GetType().Name}: {ex?.Message}" };
            }
            return new WorkerResult<T> { Value = task.Result };
        }

        // 「待たずに戻る」とみなす上限(UI スレッドの処理を待つ実装なら,メッセージを処理しない限り戻らない)
        private static readonly TimeSpan NonBlockingLimit = TimeSpan.FromSeconds(1);

        /// <summary>
        /// UI スレッドのメッセージを処理せずに,条件の成立を上限つきで待つ(成立したら true).
        /// WaitHandle・Join は STA スレッドで一部のメッセージを処理しうるため使わず,Yield とスピンで待つ.
        /// </summary>
        private static bool WaitWithoutPumping(Func<bool> 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<T> {
            public T Value;
            public string Error;
        }
    }
}
#endif