Newer
Older
TIASjudge / tests / core / test_csv_io.py
"""`core/csv_io.py` の検証(SPEC_02「データファイル形式」・PLAN_02 ステップ 2).

仕様上の要件は以下.

- 変換行列 CSV: 17 行 × 3 列(ヘッダなし).各行が `a[i], b[i], c[i]`(X, Y, Z 用係数)
- `data.csv`: 1 行目ヘッダ,2 行目以降 84 サンプル.
  列 1〜3 が L\\*a\\*b\\*,列 4〜193 が医師 38 名 × 5 色の 0/1(各医師 5 列の one-hot).
  実ファイルの 194 列目以降(全 0 の未使用ブロック)は診断に使わない
- 不正形式・欠損ファイルでは `core` の専用例外を送出する(error-handling.md,PLAN_02 完了条件)
"""

from pathlib import Path

import numpy as np
import pytest

from tiasjudge.core.csv_io import (
    COLOR_COUNT,
    DOCTOR_COUNT,
    EVALUATION_COLUMN_COUNT,
    LAB_DIMENSION,
    SAMPLE_COUNT,
    TRAINING_COLUMN_COUNT,
    TRANSFORM_COEFFICIENT_COUNT,
    CsvIoError,
    DataFileNotFoundError,
    DataFormatError,
    TrainingData,
    load_training_data,
    load_transform_matrix,
)

COLOR_LABELS = ["紫", "暗赤", "紅", "淡紅", "淡白"]
TRANSFORM_COLUMN_COUNT = 3
TOTAL_TRAINING_ROW_COUNT = SAMPLE_COUNT + 1  # ヘッダ 1 行 + 84 サンプル


# --- テスト用 CSV の生成ヘルパー ---


def write_csv(path: Path, rows: list[list[str]], encoding: str = "utf-8") -> Path:
    """行のリストを CSV として書き出す."""
    text = "\n".join(",".join(row) for row in rows) + "\n"
    path.write_bytes(text.encode(encoding))
    return path


def transform_rows(
    row_count: int = TRANSFORM_COEFFICIENT_COUNT, column_count: int = TRANSFORM_COLUMN_COUNT
) -> list[list[str]]:
    """変換行列 CSV の行(値は `行番号.列番号` で一意になるようにする)."""
    return [
        [f"{row_index}.{column_index}" for column_index in range(column_count)]
        for row_index in range(row_count)
    ]


def training_header(doctor_count: int = DOCTOR_COUNT) -> list[str]:
    return ["L", "a", "b"] + COLOR_LABELS * doctor_count


def training_sample_row(
    sample_index: int, doctor_count: int = DOCTOR_COUNT, evaluation_columns: int | None = None
) -> list[str]:
    """1 サンプル分の行を作る.医師 d の評価色は `(sample_index + d) % 5` とする."""
    lab = [f"{50 + sample_index}", f"{sample_index}.5", f"{-sample_index}.25"]
    evaluations: list[str] = []
    for doctor_index in range(doctor_count):
        color_index = (sample_index + doctor_index) % COLOR_COUNT
        evaluations += ["1" if index == color_index else "0" for index in range(COLOR_COUNT)]
    if evaluation_columns is not None:
        evaluations = (evaluations + ["0"] * evaluation_columns)[:evaluation_columns]
    return lab + evaluations


def training_rows(
    sample_count: int = SAMPLE_COUNT,
    doctor_count: int = DOCTOR_COUNT,
    evaluation_columns: int | None = None,
) -> list[list[str]]:
    rows = [training_header(doctor_count)]
    rows += [
        training_sample_row(index, doctor_count, evaluation_columns)
        for index in range(sample_count)
    ]
    return rows


@pytest.fixture
def valid_training_csv(tmp_path: Path) -> Path:
    return write_csv(tmp_path / "data.csv", training_rows())


@pytest.fixture(scope="module")
def real_data(data_csv_path: Path) -> TrainingData:
    """同梱 `data/data.csv` を読み込んだ結果."""
    return load_training_data(data_csv_path)


