"""結果画面の 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()