Newer
Older
TIASDataHub / tests / conftest.py
"""テスト共通の fixture(ダミーデータのみ.臨床データは使わない)."""

import sqlite3
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path

import pytest
from PIL import Image

from tiasdata import catalog

# 舌色データセットの 9 フォルダ(フォルダ名 → label・severity).
# TIASJudgeModel の TEST_01「フォルダ構成とラベル対応」と同じ.フォルダの直下に <番号>.png を置く
TONGUE_COLOR_FOLDERS: dict[str, tuple[str, str]] = {
    "淡紅(正常)": ("light_red", "normal"),
    "暗赤紅_あり": ("dark_red", "strong"),
    "暗赤紅_ややあり": ("dark_red", "mild"),
    "紫_ややあり": ("purple", "mild"),
    "紅_あり": ("red", "strong"),
    "紅_ややあり": ("red", "mild"),
    "紫_あり": ("purple", "strong"),
    "淡白_あり": ("pale", "strong"),
    "淡白_ややあり": ("pale", "mild"),
}


def write_dummy_png(path: Path, seed: int, size: tuple[int, int] = (8, 6)) -> Path:
    """`seed` ごとに中身の違う小さい PNG を書く(同じ `seed` なら同じバイト列)."""
    path.parent.mkdir(parents=True, exist_ok=True)
    color = (seed * 37 % 256, seed * 59 % 256, seed * 83 % 256)
    image = Image.new("RGB", size, color)
    # 色だけだと seed が 256 周期で重なるため,1 画素に seed を埋めて中身を一意にする
    image.putpixel((0, 0), (seed % 256, seed // 256 % 256, seed // 65536 % 256))
    image.save(path, format="PNG")
    return path


@dataclass(frozen=True)
class DummyImage:
    """ダミーの取り込み元の画像 1 枚."""

    number: int
    folder: str
    label: str
    severity: str
    path: Path


@dataclass(frozen=True)
class DummyTongueColorSource:
    """ダミーの舌色データセット(9 フォルダ × 数枚)."""

    root: Path
    images: list[DummyImage]


@pytest.fixture
def make_png():
    """小さい PNG を書く関数(`make_png(path, seed)`)."""
    return write_dummy_png


@pytest.fixture
def store_dir(tmp_path: Path) -> Path:
    """空のストアのフォルダ(`tiasdata init` 後と同じ状態)."""
    directory = tmp_path / "tiasdata" / "store"
    directory.mkdir(parents=True)
    return directory


@pytest.fixture
def tongue_color_source(tmp_path: Path) -> DummyTongueColorSource:
    """舌色データセットと同じ構成のダミーの取り込み元(各フォルダ 2 枚・番号は 1 から連番).

    対応表(xlsx)は `tongue_color_patient_map` で作る.
    """
    root = tmp_path / "source" / "TIASJudgeModel"
    images: list[DummyImage] = []
    number = 1
    for folder, (label, severity) in TONGUE_COLOR_FOLDERS.items():
        for _ in range(2):
            path = write_dummy_png(root / folder / f"{number}.png", seed=number)
            images.append(DummyImage(number, folder, label, severity, path))
            number += 1
    return DummyTongueColorSource(root=root, images=images)


def dummy_patient_key(number: int) -> str:
    """ダミーの取り込み元の画像の番号 → 架空の患者 ID.

    7 人に割り振るので,18 枚のダミーでは同じ患者に 2〜3 枚が当たる(ラベルの違う画像も混ざる).
    値は架空(99 で始まる 6 桁).
    """
    return str(990001 + (number - 1) % 7)


def write_patient_map(
    path: Path,
    rows: list[tuple[object, ...]],
    *,
    header: tuple[object, ...] = ("番号", "何枚目", "患者ID"),
) -> Path:
    """対応表の xlsx を書く.

    1 行目は見出し,以降は `rows`(列は 1: 番号・2: 何枚目・3: 患者 ID).
    """
    from openpyxl import Workbook

    path.parent.mkdir(parents=True, exist_ok=True)
    workbook = Workbook()
    sheet = workbook.active
    sheet.append(list(header))
    for row in rows:
        sheet.append(list(row))
    workbook.save(path)
    return path


@dataclass(frozen=True)
class DummyPatientMap:
    """ダミーの対応表(xlsx)."""

    path: Path
    # 番号 → 架空の患者 ID(`dummy_patient_key` と同じ)
    patient_keys: dict[int, str]


@pytest.fixture
def make_patient_map():
    """対応表の xlsx を書く関数(`make_patient_map(path, rows, header=...)`)."""
    return write_patient_map


@pytest.fixture
def tongue_color_patient_map(
    tmp_path: Path, tongue_color_source: DummyTongueColorSource
) -> DummyPatientMap:
    """`tongue_color_source` の全画像の対応表(架空の患者 ID.同じ患者に複数枚).

    実データと同じく,患者 ID のセルは数値の行と文字列の行を混ぜる(偶数の番号は文字列).
    """
    rows: list[tuple[object, ...]] = []
    patient_keys: dict[int, str] = {}
    seen: dict[str, int] = {}
    for image in tongue_color_source.images:
        key = dummy_patient_key(image.number)
        patient_keys[image.number] = key
        seen[key] = seen.get(key, 0) + 1
        cell: object = key if image.number % 2 == 0 else int(key)
        rows.append((image.number, seen[key], cell))
    path = write_patient_map(tmp_path / "source" / "patient_map.xlsx", rows)
    return DummyPatientMap(path=path, patient_keys=patient_keys)


def write_reviews(reviews_dir: Path, source_name: str, numbers: list[int]) -> Path:
    """要確認リスト `<reviews_dir>/<source_name>.toml` を書く."""
    reviews_dir.mkdir(parents=True, exist_ok=True)
    entries = "".join(
        f'[[review]]\nnumber = {number}\nreason = "テスト用の要確認"\n'
        f'resolve = "テスト用"\nsince = "2026-10-06"\n\n'
        for number in numbers
    )
    path = reviews_dir / f"{source_name}.toml"
    path.write_text(entries, encoding="utf-8")
    return path


@pytest.fixture
def reviews_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """一時フォルダの要確認リストのフォルダ(リポジトリの `reviews/` の代わりに使わせる).

    `tiasdata.importers.reviews.default_reviews_dir` を差し替えるので,同じプロセスで呼ぶ
    `run_import`・`cli.main` はこのフォルダを読む(subprocess で起動した CLI には効かない).
    要確認リストは `write_reviews(reviews_dir, <取り込み元の名前>, [番号, ...])` で書く
    (要確認リストのファイルが無い取り込み元の取り込みは停止する.要確認が無いなら `[]` で書く).
    """
    from tiasdata.importers import reviews

    directory = tmp_path / "reviews"
    directory.mkdir()
    monkeypatch.setattr(reviews, "default_reviews_dir", lambda: directory)
    return directory


@dataclass(frozen=True)
class ImportEnv:
    """取り込みを試すための一時の設定ファイル・取り込み元・対応表一式."""

    config_path: Path
    source_name: str
    source: DummyTongueColorSource
    patient_map: DummyPatientMap
    reviews_dir: Path
    catalog_root: Path
    identity_path: Path


@pytest.fixture
def import_env(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    tongue_color_source: DummyTongueColorSource,
    tongue_color_patient_map: DummyPatientMap,
    reviews_dir: Path,
) -> ImportEnv:
    """ダミーの取り込み元 `dummy_tongue` を設定した設定ファイル(`TIASDATA_CONFIG` で指す).

    `tiasdata init` はまだ実行していない.要確認リストは空(`[[review]]` が 0 件のファイル.
    要確認リストのファイルが無いと取り込みは停止する).
    """
    source_name = "dummy_tongue"
    write_reviews(reviews_dir, source_name, [])
    catalog_root = tmp_path / "data" / "tiasdata"
    identity_path = tmp_path / "data" / "identity" / "identity.sqlite"
    config_path = tmp_path / "conf" / "config.toml"
    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(
        "[catalog]\n"
        f"root = '{catalog_root.as_posix()}'\n"
        f"identity = '{identity_path.as_posix()}'\n\n"
        f"[sources.{source_name}]\n"
        f"path = '{tongue_color_source.root.as_posix()}'\n"
        'importer = "tongue_color_folders"\n'
        f"patient_map = '{tongue_color_patient_map.path.as_posix()}'\n",
        encoding="utf-8",
    )
    monkeypatch.setenv("TIASDATA_CONFIG", str(config_path))
    return ImportEnv(
        config_path=config_path,
        source_name=source_name,
        source=tongue_color_source,
        patient_map=tongue_color_patient_map,
        reviews_dir=reviews_dir,
        catalog_root=catalog_root,
        identity_path=identity_path,
    )


@pytest.fixture(autouse=True)
def _isolate_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
    # 開発者の環境変数が混ざらないようにする(テストごとに必要なら設定する)
    monkeypatch.delenv("TIASDATA_CONFIG", raising=False)


@pytest.fixture
def catalog_conn(tmp_path: Path) -> Iterator[sqlite3.Connection]:
    """マイグレーション適用済みの目録(tmp_path に作る)."""
    conn = catalog.connect(tmp_path / "catalog.sqlite", create=True)
    catalog.apply_migrations(conn, "catalog")
    try:
        yield conn
    finally:
        conn.close()


@pytest.fixture
def identity_conn(tmp_path: Path) -> Iterator[sqlite3.Connection]:
    """マイグレーション適用済みの対応表(tmp_path に作る)."""
    conn = catalog.connect(tmp_path / "identity.sqlite", create=True)
    catalog.apply_migrations(conn, "identity")
    try:
        yield conn
    finally:
        conn.close()


@dataclass
class DatasetsEnv:
    """データセットの定義のフォルダと,差し替えた本リポジトリの git の状態.

    `repo_state` を書き換えると,以後の `tiasdata dataset freeze` が読む状態が変わる.
    """

    datasets_dir: Path
    repo_state: object


@pytest.fixture
def datasets_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DatasetsEnv:
    """一時フォルダの `datasets/`(リポジトリの `datasets/tongue_color.toml` を複製)と,
    未コミットの変更が無い git の状態を差し替えた環境.

    `tiasdata.datasets.default_datasets_dir`・`tiasdata.repo.read_repo_state` を差し替えるので,
    同じプロセスで呼ぶ `cli.main` に効く(subprocess で起動した CLI には効かない).
    """
    from tiasdata import datasets, repo

    directory = tmp_path / "datasets"
    directory.mkdir()
    source = Path(__file__).resolve().parents[1] / "datasets" / "tongue_color.toml"
    (directory / "tongue_color.toml").write_bytes(source.read_bytes())
    env = DatasetsEnv(
        datasets_dir=directory,
        repo_state=repo.RepoState(commit="0123456789abcdef0123456789abcdef01234567", uncommitted=0),
    )
    monkeypatch.setattr(datasets, "default_datasets_dir", lambda: directory)
    monkeypatch.setattr(repo, "read_repo_state", lambda root=None: env.repo_state)
    return env