"""`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)