Newer
Older
TIASjudge / tests / gui / test_result_window_logic.py
"""結果画面の QWidget 非依存ロジックの検証(SPEC_01「結果画面」・PLAN_02 ステップ 7).

仕様上の要件は以下(SPEC_01「結果画面」).

- 診断画像はクロップ後の元画像を 1/2 縮小表示する
- 結果パネルは 5 色の行(色名 + 説明)を一覧表示し,**上位 2 色のみ**スコア(%)と下線を描く
- 言語切替ボタンは日本語 ⇔ 英語を切り替え,ラベルは切替**先**の言語名
- 診断失敗時は「Diagnosis failed / Please try again.」を表示する
- 注記は「本結果は疾患の診断を行うものではない」「表示画像は明るさ補正されている」の 2 件

※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない.
本ファイルは `ResultWindow` を構築せず,画面部品に依存しない定数・関数のみを対象とする.
※ 実行環境にフォントが無い場合でも成立するよう,描画結果のピクセル比較は行わない.
"""

from __future__ import annotations

from pathlib import Path

import numpy as np
import pytest
from PySide6.QtWidgets import QApplication

from tiasjudge.core.color import rgb_to_lab
from tiasjudge.core.csv_io import TrainingData, load_training_data
from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose
from tiasjudge.gui.result_window import (
    COLOR_SWATCHES,
    DISPLAY_ORDER,
    FAILURE_DETAIL,
    FAILURE_RECT,
    FAILURE_TITLE,
    HIGHLIGHT_COUNT,
    IMAGE_DISPLAY_DIVISOR,
    LANGUAGE_TEXTS,
    PANEL_SIZE,
    ROW_FIRST_Y,
    ROW_MARGIN,
    UNDERLINE_WIDTH,
    UNDERLINE_X_RANGE,
    Language,
    build_result_rows,
    format_score,
    is_highlighted,
    next_language,
    render_result_panel,
    row_baseline,
    switch_button_label,
    texts_for,
    to_display_pixmap,
)

# --- ヘルパー ---


@pytest.fixture(scope="session")
def gui_app() -> QApplication:
    """QPixmap の生成に必要な QApplication を用意する(conftest で offscreen 指定済み).

    ※ `QGuiApplication` ではなく `QApplication` を作る.他のテスト(`--check` 起動)が
    `QApplication.instance()` を再利用してウィジェットを生成するため,ここで
    ウィジェット非対応のインスタンスを残すとプロセスごと落ちる.
    """
    app = QApplication.instance()
    if app is None:
        app = QApplication([])
    assert isinstance(app, QApplication)
    return app


def make_result(
    percentages: dict[str, int],
    top_labels: tuple[str, str],
    *,
    failed: bool = False,
) -> DiagnosisResult:
    """表示検証用の `DiagnosisResult` を組み立てる(SVM は動かさない)."""
    scores = {key: percentages[key] / 100.0 for key in COLOR_KEYS}
    return DiagnosisResult(
        lab=rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()),
        scores=scores,
        percentages=dict(percentages),
        top_labels=top_labels,
        predictions=np.zeros(190, dtype=np.float32),
        failed=failed,
    )


def _linear_coefficients() -> np.ndarray:
    """1 次項のみの合成変換行列(SPEC_02 (1) の 17×3 形式)."""
    coefficients = np.zeros((17, 3), dtype=np.float64)
    coefficients[0] = (0.4124, 0.2126, 0.0193)
    coefficients[1] = (0.3576, 0.7152, 0.1192)
    coefficients[2] = (0.1805, 0.0722, 0.9505)
    return coefficients * (100.0 / 255.0)


SAMPLE_PERCENTAGES = {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 68, "pale": 3}


@pytest.fixture
def sample_result() -> DiagnosisResult:
    """上位 2 色が pink・purple の結果."""
    return make_result(SAMPLE_PERCENTAGES, ("pink", "purple"))