class TestLoadTransformMatrixNormal:
    """変換行列 CSV の正常系(SPEC_02「変換行列 CSV」)."""

    def test_returns_17_by_3_matrix(self, tmp_path: Path) -> None:
        matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows()))
        assert matrix.shape == (TRANSFORM_COEFFICIENT_COUNT, TRANSFORM_COLUMN_COUNT)

    def test_values_are_read_in_row_major_order(self, tmp_path: Path) -> None:
        """各行が `a[i], b[i], c[i]`.列 0=a(X 用),列 1=b(Y 用),列 2=c(Z 用)."""
        matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows()))
        for row_index in range(TRANSFORM_COEFFICIENT_COUNT):
            for column_index in range(TRANSFORM_COLUMN_COUNT):
                expected = float(f"{row_index}.{column_index}")
                assert matrix[row_index, column_index] == pytest.approx(expected)

    def test_is_floating_point(self, tmp_path: Path) -> None:
        matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows()))
        assert np.issubdtype(matrix.dtype, np.floating)

    def test_accepts_negative_and_scientific_notation(self, tmp_path: Path) -> None:
        """キャリブレーション結果は負値・指数表記を含みうる."""
        rows = [["-1.5", "2.5e-3", "+0.25"] for _ in range(TRANSFORM_COEFFICIENT_COUNT)]
        matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows))
        assert matrix[0].tolist() == pytest.approx([-1.5, 0.0025, 0.25])

    def test_accepts_string_path(self, tmp_path: Path) -> None:
        """`core` はパスを引数で受け取る(str でも Path でも良い)."""
        path = write_csv(tmp_path / "matrix.csv", transform_rows())
        assert load_transform_matrix(str(path)).shape == (TRANSFORM_COEFFICIENT_COUNT, 3)

    def test_precision_is_not_truncated(self, tmp_path: Path) -> None:
        """係数は多項式変換に直接使うため丸めずに保持する."""
        rows = [["0.123456789012345", "1", "2"] for _ in range(TRANSFORM_COEFFICIENT_COUNT)]
        matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows))
        assert matrix[0, 0] == pytest.approx(0.123456789012345, abs=1e-15)


class TestLoadTransformMatrixErrors:
    """変換行列 CSV の異常系(不正形式で専用例外)."""

    @pytest.mark.parametrize("row_count", [0, 1, 16, 18, 34])
    def test_row_count_other_than_17_is_rejected(self, tmp_path: Path, row_count: int) -> None:
        path = write_csv(tmp_path / "matrix.csv", transform_rows(row_count=row_count) or [[""]])
        with pytest.raises(DataFormatError):
            load_transform_matrix(path)

    @pytest.mark.parametrize("column_count", [1, 2, 4])
    def test_column_count_other_than_3_is_rejected(self, tmp_path: Path, column_count: int) -> None:
        path = write_csv(tmp_path / "matrix.csv", transform_rows(column_count=column_count))
        with pytest.raises(DataFormatError):
            load_transform_matrix(path)

    def test_ragged_row_is_rejected(self, tmp_path: Path) -> None:
        rows = transform_rows()
        rows[5] = ["1", "2"]
        with pytest.raises(DataFormatError):
            load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows))

    def test_non_numeric_cell_is_rejected(self, tmp_path: Path) -> None:
        rows = transform_rows()
        rows[3][1] = "abc"
        with pytest.raises(DataFormatError):
            load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows))

    def test_empty_cell_is_rejected(self, tmp_path: Path) -> None:
        rows = transform_rows()
        rows[0][2] = ""
        with pytest.raises(DataFormatError):
            load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows))

    def test_header_row_is_rejected(self, tmp_path: Path) -> None:
        """変換行列 CSV はヘッダなしのため,ヘッダ付きは形式エラー."""
        rows = [["a", "b", "c"], *transform_rows()]
        with pytest.raises(DataFormatError):
            load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows))

    def test_missing_file_raises_not_found(self, tmp_path: Path) -> None:
        with pytest.raises(DataFileNotFoundError):
            load_transform_matrix(tmp_path / "missing.csv")

    def test_directory_raises_not_found(self, tmp_path: Path) -> None:
        directory = tmp_path / "dir.csv"
        directory.mkdir()
        with pytest.raises(DataFileNotFoundError):
            load_transform_matrix(directory)

    def test_undecodable_bytes_raise_format_error(self, tmp_path: Path) -> None:
        path = tmp_path / "matrix.csv"
        path.write_bytes(b"\x80\x81\n" * TRANSFORM_COEFFICIENT_COUNT)
        with pytest.raises(DataFormatError):
            load_transform_matrix(path)

    def test_errors_share_a_common_base(self, tmp_path: Path) -> None:
        """GUI 側が 1 つの基底例外で捕捉できる(error-handling.md)."""
        assert issubclass(DataFileNotFoundError, CsvIoError)
        assert issubclass(DataFormatError, CsvIoError)
        with pytest.raises(CsvIoError):
            load_transform_matrix(tmp_path / "missing.csv")


