Newer
Older
TIASjudge / tests / test_main.py
"""エントリポイントのコマンドライン引数の検証(ENV_04「起動」「デバッグ用」).

対象は `tiasjudge.__main__` の引数解析と,`--preview-result` 用のダミー診断結果の
組み立て(デバッグ専用.診断・SVM 学習は行わない).

- `--check`: 画面を構築して即終了する(既存の起動確認は `test_package_skeleton.py`)
- `--preview-result`: 結果画面だけをダミー結果で開く
- `--failed`: `--preview-result` と併用し,診断失敗パターン(旧実装の失敗時スコア組)で開く
- `--english`: `--preview-result` と併用し,英語表示で開く(結果画面は表示専用になり
  画面内で言語を切り替えられないため,プレビューの言語はコマンドラインで指定する)

※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない.ここで画面を
生成するのは `--check` による**起動可能性の確認**に限る(Qt は conftest.py で offscreen).
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest

from tiasjudge.__main__ import (
    PREVIEW_FAILED_TOP_LABELS,
    PREVIEW_IMAGE_RGB,
    PREVIEW_IMAGE_SIZE,
    PREVIEW_LAB,
    PREVIEW_PERCENTAGES,
    PREVIEW_TOP_LABELS,
    _build_parser,
    _preview_image,
    _preview_result,
    _run_preview,
    _verify_bundled_data,
    main,
)
from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT
from tiasjudge.core.svm import COLOR_KEYS, LEGACY_FAILURE_PERCENTAGES
from tiasjudge.gui.result_window import DISPLAY_ORDER, HIGHLIGHT_COUNT, Language, build_result_rows

# --- 引数解析 ---


OPTION_FLAGS = ("check", "preview_result", "failed", "english")


class TestArgumentParser:
    """`--check` / `--preview-result` / `--failed` / `--english` の 4 オプション(ENV_04)."""

    def test_no_options_by_default(self) -> None:
        arguments = _build_parser().parse_args([])
        flags = tuple(getattr(arguments, name) for name in OPTION_FLAGS)
        assert flags == (False, False, False, False)

    @pytest.mark.parametrize(
        ("argv", "expected"),
        [
            (["--check"], ("check",)),
            (["--preview-result"], ("preview_result",)),
            (["--preview-result", "--failed"], ("preview_result", "failed")),
            (["--failed", "--preview-result", "--check"], ("preview_result", "failed", "check")),
            (["--preview-result", "--english"], ("preview_result", "english")),
            (
                ["--preview-result", "--english", "--check"],
                ("preview_result", "english", "check"),
            ),
            (
                ["--preview-result", "--english", "--failed"],
                ("preview_result", "english", "failed"),
            ),
        ],
    )
    def test_flags_are_parsed(self, argv: list[str], expected: tuple[str, ...]) -> None:
        arguments = _build_parser().parse_args(argv)
        for name in OPTION_FLAGS:
            assert getattr(arguments, name) is (name in expected), name

    def test_unknown_option_is_rejected(self) -> None:
        """未知のオプションはエラー終了する(黙って無視しない)."""
        with pytest.raises(SystemExit) as error:
            _build_parser().parse_args(["--unknown"])
        assert error.value.code == 2

    @pytest.mark.parametrize("option", ["--check", "--preview-result", "--failed", "--english"])
    def test_help_lists_the_option(self, option: str) -> None:
        assert option in _build_parser().format_help()


# --- プレビュー用のダミーデータ(デバッグ専用) ---


class TestPreviewImage:
    """ダミー画像(舌画像の代わり)."""

    def test_shape_and_dtype(self) -> None:
        image = _preview_image()
        assert image.shape == (PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3)
        assert image.dtype.name == "uint8"

    def test_is_stored_in_bgr_order(self) -> None:
        """結果画面は BGR 前提で受け取るため,BGR 並びで埋める."""
        red, green, blue = PREVIEW_IMAGE_RGB
        assert tuple(_preview_image()[0, 0]) == (blue, green, red)


class TestPreviewResult:
    """ダミー診断結果が `DiagnosisResult` として妥当であること."""

    @pytest.mark.parametrize("failed", [False, True])
    def test_scores_cover_all_color_keys(self, failed: bool) -> None:
        result = _preview_result(failed=failed)
        assert set(result.scores) == set(COLOR_KEYS)
        assert set(result.percentages) == set(COLOR_KEYS)

    @pytest.mark.parametrize("failed", [False, True])
    def test_scores_are_percentages_divided_by_100(self, failed: bool) -> None:
        result = _preview_result(failed=failed)
        for key, percent in result.percentages.items():
            assert result.scores[key] == pytest.approx(percent / 100)
            assert 0.0 <= result.scores[key] <= 1.0

    @pytest.mark.parametrize("failed", [False, True])
    def test_top_labels_are_two_known_colors(self, failed: bool) -> None:
        result = _preview_result(failed=failed)
        assert len(result.top_labels) == HIGHLIGHT_COUNT
        assert len(set(result.top_labels)) == HIGHLIGHT_COUNT
        assert set(result.top_labels) <= set(COLOR_KEYS)

    @pytest.mark.parametrize("failed", [False, True])
    def test_top_labels_are_the_two_highest_scores(self, failed: bool) -> None:
        """上位 2 色はスコア降順の先頭 2 件(表示と矛盾しないダミーであること)."""
        result = _preview_result(failed=failed)
        ranked = sorted(result.percentages, key=lambda key: result.percentages[key], reverse=True)
        assert set(result.top_labels) == set(ranked[:HIGHLIGHT_COUNT])

    @pytest.mark.parametrize("failed", [False, True])
    def test_predictions_have_the_full_column_count(self, failed: bool) -> None:
        result = _preview_result(failed=failed)
        assert result.predictions.shape == (EVALUATION_COLUMN_COUNT,)

    def test_success_pattern(self) -> None:
        """成功パターンは `PREVIEW_PERCENTAGES`・失敗フラグなし(ENV_04「デバッグ用」)."""
        result = _preview_result(failed=False)
        assert result.percentages == PREVIEW_PERCENTAGES
        assert result.top_labels == PREVIEW_TOP_LABELS
        assert result.failed is False
        assert result.failure_reason is None

    def test_success_pattern_totals_100_percent(self) -> None:
        assert sum(PREVIEW_PERCENTAGES.values()) == 100

    def test_failed_pattern_uses_the_legacy_failure_scores(self) -> None:
        """失敗パターンは旧実装の失敗時スコア組(`LEGACY_FAILURE_PERCENTAGES`)."""
        result = _preview_result(failed=True)
        assert result.percentages == LEGACY_FAILURE_PERCENTAGES
        assert result.top_labels == PREVIEW_FAILED_TOP_LABELS
        assert result.failed is True
        assert result.failure_reason is not None

    def test_failed_top_labels_follow_the_legacy_scores(self) -> None:
        """失敗パターンの上位 2 色は旧スコア組を降順に並べた先頭 2 件(紫・淡紅)."""
        ranked = sorted(
            LEGACY_FAILURE_PERCENTAGES,
            key=lambda key: LEGACY_FAILURE_PERCENTAGES[key],
            reverse=True,
        )
        assert set(PREVIEW_FAILED_TOP_LABELS) == set(ranked[:HIGHLIGHT_COUNT])

    def test_does_not_share_the_legacy_score_dictionary(self) -> None:
        """モジュール定数を書き換えない(コピーを持つ)."""
        result = _preview_result(failed=True)
        assert result.percentages is not LEGACY_FAILURE_PERCENTAGES
        result = _preview_result(failed=False)
        assert result.percentages is not PREVIEW_PERCENTAGES

    @pytest.mark.parametrize("failed", [False, True])
    @pytest.mark.parametrize("language", list(Language))
    def test_rows_can_be_built_from_the_dummy_result(
        self, failed: bool, language: Language
    ) -> None:
        """ダミー結果から結果画面の 5 行を組み立てられる(表示可能な形であること)."""
        rows = build_result_rows(_preview_result(failed=failed), language)
        assert tuple(row.key for row in rows) == DISPLAY_ORDER
        assert len({row.key for row in rows if row.highlighted}) == HIGHLIGHT_COUNT


# --- 起動(ENV_04「デバッグ用」) ---


class TestPreviewLaunch:
    """`--preview-result` の起動確認(`--check` で画面を構築して即終了する)."""

    @pytest.mark.parametrize(
        "argv",
        [
            ["--check"],
            ["--preview-result", "--check"],
            ["--preview-result", "--failed", "--check"],
            ["--preview-result", "--english", "--check"],
            ["--preview-result", "--english", "--failed", "--check"],
        ],
    )
    def test_check_exits_successfully(self, argv: list[str]) -> None:
        assert main(argv) == 0

    def test_preview_does_not_build_the_main_window(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """プレビューはメイン画面を作らない(結果画面だけを開く)."""
        import tiasjudge.gui.main_window as main_window

        def fail(*args: object, **kwargs: object) -> None:
            raise AssertionError("プレビューではメイン画面を作ってはならない")

        monkeypatch.setattr(main_window, "MainWindow", fail)
        assert main(["--preview-result", "--check"]) == 0

    @pytest.mark.parametrize(
        ("options", "expected_language"),
        [([], Language.JAPANESE), (["--english"], Language.ENGLISH)],
        ids=["default_japanese", "english_option"],
    )
    @pytest.mark.parametrize("failed", [False, True])
    def test_preview_opens_the_result_window_with_the_requested_language(
        self,
        monkeypatch: pytest.MonkeyPatch,
        failed: bool,
        options: list[str],
        expected_language: Language,
    ) -> None:
        """結果画面にダミー結果・ダミー画像・指定言語が渡される(ENV_04「デバッグ用」).

        既定は日本語,`--english` を付けると英語で開く(`--failed` との併用も可).
        """
        import tiasjudge.gui.result_window as result_window

        captured: dict[str, object] = {}

        class FakeResultWindow:
            def __init__(self, result: object, image: object, language: object) -> None:
                captured.update(result=result, image=image, language=language)

            def close(self) -> None:
                captured["closed"] = True

        monkeypatch.setattr(result_window, "ResultWindow", FakeResultWindow)
        argv = ["--preview-result", "--check", *options] + (["--failed"] if failed else [])
        assert main(argv) == 0

        assert captured["language"] is expected_language
        assert captured["closed"] is True
        result = captured["result"]
        assert result.failed is failed  # type: ignore[union-attr]
        assert result.percentages == _preview_result(failed=failed).percentages  # type: ignore[union-attr]
        image = captured["image"]
        assert image.shape == (PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3)  # type: ignore[union-attr]

    def test_module_invocation_with_preview_exits_successfully(
        self, repo_root: Path, headless_env: dict[str, str]
    ) -> None:
        """`python -m tiasjudge --preview-result --check` が正常終了する(ENV_04 の記載どおり)."""
        result = subprocess.run(
            [sys.executable, "-m", "tiasjudge", "--preview-result", "--check"],
            cwd=repo_root,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            env=headless_env,
            timeout=120,
        )
        assert result.returncode == 0, result.stderr


# --- 表示先モニタ(プレビュー) ---


class TestRunPreview:
    """`_run_preview`(`--preview-result` の本体)のモニタ選択・終了コード.

    ※ `QApplication` はスタブを渡す(イベントループを回さずに検証するため).
    画面の見た目は人間が確認する(testing.md「GUI の自動テストは書かない」).
    """

    class FakeApp:
        """`QApplication` の代わり(`screens` / `primaryScreen` / `exec` のみ)."""

        def __init__(self, screens: list[object], exit_code: int = 0) -> None:
            self._screens = screens
            self._exit_code = exit_code
            self.exec_called = False

        def screens(self) -> list[object]:
            return self._screens

        def primaryScreen(self) -> object | None:  # noqa: N802 - Qt の API 名に合わせる
            return self._screens[0] if self._screens else None

        def exec(self) -> int:
            self.exec_called = True
            return self._exit_code

    @pytest.fixture
    def fake_result_window(self, monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
        """`ResultWindow` をスタブに差し替え,生成・close を記録する."""
        import tiasjudge.gui.result_window as result_window

        captured: dict[str, object] = {"closed": False}

        class FakeResultWindow:
            def __init__(self, result: object, image: object, language: object) -> None:
                captured.update(result=result, image=image, language=language, window=self)

            def close(self) -> None:
                captured["closed"] = True

        monkeypatch.setattr(result_window, "ResultWindow", FakeResultWindow)
        return captured

    def test_check_returns_zero_without_selecting_a_screen(
        self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object]
    ) -> None:
        """`--check` 併用時は画面を構築して閉じるだけ(最大化もイベントループも行わない)."""
        import tiasjudge.gui.result_window as result_window

        def fail(*args: object, **kwargs: object) -> bool:
            raise AssertionError("--check では表示先モニタを選んではならない")

        monkeypatch.setattr(result_window, "show_on_secondary_screen", fail)
        app = TestRunPreview.FakeApp([object()])

        assert _run_preview(app, failed=False, check=True) == 0  # type: ignore[arg-type]
        assert fake_result_window["closed"] is True
        assert app.exec_called is False

    def test_uses_the_primary_screen_as_the_reference(
        self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object]
    ) -> None:
        """プレビューにはメイン画面が無いため,基準はプライマリモニタ."""
        import tiasjudge.gui.result_window as result_window

        captured: dict[str, object] = {}

        def record(window: object, screens: object, reference: object) -> bool:
            captured.update(window=window, screens=screens, reference=reference)
            return True

        monkeypatch.setattr(result_window, "show_on_secondary_screen", record)
        screens = [object(), object()]
        app = TestRunPreview.FakeApp(screens, exit_code=0)

        assert _run_preview(app, failed=False, check=False) == 0  # type: ignore[arg-type]
        assert captured["window"] is fake_result_window["window"]
        assert list(captured["screens"]) == screens  # type: ignore[call-overload]
        assert captured["reference"] is screens[0]
        assert app.exec_called is True

    def test_returns_the_event_loop_exit_code(
        self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object]
    ) -> None:
        """イベントループの終了コードをそのまま返す."""
        import tiasjudge.gui.result_window as result_window

        monkeypatch.setattr(
            result_window, "show_on_secondary_screen", lambda *args, **kwargs: False
        )
        app = TestRunPreview.FakeApp([object()], exit_code=3)

        assert _run_preview(app, failed=True, check=False) == 3  # type: ignore[arg-type]
        assert fake_result_window["language"] is Language.JAPANESE
        assert fake_result_window["result"].failed is True  # type: ignore[union-attr]

    def test_language_defaults_to_japanese(self, fake_result_window: dict[str, object]) -> None:
        """`english` を省略した呼び出しは日本語(既定値)."""
        app = TestRunPreview.FakeApp([object()])
        assert _run_preview(app, failed=False, check=True) == 0  # type: ignore[arg-type]
        assert fake_result_window["language"] is Language.JAPANESE

    @pytest.mark.parametrize(
        ("english", "expected_language"),
        [(False, Language.JAPANESE), (True, Language.ENGLISH)],
    )
    def test_english_option_selects_the_language(
        self,
        fake_result_window: dict[str, object],
        english: bool,
        expected_language: Language,
    ) -> None:
        """結果画面は表示専用のため,プレビューの言語は `--english` で決まる."""
        app = TestRunPreview.FakeApp([object()])
        assert _run_preview(app, failed=False, english=english, check=True) == 0  # type: ignore[arg-type]
        assert fake_result_window["language"] is expected_language


# --- 同梱データの動作確認(`--verify-data`.PLAN_02 ステップ 8「配布」) ---


class TestVerifyDataOption:
    """`--verify-data`: 画面を出さずに同梱学習データの読込と診断を 1 回だけ行う.

    配布 exe(PyInstaller onefile)が `data/data.csv` を同梱できているか,
    OpenCV の SVM が動くかを画面なしで確認するためのオプション(ENV_04「配布用ビルド」).
    """

    def test_flag_defaults_to_false(self) -> None:
        assert _build_parser().parse_args([]).verify_data is False

    def test_flag_is_parsed(self) -> None:
        assert _build_parser().parse_args(["--verify-data"]).verify_data is True

    def test_help_lists_the_option(self) -> None:
        assert "--verify-data" in _build_parser().format_help()

    def test_returns_zero_with_the_bundled_data(self) -> None:
        assert _verify_bundled_data() == 0

    def test_reports_the_diagnosis_of_the_bundled_data(
        self, data_csv_path: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        """診断は同梱データ + 既定の L*a*b* で行い,結果をログに出す(SPEC_02 の 5 色)."""
        from tiasjudge.core.color import Lab
        from tiasjudge.core.csv_io import load_training_data
        from tiasjudge.core.svm import diagnose

        with caplog.at_level("INFO"):
            assert _verify_bundled_data() == 0

        expected = diagnose(Lab(*PREVIEW_LAB), load_training_data(data_csv_path))
        messages = "\n".join(record.getMessage() for record in caplog.records)
        assert str(expected.percentages) in messages

    def test_uses_the_default_training_data_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """学習データのパスは GUI と同じ解決(frozen 対応)を使う."""
        import tiasjudge.gui.main_window as main_window

        called: list[bool] = []
        original = main_window.default_training_data_path

        def record() -> Path:
            called.append(True)
            return original()

        monkeypatch.setattr(main_window, "default_training_data_path", record)
        assert _verify_bundled_data() == 0
        assert called == [True]

    def test_returns_one_when_the_data_is_missing(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """学習データを読めない場合は終了コード 1(黙って成功しない)."""
        from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR

        monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(tmp_path / "missing.csv"))
        assert _verify_bundled_data() == 1

    def test_returns_one_for_a_malformed_csv(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """形式不正な CSV でも例外を投げず終了コード 1 を返す."""
        from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR

        broken = tmp_path / "broken.csv"
        broken.write_text("L,a,b\n1,2\n", encoding="utf-8")
        monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(broken))
        assert _verify_bundled_data() == 1

    def test_main_returns_zero_without_building_any_window(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """`--verify-data` は画面を作らない(メイン画面・結果画面とも構築しない)."""
        import tiasjudge.gui.main_window as main_window
        import tiasjudge.gui.result_window as result_window

        def fail(*args: object, **kwargs: object) -> None:
            raise AssertionError("--verify-data では画面を作ってはならない")

        monkeypatch.setattr(main_window, "MainWindow", fail)
        monkeypatch.setattr(result_window, "ResultWindow", fail)
        assert main(["--verify-data"]) == 0

    def test_main_returns_one_when_the_data_is_missing(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR

        monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(tmp_path / "missing.csv"))
        assert main(["--verify-data"]) == 1

    def test_module_invocation_exits_successfully(
        self, repo_root: Path, headless_env: dict[str, str]
    ) -> None:
        """`python -m tiasjudge --verify-data` が正常終了する(ENV_04 の記載どおり)."""
        result = subprocess.run(
            [sys.executable, "-m", "tiasjudge", "--verify-data"],
            cwd=repo_root,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            env=headless_env,
            timeout=120,
        )
        assert result.returncode == 0, result.stderr