@pytest.fixture(scope="module")
def training_data(data_csv_path: Path) -> TrainingData:
    """同梱学習データ(`data/data.csv`)を読み込む."""
    return load_training_data(data_csv_path)


def make_image(width: int, height: int, bgr: tuple[int, int, int] = (10, 20, 30)) -> np.ndarray:
    """指定サイズの単色 BGR 画像を作る."""
    image = np.empty((height, width, 3), dtype=np.uint8)
    image[:, :] = bgr
    return image


# --- 定数(SPEC_01・旧実装 `ResultDlg.cpp` 踏襲) ---


def test_display_order_is_pink_crimson_dark_red_purple_pale() -> None:
    """パネルの並びは上から 淡紅・紅・暗赤・紫・淡白(SPEC_01「5 色の表示内容」)."""
    assert DISPLAY_ORDER == ("pink", "crimson", "dark_red", "purple", "pale")


def test_display_order_covers_all_color_keys_exactly_once() -> None:
    """5 色すべてを過不足なく含む(`core.svm.COLOR_KEYS` と同じ集合)."""
    assert len(DISPLAY_ORDER) == len(set(DISPLAY_ORDER)) == len(COLOR_KEYS)
    assert set(DISPLAY_ORDER) == set(COLOR_KEYS)


def test_highlight_count_is_two() -> None:
    """スコア・下線を描くのは上位 2 色のみ(SPEC_01「結果パネル」)."""
    assert HIGHLIGHT_COUNT == 2


def test_image_is_displayed_at_half_scale() -> None:
    """診断画像は 1/2 縮小表示(SPEC_01「診断画像」)."""
    assert IMAGE_DISPLAY_DIVISOR == 2


def test_panel_size_follows_legacy() -> None:
    """結果パネルは旧実装の背景画像と同じ 644×644."""
    assert PANEL_SIZE == 644


def test_row_layout_follows_legacy_coordinates() -> None:
    """行の基準座標は旧実装 `ShowResultImg()` の y=250 / margin=87."""
    assert (ROW_FIRST_Y, ROW_MARGIN) == (250, 87)


def test_underline_follows_legacy_geometry() -> None:
    """下線は x=15〜635・太さ 8(旧実装 `cv::line(..., 8)`)."""
    assert UNDERLINE_X_RANGE == (15, 635)
    assert UNDERLINE_WIDTH == 8


def test_failure_band_follows_legacy_rect() -> None:
    """失敗帯は (0, 250)-(550, 450)(旧実装 `cv::rectangle`).x, y, 幅, 高さで保持する."""
    x, y, width, height = FAILURE_RECT
    assert (x, y) == (0, 250)
    assert (x + width, y + height) == (550, 450)


def test_failure_message_follows_spec() -> None:
    """失敗表示の文言は「Diagnosis failed / Please try again.」(SPEC_01「診断失敗表示」)."""
    assert FAILURE_TITLE == "Diagnosis failed"
    assert FAILURE_DETAIL == "Please try again."


# --- 言語リソース(SPEC_01「5 色の表示内容」) ---