class TestLoadTrainingDataNormal:
    """`data.csv` の正常系(生成したデータ)."""

    def test_shapes_follow_spec(self, valid_training_csv: Path) -> None:
        data = load_training_data(valid_training_csv)
        assert data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION)
        assert data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT)

    def test_counts_follow_spec(self, valid_training_csv: Path) -> None:
        data = load_training_data(valid_training_csv)
        assert data.sample_count == SAMPLE_COUNT
        assert data.output_dimension == EVALUATION_COLUMN_COUNT == 190
        assert data.doctor_count == DOCTOR_COUNT == 38

    def test_lab_values_come_from_first_three_columns(self, valid_training_csv: Path) -> None:
        data = load_training_data(valid_training_csv)
        for index in range(SAMPLE_COUNT):
            expected = [float(cell) for cell in training_sample_row(index)[:LAB_DIMENSION]]
            assert data.lab[index].tolist() == pytest.approx(expected)

    def test_evaluation_columns_come_from_columns_4_to_193(self, valid_training_csv: Path) -> None:
        data = load_training_data(valid_training_csv)
        for index in range(SAMPLE_COUNT):
            expected = [int(cell) for cell in training_sample_row(index)[LAB_DIMENSION:]]
            assert data.evaluations[index].tolist() == expected

    def test_evaluation_layout_is_doctor_major(self, valid_training_csv: Path) -> None:
        """列 `doctor_index * 5 + color_index` が医師ごとの one-hot に対応する."""
        data = load_training_data(valid_training_csv)
        for sample_index in range(SAMPLE_COUNT):
            for doctor_index in range(DOCTOR_COUNT):
                block = data.evaluations[
                    sample_index,
                    doctor_index * COLOR_COUNT : (doctor_index + 1) * COLOR_COUNT,
                ]
                assert block.sum() == 1
                expected_color = (sample_index + doctor_index) % COLOR_COUNT
                assert int(block.argmax()) == expected_color

    def test_evaluations_are_binary(self, valid_training_csv: Path) -> None:
        data = load_training_data(valid_training_csv)
        assert set(np.unique(data.evaluations).tolist()) <= {0, 1}

    def test_color_columns_returns_all_doctors_for_one_color(
        self, valid_training_csv: Path
    ) -> None:
        data = load_training_data(valid_training_csv)
        for color_index in range(COLOR_COUNT):
            columns = data.color_columns(color_index)
            assert columns.shape == (SAMPLE_COUNT, DOCTOR_COUNT)
            expected = data.evaluations[:, color_index::COLOR_COUNT]
            assert np.array_equal(columns, expected)

    def test_color_columns_matches_generated_one_hot(self, valid_training_csv: Path) -> None:
        data = load_training_data(valid_training_csv)
        for color_index in range(COLOR_COUNT):
            columns = data.color_columns(color_index)
            for sample_index in range(SAMPLE_COUNT):
                for doctor_index in range(DOCTOR_COUNT):
                    expected = int((sample_index + doctor_index) % COLOR_COUNT == color_index)
                    assert columns[sample_index, doctor_index] == expected

    @pytest.mark.parametrize("color_index", [-1, COLOR_COUNT, 100])
    def test_color_columns_rejects_out_of_range(
        self, valid_training_csv: Path, color_index: int
    ) -> None:
        data = load_training_data(valid_training_csv)
        with pytest.raises(ValueError):
            data.color_columns(color_index)

    def test_extra_trailing_columns_are_ignored(self, tmp_path: Path) -> None:
        """実ファイルの 194 列目以降(未使用ブロック)は読み飛ばす(SPEC_02 注記)."""
        rows = training_rows(doctor_count=39)
        path = write_csv(tmp_path / "data.csv", rows)
        data = load_training_data(path)
        assert data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT)
        expected = [int(cell) for cell in training_sample_row(0, doctor_count=39)[LAB_DIMENSION:]]
        assert data.evaluations[0].tolist() == expected[:EVALUATION_COLUMN_COUNT]

    def test_accepts_cp932_encoded_file(self, tmp_path: Path) -> None:
        """旧実装由来のファイル(Shift-JIS ヘッダ)も読める."""
        path = write_csv(tmp_path / "data_sjis.csv", training_rows(), encoding="cp932")
        data = load_training_data(path)
        assert data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION)

    def test_result_is_immutable_container(self, valid_training_csv: Path) -> None:
        data = load_training_data(valid_training_csv)
        assert isinstance(data, TrainingData)
        with pytest.raises(Exception):  # noqa: B017 - frozen dataclass の再代入禁止
            data.lab = np.zeros((SAMPLE_COUNT, LAB_DIMENSION), dtype=np.float32)  # type: ignore[misc]