class TestLanguageTexts:
    """日英 2 言語分の表示リソース."""

    def test_both_languages_are_defined(self) -> None:
        assert set(LANGUAGE_TEXTS) == set(Language) == {Language.JAPANESE, Language.ENGLISH}

    @pytest.mark.parametrize("language", list(Language))
    def test_rows_cover_display_order_exactly(self, language: Language) -> None:
        """各言語の行は `DISPLAY_ORDER` の 5 キーを過不足なく持つ."""
        keys = [row.key for row in texts_for(language).rows]
        assert len(keys) == len(set(keys)) == len(DISPLAY_ORDER)
        assert set(keys) == set(DISPLAY_ORDER)

    @pytest.mark.parametrize("language", list(Language))
    def test_has_two_notes(self, language: Language) -> None:
        """注記は 2 件(SPEC_01「注記」)."""
        assert len(texts_for(language).notes) == 2

    @pytest.mark.parametrize("language", list(Language))
    def test_note_contents_follow_spec(self, language: Language) -> None:
        """注記は「疾患の診断を行うものではない」「明るさ補正されている」の 2 件."""
        notes = texts_for(language).notes
        if language is Language.JAPANESE:
            assert "病気" in notes[0] or "疾患" in notes[0]
            assert "明るさ" in notes[1]
        else:
            assert "disease" in notes[0]
            assert "brightness" in notes[1]

    @pytest.mark.parametrize("language", list(Language))
    def test_labels_are_not_empty(self, language: Language) -> None:
        texts = texts_for(language)
        assert texts.window_title
        assert texts.heading
        assert texts.switch_button_label
        assert texts.close_button_label
        assert all(row.name and row.description for row in texts.rows)

    def test_english_names_and_descriptions_match_spec_table(self) -> None:
        """英語の色名・説明は SPEC_01「5 色の表示内容」の表どおり."""
        expected = {
            "pink": ("Pink", "Normal"),
            "crimson": ("Red", "Hot flush / feverish"),
            "dark_red": ("Dark red", "Blood slightly thick"),
            "purple": ("Purple", "Blood is thick"),
            "pale": ("Pale red", "Cold / fatigue / anemia"),
        }
        actual = {row.key: (row.name, row.description) for row in texts_for(Language.ENGLISH).rows}
        assert actual == expected

    def test_japanese_names_match_spec_table(self) -> None:
        """日本語の色名は SPEC_01「5 色の表示内容」の表どおり.

        5 色目は結果画面では「淡白紅」(メイン画面の「淡白」とは表記が異なる).
        旧実装の背景画像 `img_student2.bmp` の表記を現行踏襲したもので,
        SPEC_01 の注記に明記されている.
        """
        expected = {
            "pink": "淡紅",
            "crimson": "紅",
            "dark_red": "暗赤",
            "purple": "紫",
            "pale": "淡白紅",
        }
        actual = {row.key: row.name for row in texts_for(Language.JAPANESE).rows}
        assert actual == expected

    def test_swatch_is_defined_for_every_color(self) -> None:
        assert set(COLOR_SWATCHES) == set(DISPLAY_ORDER)
        for value in COLOR_SWATCHES.values():
            assert len(value) == 3
            assert all(0 <= channel <= 255 for channel in value)


# --- 言語切替(SPEC_01「言語切替ボタン」) ---


class TestLanguageSwitching:
    """日本語 ⇔ 英語の切替とボタンラベル."""

    def test_next_language_toggles(self) -> None:
        assert next_language(Language.JAPANESE) is Language.ENGLISH
        assert next_language(Language.ENGLISH) is Language.JAPANESE

    @pytest.mark.parametrize("language", list(Language))
    def test_next_language_round_trip(self, language: Language) -> None:
        assert next_language(next_language(language)) is language

    def test_switch_button_shows_the_target_language(self) -> None:
        """ラベルは切替**先**の言語名(SPEC_01)."""
        assert switch_button_label(Language.JAPANESE) == "English"
        assert switch_button_label(Language.ENGLISH) == "日本語"

    @pytest.mark.parametrize("language", list(Language))
    def test_switch_button_label_is_not_the_current_language(self, language: Language) -> None:
        """現在の言語名をそのまま出さない(切替先を示す)."""
        assert switch_button_label(language) != switch_button_label(next_language(language))

    @pytest.mark.parametrize("language", list(Language))
    def test_texts_for_returns_the_registered_resource(self, language: Language) -> None:
        assert texts_for(language) is LANGUAGE_TEXTS[language]


# --- is_highlighted / format_score / row_baseline ---


class TestIsHighlighted:
    """強調対象(上位 2 色)の判定."""

    def test_top_two_are_highlighted(self) -> None:
        top = ("pink", "purple")
        assert is_highlighted("pink", top) is True
        assert is_highlighted("purple", top) is True

    @pytest.mark.parametrize("key", ["crimson", "dark_red", "pale"])
    def test_others_are_not_highlighted(self, key: str) -> None:
        assert is_highlighted(key, ("pink", "purple")) is False

    def test_only_first_two_labels_are_used(self) -> None:
        """3 件目以降が渡されても強調は 2 色まで."""
        assert is_highlighted("crimson", ("pink", "purple", "crimson")) is False

    def test_unknown_key_is_not_highlighted(self) -> None:
        assert is_highlighted("unknown", ("pink", "purple")) is False


class TestFormatScore:
    """スコア文字列(旧実装 `Format("%3.0lf")` と同じ 3 桁右詰め)."""

    @pytest.mark.parametrize(
        ("percent", "expected"),
        [(0, "  0"), (3, "  3"), (42, " 42"), (100, "100")],
    )
    def test_right_aligned_to_three_columns(self, percent: int, expected: str) -> None:
        assert format_score(percent) == expected

    @pytest.mark.parametrize("percent", [0, 1, 50, 99, 100])
    def test_length_is_at_least_three(self, percent: int) -> None:
        assert len(format_score(percent)) == 3
        assert format_score(percent).strip() == str(percent)


class TestRowBaseline:
    """行のベースライン y(旧実装 `y = 250; margin = 87`)."""

    @pytest.mark.parametrize(
        ("index", "expected"),
        [(0, 250), (1, 337), (2, 424), (3, 511), (4, 598)],
    )
    def test_matches_legacy_positions(self, index: int, expected: int) -> None:
        assert row_baseline(index) == expected

    def test_all_rows_fit_in_the_panel(self) -> None:
        assert row_baseline(len(DISPLAY_ORDER) - 1) < PANEL_SIZE


# --- build_result_rows(SPEC_01「結果パネル」) ---


class TestBuildResultRows:
    """診断結果から行データを組み立てる."""

    @pytest.mark.parametrize("language", list(Language))
    def test_returns_five_rows_in_display_order(
        self, sample_result: DiagnosisResult, language: Language
    ) -> None:
        rows = build_result_rows(sample_result, language)
        assert tuple(row.key for row in rows) == DISPLAY_ORDER

    @pytest.mark.parametrize("language", list(Language))
    def test_only_top_two_are_highlighted(
        self, sample_result: DiagnosisResult, language: Language
    ) -> None:
        rows = build_result_rows(sample_result, language)
        highlighted = {row.key for row in rows if row.highlighted}
        assert highlighted == set(sample_result.top_labels)
        assert len(highlighted) == HIGHLIGHT_COUNT

    def test_score_text_is_only_set_on_highlighted_rows(
        self, sample_result: DiagnosisResult
    ) -> None:
        for row in build_result_rows(sample_result, Language.JAPANESE):
            if row.highlighted:
                assert row.score_text == format_score(sample_result.percentages[row.key])
            else:
                assert row.score_text is None

    def test_score_text_is_right_aligned(self, sample_result: DiagnosisResult) -> None:
        """スコアは 3 桁右詰め(pink=68 → " 68",purple=42 → " 42")."""
        rows = {row.key: row for row in build_result_rows(sample_result, Language.JAPANESE)}
        assert rows["pink"].score_text == " 68"
        assert rows["purple"].score_text == " 42"

    @pytest.mark.parametrize("language", list(Language))
    def test_uses_the_language_texts(
        self, sample_result: DiagnosisResult, language: Language
    ) -> None:
        expected = {row.key: (row.name, row.description) for row in texts_for(language).rows}
        for row in build_result_rows(sample_result, language):
            assert (row.name, row.description) == expected[row.key]

    def test_swatch_matches_the_color_table(self, sample_result: DiagnosisResult) -> None:
        for row in build_result_rows(sample_result, Language.ENGLISH):
            assert row.swatch == COLOR_SWATCHES[row.key]

    @pytest.mark.parametrize(
        "top_labels",
        [("pink", "purple"), ("pale", "crimson"), ("dark_red", "pink"), ("crimson", "dark_red")],
    )
    def test_highlight_follows_top_labels(self, top_labels: tuple[str, str]) -> None:
        result = make_result(SAMPLE_PERCENTAGES, top_labels)
        rows = build_result_rows(result, Language.JAPANESE)
        assert {row.key for row in rows if row.highlighted} == set(top_labels)

    def test_zero_percent_top_label_still_shows_a_score(self) -> None:
        """スコア 0 の色が上位に来ても "  0" を表示する(空文字にしない)."""
        result = make_result(SAMPLE_PERCENTAGES, ("dark_red", "pale"))
        rows = {row.key: row for row in build_result_rows(result, Language.JAPANESE)}
        assert rows["dark_red"].score_text == "  0"

    def test_failed_result_still_builds_rows(self) -> None:
        """診断失敗でも 5 行を組み立てられる(失敗表示はスコアに重ねて描かれる)."""
        result = make_result(
            {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 11, "pale": 3},
            ("purple", "pink"),
            failed=True,
        )
        rows = build_result_rows(result, Language.ENGLISH)
        assert tuple(row.key for row in rows) == DISPLAY_ORDER
        assert {row.key for row in rows if row.highlighted} == {"purple", "pink"}

    def test_rows_are_immutable(self, sample_result: DiagnosisResult) -> None:
        """行データは凍結データクラス(描画側で書き換えられない)."""
        row = build_result_rows(sample_result, Language.JAPANESE)[0]
        with pytest.raises(Exception):  # noqa: B017 - FrozenInstanceError(dataclasses)
            row.name = "書き換え"  # type: ignore[misc]