class TestLoadTrainingDataErrors:
    """`data.csv` の異常系."""

    @pytest.mark.parametrize("sample_count", [0, 1, 83, 85])
    def test_sample_count_other_than_84_is_rejected(
        self, tmp_path: Path, sample_count: int
    ) -> None:
        path = write_csv(tmp_path / "data.csv", training_rows(sample_count=sample_count))
        with pytest.raises(DataFormatError):
            load_training_data(path)

    def test_missing_header_is_rejected(self, tmp_path: Path) -> None:
        """ヘッダ 1 行 + 84 サンプルの計 85 行を要求する."""
        rows = training_rows()[1:]
        with pytest.raises(DataFormatError):
            load_training_data(write_csv(tmp_path / "data.csv", rows))

    @pytest.mark.parametrize("doctor_count", [0, 1, 37])
    def test_insufficient_evaluation_columns_are_rejected(
        self, tmp_path: Path, doctor_count: int
    ) -> None:
        path = write_csv(tmp_path / "data.csv", training_rows(doctor_count=doctor_count))
        with pytest.raises(DataFormatError):
            load_training_data(path)

    def test_column_short_by_one_is_rejected(self, tmp_path: Path) -> None:
        rows = training_rows(evaluation_columns=EVALUATION_COLUMN_COUNT - 1)
        with pytest.raises(DataFormatError):
            load_training_data(write_csv(tmp_path / "data.csv", rows))

    def test_single_ragged_row_is_rejected(self, tmp_path: Path) -> None:
        rows = training_rows()
        rows[42] = rows[42][: TRAINING_COLUMN_COUNT - 1]
        with pytest.raises(DataFormatError):
            load_training_data(write_csv(tmp_path / "data.csv", rows))

    def test_non_numeric_lab_cell_is_rejected(self, tmp_path: Path) -> None:
        rows = training_rows()
        rows[10][1] = "N/A"
        with pytest.raises(DataFormatError):
            load_training_data(write_csv(tmp_path / "data.csv", rows))

    def test_empty_lab_cell_is_rejected(self, tmp_path: Path) -> None:
        rows = training_rows()
        rows[7][0] = ""
        with pytest.raises(DataFormatError):
            load_training_data(write_csv(tmp_path / "data.csv", rows))

    @pytest.mark.parametrize("value", ["2", "-1", "0.5", "x"])
    def test_evaluation_cell_other_than_0_or_1_is_rejected(
        self, tmp_path: Path, value: str
    ) -> None:
        rows = training_rows()
        rows[20][LAB_DIMENSION + 3] = value
        with pytest.raises(DataFormatError):
            load_training_data(write_csv(tmp_path / "data.csv", rows))

    def test_evaluation_error_in_last_valid_column_is_detected(self, tmp_path: Path) -> None:
        """193 列目(最後の有効列)も検証対象である."""
        rows = training_rows()
        rows[1][TRAINING_COLUMN_COUNT - 1] = "9"
        with pytest.raises(DataFormatError):
            load_training_data(write_csv(tmp_path / "data.csv", rows))

    def test_missing_file_raises_not_found(self, tmp_path: Path) -> None:
        with pytest.raises(DataFileNotFoundError):
            load_training_data(tmp_path / "missing.csv")

    def test_directory_raises_not_found(self, tmp_path: Path) -> None:
        directory = tmp_path / "data_dir"
        directory.mkdir()
        with pytest.raises(DataFileNotFoundError):
            load_training_data(directory)

    def test_undecodable_bytes_raise_format_error(self, tmp_path: Path) -> None:
        path = tmp_path / "data.csv"
        path.write_bytes(b"\x80\x81,\x80\x81\n" * TOTAL_TRAINING_ROW_COUNT)
        with pytest.raises(DataFormatError):
            load_training_data(path)

    def test_error_message_points_to_the_file(self, tmp_path: Path) -> None:
        """GUI がそのまま提示できるよう,メッセージにファイル名を含める."""
        rows = training_rows()
        rows[3][0] = "abc"
        path = write_csv(tmp_path / "broken.csv", rows)
        with pytest.raises(DataFormatError) as error:
            load_training_data(path)
        assert "broken.csv" in str(error.value)