# --- to_display_pixmap(SPEC_01「診断画像」) ---


class TestToDisplayPixmap:
    """BGR 画像 → 1/2 縮小 QPixmap."""

    def test_halves_the_image_size(self, gui_app: QApplication) -> None:
        pixmap = to_display_pixmap(make_image(1024, 1280))
        assert (pixmap.width(), pixmap.height()) == (512, 640)

    def test_respects_the_divisor_argument(self, gui_app: QApplication) -> None:
        pixmap = to_display_pixmap(make_image(1024, 1280), 4)
        assert (pixmap.width(), pixmap.height()) == (256, 320)

    def test_never_produces_a_zero_sized_pixmap(self, gui_app: QApplication) -> None:
        pixmap = to_display_pixmap(make_image(1, 1))
        assert (pixmap.width(), pixmap.height()) == (1, 1)

    def test_converts_bgr_to_rgb(self, gui_app: QApplication) -> None:
        """BGR(0, 0, 255) は赤として表示される."""
        pixmap = to_display_pixmap(make_image(64, 64, (0, 0, 255)))
        color = pixmap.toImage().pixelColor(10, 10)
        assert (color.red(), color.green(), color.blue()) == (255, 0, 0)

    def test_does_not_alias_the_source_buffer(self, gui_app: QApplication) -> None:
        """元配列を書き換えても QPixmap の内容は変わらない(バッファ寿命に依存しない)."""
        image = make_image(64, 64, (0, 0, 255))
        pixmap = to_display_pixmap(image)
        image[:, :] = (255, 255, 255)
        color = pixmap.toImage().pixelColor(10, 10)
        assert (color.red(), color.green(), color.blue()) == (255, 0, 0)


# --- render_result_panel(描画の成立確認のみ.文字の見た目は人間が確認する) ---


class TestRenderResultPanel:
    """結果パネルの描画.

    ※ フォントの有無が環境で異なるため,ピクセル比較はしない(testing.md「GUI の自動テストは
    書かない」方針に沿い,描画が成立することのみ確認する).
    """

    @pytest.mark.parametrize("language", list(Language))
    def test_returns_a_panel_sized_pixmap(
        self, gui_app: QApplication, sample_result: DiagnosisResult, language: Language
    ) -> None:
        rows = build_result_rows(sample_result, language)
        pixmap = render_result_panel(rows, texts_for(language), sample_result.failed)
        assert not pixmap.isNull()
        assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE)

    def test_failed_panel_is_rendered(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        rows = build_result_rows(sample_result, Language.ENGLISH)
        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), failed=True)
        assert not pixmap.isNull()
        assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE)

    def test_failure_band_is_filled_only_when_failed(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        """失敗時のみ帯の領域が塗られる(文字ではなく矩形なのでフォント非依存).

        塗り色は旧実装 `cv::Scalar(200, 0, 0)`(BGR)= RGB (0, 0, 200) の青.
        """
        rows = build_result_rows(sample_result, Language.ENGLISH)
        texts = texts_for(Language.ENGLISH)
        # 帯の内側かつ下線・文字が来ない位置を見る
        sample_point = (FAILURE_RECT[0] + 5, FAILURE_RECT[1] + 5)
        band_color = (0, 0, 200)

        failed_color = (
            render_result_panel(rows, texts, failed=True).toImage().pixelColor(*sample_point)
        )
        assert (failed_color.red(), failed_color.green(), failed_color.blue()) == band_color

        normal_color = (
            render_result_panel(rows, texts, failed=False).toImage().pixelColor(*sample_point)
        )
        assert (normal_color.red(), normal_color.green(), normal_color.blue()) != band_color

    def test_returns_a_new_pixmap_each_time(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        """言語切替で描き直せるよう毎回生成する(同一オブジェクトを使い回さない)."""
        rows = build_result_rows(sample_result, Language.JAPANESE)
        texts = texts_for(Language.JAPANESE)
        first = render_result_panel(rows, texts, failed=False)
        second = render_result_panel(rows, texts, failed=False)
        assert first is not second

    def test_renders_without_highlighted_rows(
        self, gui_app: QApplication, sample_result: DiagnosisResult
    ) -> None:
        """強調行が無い(スコア・下線を描かない)場合も描画できる."""
        rows = tuple(
            type(row)(
                key=row.key,
                name=row.name,
                description=row.description,
                swatch=row.swatch,
                highlighted=False,
                score_text=None,
            )
            for row in build_result_rows(sample_result, Language.ENGLISH)
        )
        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), failed=False)
        assert not pixmap.isNull()


# --- 結合(core.diagnose → 表示行) ---


class TestIntegrationWithDiagnose:
    """実データ(`data/data.csv`)で診断した結果を表示行に変換する."""

    def test_rows_are_consistent_with_the_diagnosis(
        self, gui_app: QApplication, training_data: TrainingData
    ) -> None:
        result = diagnose(rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()), training_data)
        rows = build_result_rows(result, Language.JAPANESE)

        assert tuple(row.key for row in rows) == DISPLAY_ORDER
        assert {row.key for row in rows if row.highlighted} == set(result.top_labels)
        for row in rows:
            if row.highlighted:
                assert row.score_text is not None
                assert int(row.score_text) == result.percentages[row.key]

    def test_panel_renders_for_a_real_diagnosis(
        self, gui_app: QApplication, training_data: TrainingData
    ) -> None:
        result = diagnose(rgb_to_lab((180.0, 110.0, 110.0), _linear_coefficients()), training_data)
        rows = build_result_rows(result, Language.ENGLISH)
        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), result.failed)
        assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE)

    def test_failed_diagnosis_rows_and_panel(
        self, gui_app: QApplication, training_data: TrainingData
    ) -> None:
        """診断失敗(L* <= 0 の域外入力)でも行の組み立てと描画ができる."""
        result = diagnose((-16.0, 0.0, 0.0), training_data)
        assert result.failed is True

        rows = build_result_rows(result, Language.ENGLISH)
        assert tuple(row.key for row in rows) == DISPLAY_ORDER
        assert len({row.key for row in rows if row.highlighted}) == HIGHLIGHT_COUNT

        pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), result.failed)
        assert not pixmap.isNull()