class TestLoadRealDataCsv:
    """同梱 `data/data.csv` の読込(PLAN_02 ステップ 2 完了条件「実データを読める」)."""

    def test_shapes(self, real_data: TrainingData) -> None:
        assert real_data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION)
        assert real_data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT)
        assert real_data.doctor_count == DOCTOR_COUNT

    def test_lab_matches_file_content(
        self, real_data: TrainingData, data_csv_rows: list[list[str]]
    ) -> None:
        for index, row in enumerate(data_csv_rows[1:]):
            expected = [float(cell) for cell in row[:LAB_DIMENSION]]
            assert real_data.lab[index].tolist() == pytest.approx(expected, rel=1e-6)

    def test_evaluations_match_first_190_evaluation_columns(
        self, real_data: TrainingData, data_csv_rows: list[list[str]]
    ) -> None:
        for index, row in enumerate(data_csv_rows[1:]):
            expected = [int(cell) for cell in row[LAB_DIMENSION:TRAINING_COLUMN_COUNT]]
            assert real_data.evaluations[index].tolist() == expected

    def test_evaluations_are_binary(self, real_data: TrainingData) -> None:
        assert set(np.unique(real_data.evaluations).tolist()) <= {0, 1}

    def test_each_doctor_block_is_one_hot_or_all_zero(self, real_data: TrainingData) -> None:
        blocks = real_data.evaluations.reshape(SAMPLE_COUNT, DOCTOR_COUNT, COLOR_COUNT)
        assert set(blocks.sum(axis=2).ravel().tolist()) <= {0, 1}

    def test_every_color_has_positive_examples(self, real_data: TrainingData) -> None:
        """5 色すべてに正例がある(SPEC_02 (3) の学習成立の前提)."""
        for color_index in range(COLOR_COUNT):
            assert real_data.color_columns(color_index).sum() > 0

    def test_lab_is_within_valid_range(self, real_data: TrainingData) -> None:
        assert real_data.lab[:, 0].min() >= 0.0
        assert real_data.lab[:, 0].max() <= 100.0
        assert np.abs(real_data.lab[:, 1:]).max() <= 128.0

    def test_unused_trailing_block_is_excluded(self, data_csv_rows: list[list[str]]) -> None:
        """194 列目以降(全 0 の未使用ブロック)が読み込み対象外であること."""
        assert len(data_csv_rows[0]) > TRAINING_COLUMN_COUNT, "実ファイルは 193 列より多い前提"
        trailing = [int(cell) for row in data_csv_rows[1:] for cell in row[TRAINING_COLUMN_COUNT:]]
        assert set(trailing) == {0}, "無視する末尾列に非 0 の評価が含まれる"

    def test_repeated_load_is_deterministic(self, data_csv_path: Path) -> None:
        first = load_training_data(data_csv_path)
        second = load_training_data(data_csv_path)
        assert np.array_equal(first.lab, second.lab)
        assert np.array_equal(first.evaluations, second.evaluations)