P1: file parsers, sample sheet, discovery, CLI inspect

Readers for IDAT v3, BPM (locus entries v4-v8), CSV manifest, EGT
(data blocks 5-9) and GTC v3-v5 (plus a layout-preserving GTC writer);
sample-sheet validation (IN-001..IN-014), IDAT discovery and pairing
(IN-020..IN-022), message catalogue and exit codes (CLI-014/016),
JSON Lines logging (CLI-007/008), atomic writes (CLI-011), run manifest,
and `simurg inspect`.

Validation (docs/validation/P1.md): GTC, BPM and EGT readers agree
exactly with Illumina BeadArrayFiles on GSA v4 / GDA v1 files; GTCs
round-trip byte-identically; BPM and CSV manifests are consistent.
This commit is contained in:
kemal committed 2026-09-27 16:56:39 +01:00
1 parent 705138d70b
commit e518ea03d5
40 files changed
+4742

No files matched your search

View File
Whitespace-only changes.
+45
View File
@@ -0,0 +1,45 @@
"""Shared test configuration.
Tests marked ``data`` need files fetched by ``scripts/fetch_data.sh p0``; tests marked ``oracle`` need
reference repositories under ``refs/`` (``scripts/bootstrap_refs.sh``). Both are skipped when absent.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data" / "p0"
REFS = ROOT / "refs"
GSA = DATA / "gsa_v4_pgx"
GSA_BPM = GSA / "GSA-PGx-48v4-0_20079540_E2_bpm" / "GSA-PGx-48v4-0_20079540_E2.bpm"
GSA_CSV = GSA / "GSA-PGx-48v4-0_20079540_E2_csv" / "GSA-PGx-48v4-0_20079540_E2.csv"
GSA_EGT = GSA / "GSA-PGx-48v4-0_20079540_E2_ClusterFile" / "GSA-PGx-48v4-0_20079540_E2_ClusterFile.egt"
GSA_IDATS = GSA / "idats_Demo_36" / "idats_Demo_36"
GDA_EGT = DATA / "gda_v1" / "infinium-global-diversity-array-8-v1-0-D1-cluster-file" / "GDA-8v1-0_D1_ClusterFile.egt"
GDA_BPM = DATA / "gda_v1" / "infinium-global-diversity-array-8-v1-0-D1-manifest-file-bpm" / "GDA-8v1-0_D1.bpm"
GDA_SHEET = DATA / "gda_v1" / "infinium-global-diversity-array-8-v1-0_D1_SampleSheet_Demo_36.csv"
BIOC_IDATS = DATA / "bioc" / "IlluminaDataTestFiles_1.50.0" / "IlluminaDataTestFiles" / "inst" / "extdata" / "idat"
ORACLE_GTCS = DATA / "oracle" / "gsa_gtc_gtc2vcf"
def need(*paths: Path) -> None:
missing = [p for p in paths if not p.exists()]
if missing:
pytest.skip(f"missing test data: {missing[0]} (run scripts/fetch_data.sh p0)")
@pytest.fixture(scope="session")
def beadarrayfiles(): # type: ignore[no-untyped-def]
"""Illumina BeadArrayFiles (BSD-2) as an oracle."""
path = REFS / "BeadArrayFiles"
if not (path / "module").is_dir():
pytest.skip("refs/BeadArrayFiles not present (run scripts/bootstrap_refs.sh)")
sys.path.insert(0, str(path))
import module # type: ignore[import-not-found]
return module
+182
View File
@@ -0,0 +1,182 @@
"""Phase P1 acceptance on real files (plan §18 P1): parsers agree field-by-field with Illumina's BeadArrayFiles
(BSD-2) and GTCs round-trip byte-identically. Needs ``scripts/fetch_data.sh p0``; GTC oracles are produced with
gtc2vcf ``idat2gtc`` (see docs/validation/P1.md).
"""
from __future__ import annotations
import numpy as np
import pytest
from tests.conftest import (
BIOC_IDATS,
GDA_EGT,
GDA_SHEET,
GSA_BPM,
GSA_CSV,
GSA_EGT,
GSA_IDATS,
ORACLE_GTCS,
need,
)
from simurg.core.errors import FormatError
from simurg.formats.bpm import read_bpm
from simurg.formats.csv_manifest import compare_manifests, read_csv_manifest
from simurg.formats.discovery import discover_idats
from simurg.formats.egt import read_egt
from simurg.formats.gtc import encode_gtc, read_gtc
from simurg.formats.idat import check_pair, read_idat
from simurg.formats.samplesheet import parse_samplesheet
pytestmark = pytest.mark.data
@pytest.fixture(scope="module")
def gsa_bpm(): # type: ignore[no-untyped-def]
need(GSA_BPM)
return read_bpm(GSA_BPM)
def test_gsa_idats_all_parse_and_pair() -> None:
need(GSA_IDATS)
d = discover_idats(GSA_IDATS)
assert len(d.pairs) == 36 and all(p.complete for p in d.pairs.values())
ref = None
for pair in d.pairs.values():
g, r = read_idat(pair.grn), read_idat(pair.red) # type: ignore[arg-type]
check_pair(g, r)
assert g.barcode == pair.barcode and g.position == pair.position
assert g.scan_time_iso # every demo IDAT has a scan entry
ids = np.sort(g.address_ids)
if ref is None:
ref = ids
assert np.array_equal(ids, ref)
def test_bioc_idats() -> None:
need(BIOC_IDATS)
ok = {
"4019585376_B_Red.idat": 381079,
"4019585376_B_Red.idat.gz": 381079,
"5723646052_R02C02_Grn.idat": 622399,
"5723646052_R02C02_Grn.idat.gz": 622399,
}
for name, n in ok.items():
assert read_idat(BIOC_IDATS / name).n_addresses == n
for name in ("4343238080_A_Grn.idat", "6016741005_A_Grn.idat"):
with pytest.raises(FormatError) as e:
read_idat(BIOC_IDATS / name)
assert e.value.code == "E_IDAT_ENCRYPTED"
def test_idat_addresses_cover_manifest(gsa_bpm) -> None: # type: ignore[no-untyped-def] # precursor of IN-027
need(GSA_IDATS)
pair = next(iter(discover_idats(GSA_IDATS).pairs.values()))
ids = read_idat(pair.grn).address_ids # type: ignore[arg-type]
wanted = np.concatenate([gsa_bpm.address_a, gsa_bpm.address_b[gsa_bpm.address_b != 0]])
assert np.isin(wanted, ids).mean() == 1.0
def test_gtc_matches_beadarrayfiles_and_roundtrips(beadarrayfiles) -> None: # type: ignore[no-untyped-def]
need(ORACLE_GTCS)
files = sorted(ORACLE_GTCS.glob("*.gtc"))
assert files
GenotypeCalls = beadarrayfiles.GenotypeCalls
for f in files:
g = read_gtc(f)
assert encode_gtc(g) == f.read_bytes(), f"round-trip differs: {f}"
b = GenotypeCalls(str(f))
assert np.array_equal(g.raw_x, b.get_raw_x_intensities())
assert np.array_equal(g.raw_y, b.get_raw_y_intensities())
assert g.genotypes.tolist() == list(b.get_genotypes())
assert np.array_equal(g.genotype_scores, b.get_genotype_scores(), equal_nan=True)
assert np.array_equal(g.b_allele_freqs, b.get_ballele_freqs(), equal_nan=True)
assert np.array_equal(g.logr_ratios, b.get_logr_ratios(), equal_nan=True)
assert g.base_call_strings() == [x.decode() for x in b.get_base_calls()]
assert (g.call_rate, g.logr_dev, g.gc10, g.gc50) == (
b.get_call_rate(),
b.get_logr_dev(),
b.get_gc10(),
b.get_gc50(),
)
assert g.gender == b.get_gender()
assert (g.num_calls, g.num_no_calls, g.num_intensity_only) == (
b.get_num_calls(),
b.get_num_no_calls(),
b.get_num_intensity_only(),
)
assert g.percentiles_x.tolist() == list(b.get_percentiles_x())
xf = [
(t.offset_x, t.offset_y, t.scale_x, t.scale_y, t.shear, t.theta) for t in b.get_normalization_transforms()
]
mine = [
tuple(float(x[k]) for k in ("offset_x", "offset_y", "scale_x", "scale_y", "shear", "theta"))
for x in g.normalization_transforms
]
assert mine == [tuple(float(v) for v in t) for t in xf]
def test_gtc_norm_transform_count_matches_bpm_bins(gsa_bpm) -> None: # type: ignore[no-untyped-def]
need(ORACLE_GTCS)
g = read_gtc(sorted(ORACLE_GTCS.glob("*.gtc"))[0])
assert len(g.normalization_transforms) == len(np.unique(gsa_bpm.norm_bin_ids()))
assert g.num_snps == gsa_bpm.num_loci
@pytest.mark.slow
def test_bpm_matches_beadarrayfiles(gsa_bpm, beadarrayfiles) -> None: # type: ignore[no-untyped-def]
b = beadarrayfiles.BeadPoolManifest(str(GSA_BPM))
m = gsa_bpm
assert m.names == b.names
assert m.snps == b.snps
assert m.chroms == b.chroms
assert m.map_infos.tolist() == b.map_infos
assert m.address_a.tolist() == b.addresses
assert m.assay_types.tolist() == b.assay_types
assert m.normalization_lookups().tolist() == b.normalization_lookups
rs = beadarrayfiles.RefStrand
assert [rs.from_string(s) for s in m.ref_strands] == b.ref_strands
assert m.manifest_name == b.manifest_name and m.control_config == b.control_config
@pytest.mark.slow
@pytest.mark.parametrize("egt_path", [GSA_EGT, GDA_EGT], ids=["gsa", "gda"])
def test_egt_matches_beadarrayfiles(egt_path, beadarrayfiles) -> None: # type: ignore[no-untyped-def]
need(egt_path)
e = read_egt(egt_path)
with open(egt_path, "rb") as fh:
b = beadarrayfiles.ClusterFile.read_cluster_file(fh)
assert e.num_records == len(b.records) if hasattr(b, "records") else True
idx = e.name_index()
for name in e.names[:: max(1, e.num_records // 5000)]: # ~5000 evenly spaced records
rec = b.get_record(name)
i = idx[name]
for k, stats in enumerate((rec.aa_cluster_stats, rec.ab_cluster_stats, rec.bb_cluster_stats)):
assert e.n[i, k] == stats.N
assert e.theta_mean[i, k] == np.float32(stats.theta_mean)
assert e.theta_dev[i, k] == np.float32(stats.theta_dev)
assert e.r_mean[i, k] == np.float32(stats.r_mean)
assert e.r_dev[i, k] == np.float32(stats.r_dev)
assert e.intensity_threshold[i] == np.float32(rec.intensity_threshold)
assert e.gentrain_score[i] == np.float32(rec.cluster_score.total_score)
assert e.cluster_sep[i] == np.float32(rec.cluster_score.cluster_separation)
assert e.addresses[i] == rec.address
@pytest.mark.slow
def test_csv_manifest_consistent_with_bpm(gsa_bpm) -> None: # type: ignore[no-untyped-def] # IN-042
need(GSA_CSV)
c = read_csv_manifest(GSA_CSV)
assert compare_manifests(gsa_bpm, c) == []
# Assay type inferred from CSV sequences (fallback for BPM locus entries < v6, gtc2vcf rule) agrees with the
# BPM's explicit value for 99.71% of Infinium I loci on GSA v4; the BPM is authoritative (known_unknowns K-003).
inf1 = gsa_bpm.address_b != 0
agree = (c.assay_types[inf1] == gsa_bpm.assay_types[inf1]).mean()
assert agree >= 0.995
def test_gda_demo_sheet() -> None:
need(GDA_SHEET)
s = parse_samplesheet(GDA_SHEET)
assert len(s.rows) == 36 and s.has_sample_name
assert {r.values["Parent1"] for r in s.rows} >= {"NA12891"}
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import gzip
import numpy as np
import pytest
from hypothesis import given
from hypothesis import strategies as st
from simurg.core.errors import FormatError
from simurg.formats.binutil import BinaryReader, BinaryWriter, load_bytes
@given(st.text(max_size=400))
def test_string_roundtrip(s: str) -> None:
w = BinaryWriter()
w.string(s)
r = BinaryReader(w.getvalue(), "E_GTC_CORRUPT")
assert r.string() == s
assert r.remaining() == 0
@pytest.mark.parametrize("n", [0, 1, 127, 128, 16383, 16384, 300_000])
def test_varint_boundaries(n: int) -> None:
w = BinaryWriter()
w.string(b"x" * n)
r = BinaryReader(w.getvalue(), "E_GTC_CORRUPT")
assert r.varint_length() == n
def test_invalid_utf8_is_replaced_not_raised() -> None: # IN-033, EDGE-002
w = BinaryWriter()
w.string(b"ok\xff\xfe")
assert BinaryReader(w.getvalue(), "E_GTC_CORRUPT").string() == "ok��"
def test_string_past_eof_is_format_error() -> None:
buf = bytes([50]) + b"short"
with pytest.raises(FormatError) as e:
BinaryReader(buf, "E_GTC_CORRUPT", "x.gtc").string()
assert e.value.code == "E_GTC_CORRUPT"
def test_bad_varint_is_format_error() -> None:
with pytest.raises(FormatError):
BinaryReader(b"\xff\xff\xff\xff\xff\xff", "E_BPM_FORMAT").varint_length()
def test_array_bounds_and_seek() -> None:
r = BinaryReader(np.arange(4, dtype="<i4").tobytes(), "E_IDAT_FORMAT")
assert r.array("<i4", 4).tolist() == [0, 1, 2, 3]
r.seek(0)
with pytest.raises(FormatError):
r.array("<i4", 5)
with pytest.raises(FormatError):
r.seek(100)
def test_load_bytes_gzip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "a.bin.gz"
p.write_bytes(gzip.compress(b"hello"))
assert bytes(load_bytes(p)) == b"hello"
q = tmp_path / "b.bin"
q.write_bytes(b"world")
assert bytes(load_bytes(q)) == b"world"
e = tmp_path / "empty.bin"
e.write_bytes(b"")
assert bytes(load_bytes(e)) == b""
+139
View File
@@ -0,0 +1,139 @@
"""BPM and EGT readers on synthetic files (spec IN-040, IN-044; plan §6.2 normalization-bin rule)."""
from __future__ import annotations
import numpy as np
import pytest
from simurg.core.errors import FormatError
from simurg.formats.bpm import infer_assay_type, parse_control_config, read_bpm
from simurg.formats.egt import read_egt
from simurg.synth.files import SynthLocus, encode_bpm, encode_egt
def _loci() -> list[SynthLocus]:
return [
SynthLocus("rs1", "1", 100, "[A/G]", 11, 0, 0, norm_id=1),
SynthLocus("rs2", "2", 200, "[A/T]", 12, 13, 1, norm_id=1, ilmn_strand="BOT", ref_strand="-"),
SynthLocus("rs3", "X", 300, "[C/G]", 14, 15, 2, norm_id=99),
SynthLocus("rs4", "Y", 400, "[T/C]", 16, 0, 0, norm_id=2, intensity_only=1),
SynthLocus("rs5", "MT", 500, "[A/C]", 17, 0, 0, norm_id=99),
]
def test_bpm_roundtrip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(_loci(), control_config="0000000001,Staining,Red,DNP (High)\n"))
m = read_bpm(p)
assert m.names == ["rs1", "rs2", "rs3", "rs4", "rs5"]
assert m.chroms == ["1", "2", "X", "Y", "MT"]
assert m.map_infos.tolist() == [100, 200, 300, 400, 500]
assert m.address_a.tolist() == [11, 12, 14, 16, 17]
assert m.address_b.tolist() == [0, 13, 15, 0, 0]
assert m.assay_types.tolist() == [0, 1, 2, 0, 0]
assert m.intensity_only.tolist() == [False, False, False, True, False]
assert m.ref_strands == ["+", "-", "+", "+", "+"]
assert m.ilmn_strands[1] == "BOT"
assert m.controls == [{"address": "0000000001", "category": "Staining", "color": "Red", "name": "DNP (High)"}]
assert m.header_lines == ["Illumina, Inc."]
def test_norm_bins_mod256_and_rank(tmp_path) -> None: # type: ignore[no-untyped-def]
# rs3: 99 + 100*2 = 299 -> 43 (byte wrap) ; rs2: 1 + 100 = 101
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(_loci()))
m = read_bpm(p)
assert m.norm_bin_ids().tolist() == [1, 101, 43, 2, 99]
# sorted distinct: [1, 2, 43, 99, 101] -> ranks
assert m.normalization_lookups().tolist() == [0, 4, 2, 1, 3]
def test_entries_mapped_by_name(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(_loci(), entry_order=[4, 3, 2, 1, 0]))
m = read_bpm(p)
assert m.map_infos.tolist() == [100, 200, 300, 400, 500]
@pytest.mark.parametrize("entry_version", [4, 6, 7, 8])
def test_entry_versions(tmp_path, entry_version) -> None: # type: ignore[no-untyped-def]
loci = [
SynthLocus("rs1", source_seq="ACGT[A/G]TTGA", probe_a="ACGTA"),
SynthLocus("rs2", address_b=5, assay_type=1, source_seq="AAAT[A/T]AAAC", probe_a="ACGTA", probe_b="ACGTT"),
]
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(loci, entry_version=entry_version))
m = read_bpm(p)
assert m.assay_types.tolist() == [0, 1]
assert (m.base_fractions is not None) == (entry_version >= 7)
assert m.has_ref_strand == (entry_version >= 8)
def test_bpm_errors(tmp_path) -> None: # type: ignore[no-untyped-def]
good = encode_bpm(_loci())
cases = {
"magic": b"XPM" + good[3:],
"truncated": good[: len(good) // 2],
"bad_assay": encode_bpm([SynthLocus("rs1", address_b=0, assay_type=1)]),
"bad_norm": encode_bpm([SynthLocus("rs1", norm_id=150)]),
}
for label, data in cases.items():
p = tmp_path / f"{label}.bpm"
p.write_bytes(data)
with pytest.raises(FormatError) as e:
read_bpm(p)
assert e.value.code == "E_BPM_FORMAT", label
def test_long_manifest_name(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-006
name = "X" * 200 + "_ünicode"
p = tmp_path / "long name (1).bpm"
p.write_bytes(encode_bpm(_loci(), manifest_name=name))
assert read_bpm(p).manifest_name == name
def test_infer_assay_type() -> None:
assert infer_assay_type("ACGT", "", "AC[A/G]TT") == 0
assert infer_assay_type("ACGA", "ACGT", "AAA[A/T]TTT") == 1
assert infer_assay_type("ACGA", "ACGT", "CCC[C/G]GGG") == 2
assert infer_assay_type("", "", "") == 255
def test_control_config_parse() -> None:
txt = "0027630314:0027630314,Staining,Red,DNP (High)\n\nbad line\n1,Hyb,Green,Hyb (High), extra\n"
assert parse_control_config(txt)[1]["name"] == "Hyb (High),extra"
assert len(parse_control_config(txt)) == 2
@pytest.mark.parametrize("dbv", [9, 8, 7, 6, 5, 4])
def test_egt_versions(tmp_path, dbv) -> None: # type: ignore[no-untyped-def]
names = ["rs1", "rs2"]
tm = [[0.05, 0.5, 0.95], [0.1, 0.45, 0.9]]
rm = [[1.0, 1.1, 0.9], [0.8, 0.9, 1.0]]
p = tmp_path / "c.egt"
p.write_bytes(encode_egt(names, theta_mean=tm, r_mean=rm, intensity_threshold=[0.2, 0.0], data_block_version=dbv))
e = read_egt(p)
assert e.names == names
np.testing.assert_allclose(e.theta_mean, tm, rtol=1e-6)
np.testing.assert_allclose(e.r_mean, rm, rtol=1e-6)
assert e.n.tolist() == [[100, 100, 100]] * 2
if dbv >= 7:
assert e.intensity_threshold.tolist()[0] == pytest.approx(0.2)
else:
assert np.isnan(e.intensity_threshold).all()
np.testing.assert_allclose(e.mean_r(), np.mean(rm, axis=1), rtol=1e-6)
def test_egt_count_mismatch(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "c.egt"
p.write_bytes(encode_egt(["rs1"], theta_mean=[[0.1, 0.5, 0.9]], r_mean=[[1, 1, 1]], corrupt_counts=True))
with pytest.raises(FormatError) as e:
read_egt(p)
assert e.value.code == "E_EGT_LOAD"
def test_egt_bad_version(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "c.egt"
p.write_bytes(b"\x04\x00\x00\x00" + b"\x00" * 50)
with pytest.raises(FormatError):
read_egt(p)
+95
View File
@@ -0,0 +1,95 @@
"""CLI behaviour (CLI-007, CLI-014, CLI-020) and the message catalogue (CLI-016)."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
from typer.testing import CliRunner
from simurg.cli.main import app
from simurg.core.errors import CATALOGUE, render_markdown
from simurg.synth.files import SynthIdat, SynthLocus, encode_bpm, encode_egt, encode_idat
ROOT = Path(__file__).resolve().parents[2]
runner = CliRunner()
def _files(tmp_path: Path) -> dict[str, Path]:
idat = tmp_path / "200000000001_R01C01_Grn.idat"
idat.write_bytes(
encode_idat(SynthIdat(address_ids=np.arange(1, 6, dtype=np.int32), mean=np.arange(5, dtype=np.uint16)))
)
bpm = tmp_path / "m.bpm"
bpm.write_bytes(encode_bpm([SynthLocus("rs1"), SynthLocus("rs2", address_a=2)]))
egt = tmp_path / "c.egt"
egt.write_bytes(encode_egt(["rs1"], theta_mean=[[0.1, 0.5, 0.9]], r_mean=[[1, 1, 1]]))
return {"idat": idat, "bpm": bpm, "egt": egt}
def test_version() -> None:
r = runner.invoke(app, ["--version"])
assert r.exit_code == 0 and r.stdout.startswith("SimurgArray ")
def test_inspect_json(tmp_path) -> None: # type: ignore[no-untyped-def]
f = _files(tmp_path)
r = runner.invoke(app, ["inspect", "--json", str(f["idat"]), str(f["bpm"]), str(f["egt"])])
assert r.exit_code == 0, r.output
out = json.loads(r.stdout)
assert [o["kind"] for o in out] == ["idat", "bpm", "egt"]
assert out[1]["num_loci"] == 2 and out[2]["num_records"] == 1
def test_inspect_missing_file_exit2() -> None:
r = runner.invoke(app, ["inspect", "/nonexistent/x.idat"])
assert r.exit_code == 2
assert "E_FILE_NOT_FOUND" in r.output
def test_inspect_partial_failure_exit3(tmp_path) -> None: # type: ignore[no-untyped-def]
f = _files(tmp_path)
bad = tmp_path / "bad.gtc"
bad.write_bytes(b"gtc\x05" + b"\x00" * 3)
r = runner.invoke(app, ["inspect", "--log-json", str(f["idat"]), str(bad)])
assert r.exit_code == 3
line = next(ln for ln in r.output.splitlines() if ln.startswith('{"schema"'))
rec = json.loads(line)
assert rec["schema"] == "simurg.log/1" and rec["code"] == "E_GTC_CORRUPT" and rec["level"] == "error"
def test_unknown_option_is_usage_error(tmp_path) -> None: # type: ignore[no-untyped-def]
r = runner.invoke(app, ["inspect", "--no-such-option", "x"])
assert r.exit_code == 2
def test_catalogue_integrity() -> None:
for code, d in CATALOGUE.items():
assert code == d.code
assert code[:2] in ("E_", "W_", "I_")
assert d.level in ("error", "warning", "info")
def test_messages_doc_is_current() -> None:
doc = ROOT / "docs" / "messages.md"
assert doc.read_text(encoding="utf-8") == render_markdown(), "regenerate: python -m simurg.core.errors"
def test_locale_independent_output(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-001
f = _files(tmp_path)
outs = set()
for loc in ("C.UTF-8", "tr_TR.UTF-8", "de_DE.UTF-8"):
env = {"LC_ALL": loc, "PATH": "/usr/bin:/bin"}
r = subprocess.run(
[sys.executable, "-m", "simurg.cli.main", "inspect", "--json", str(f["bpm"])],
capture_output=True,
text=True,
env=env,
cwd=ROOT,
)
assert r.returncode == 0, r.stderr
outs.add(r.stdout)
assert len(outs) == 1
+120
View File
@@ -0,0 +1,120 @@
"""IDAT discovery (IN-020 … IN-022, EDGE-005) and atomic writes / overwrite policy (CLI-010, CLI-011)."""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import pytest
from simurg.core.atomic import atomic_open, atomic_write_bytes, check_outputs_absent
from simurg.core.errors import SimurgError
from simurg.formats import discovery as disc
from simurg.formats.samplesheet import parse_samplesheet
from simurg.synth.files import SynthIdat, encode_idat
def write_pair(
folder: Path,
barcode: str,
pos: str,
scan: str = "12/27/2023 5:52:21 PM",
channels: tuple[str, ...] = ("Grn", "Red"),
) -> None:
folder.mkdir(parents=True, exist_ok=True)
s = SynthIdat(
address_ids=np.arange(1, 11, dtype=np.int32),
mean=np.arange(10, dtype=np.uint16),
barcode=barcode,
position=pos,
run_info=[(scan, "Scan", "", "iScan", "1")],
)
for ch in channels:
(folder / f"{barcode}_{pos}_{ch}.idat").write_bytes(encode_idat(s))
def test_pairs_and_unpaired(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "a", "200000000001", "R01C01")
write_pair(tmp_path / "b" / "deep", "200000000001", "R02C01", channels=("Grn",))
d = disc.discover_idats(tmp_path)
assert sorted(d.pairs) == ["200000000001_R01C01", "200000000001_R02C01"]
assert d.pairs["200000000001_R01C01"].complete
assert not d.pairs["200000000001_R02C01"].complete
assert [c for c, _ in d.warnings] == ["W_IDAT_UNPAIRED"]
def test_duplicate_pairs_error_lists_all_paths(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "run1", "200000000001", "R01C01")
write_pair(tmp_path / "run2", "200000000001", "R01C01")
with pytest.raises(SimurgError) as e:
disc.discover_idats(tmp_path)
assert e.value.code == "E_DUP_IDAT"
assert "run1" in e.value.message and "run2" in e.value.message
def test_duplicate_first_and_newest_deterministic(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "z_old", "200000000001", "R01C01", scan="1/1/2020 1:00:00 PM")
write_pair(tmp_path / "a_new", "200000000001", "R01C01", scan="1/1/2024 1:00:00 PM")
first = disc.discover_idats(tmp_path, on_duplicate="first").pairs["200000000001_R01C01"]
newest = disc.discover_idats(tmp_path, on_duplicate="newest").pairs["200000000001_R01C01"]
assert first.folder.name == "a_new" # bytewise smallest path
assert newest.folder.name == "a_new"
# shuffled directory listing must not change anything
real_scandir = os.scandir
def shuffled(path): # type: ignore[no-untyped-def]
return list(reversed(list(real_scandir(path))))
monkeypatch.setattr(os, "scandir", shuffled)
assert disc.discover_idats(tmp_path, on_duplicate="first").pairs["200000000001_R01C01"].folder.name == "a_new"
def test_symlink_loop(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "a", "200000000001", "R01C01")
(tmp_path / "a" / "loop").symlink_to(tmp_path, target_is_directory=True)
assert list(disc.discover_idats(tmp_path).pairs) == ["200000000001_R01C01"]
def test_resolve_with_sheet(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "idats", "200000000001", "R01C01")
write_pair(tmp_path / "idats", "200000000001", "R03C01")
sh = tmp_path / "s.csv"
sh.write_text(
"[Header]\nRootFolder,idats\n[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n"
"200000000001,R01C01,alpha\n200000000001,R02C01,beta\n"
)
sheet = parse_samplesheet(sh)
folder, notes = disc.resolve_input_folder(None, sheet)
assert folder == tmp_path / "idats" and notes == []
samples, notes = disc.resolve_samples(disc.discover_idats(folder), sheet)
assert [(s.sample_name, s.status) for s in samples] == [("alpha", "READY"), ("beta", "MISSING_INPUT")]
assert notes[0][0] == "I_UNLISTED_IDATS"
_, notes = disc.resolve_input_folder(tmp_path / "other", sheet)
assert notes[0][0] == "W_SHEET_ROOTFOLDER_OVERRIDDEN"
def test_input_rules() -> None:
with pytest.raises(SimurgError) as e:
disc.resolve_input_folder(None, None)
assert e.value.code == "E_NO_INPUT"
def test_atomic_write_and_failure(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "out" / "x.bin"
atomic_write_bytes(p, b"abc")
assert p.read_bytes() == b"abc"
with pytest.raises(RuntimeError), atomic_open(tmp_path / "y.bin") as fh:
fh.write(b"partial") # type: ignore[arg-type]
raise RuntimeError("boom")
assert not (tmp_path / "y.bin").exists()
assert not (tmp_path / "y.bin.part").exists()
def test_overwrite_policy(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "x"
p.write_text("1")
with pytest.raises(SimurgError) as e:
check_outputs_absent([p, tmp_path / "missing"], force=False)
assert e.value.code == "E_OUTPUT_EXISTS"
check_outputs_absent([p], force=True)
+152
View File
@@ -0,0 +1,152 @@
"""GTC codec: synthetic round-trips, property tests, corruption handling (spec IN-032, IN-033, EDGE-002)."""
from __future__ import annotations
import numpy as np
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from simurg.core.errors import FormatError
from simurg.formats.gtc import (
CANONICAL_ORDER_V3,
CANONICAL_ORDER_V5,
XFORM_DTYPE,
Gtc,
ScannerData,
encode_gtc,
read_gtc,
write_gtc,
)
def make_gtc(n: int = 20, version: int = 5, seed: int = 0) -> Gtc:
rng = np.random.default_rng(seed)
xf = np.zeros(3, XFORM_DTYPE)
xf["version"] = 1
xf["offset_x"] = rng.normal(size=3)
xf["theta"] = rng.normal(size=3)
g = Gtc(
version=version,
num_snps=n,
sample_name="NA12878 ünicode",
cluster_file="c.egt",
snp_manifest="m.bpm",
imaging_date="12/27/2023 5:53:34 PM",
autocall_date="01/01/26 12:00 PM",
autocall_version="simurg-0.1",
normalization_transforms=xf,
controls_x=rng.integers(0, 60000, 8).astype(np.uint16),
controls_y=rng.integers(0, 60000, 8).astype(np.uint16),
raw_x=rng.integers(0, 60000, n).astype(np.uint16),
raw_y=rng.integers(0, 60000, n).astype(np.uint16),
genotypes=rng.integers(0, 4, n).astype(np.uint8),
base_calls=np.frombuffer(b"AG" * n, np.uint8).reshape(n, 2).copy(),
genotype_scores=rng.random(n).astype(np.float32),
scanner_data=ScannerData("N103", b"\x01\x00\x00\x00", b"\x02\x00\x00\x00", "4.2.1", "user"),
call_rate=0.99,
gender=b"F",
logr_dev=0.12,
gc10=0.4,
b_allele_freqs=rng.random(n).astype(np.float32),
logr_ratios=rng.normal(size=n).astype(np.float32),
percentiles_x=np.array([1, 2, 3], np.uint16),
percentiles_y=np.array([4, 5, 6], np.uint16),
slide_identifier="200000000001",
)
g.sample_data["p50gc"] = 0.7
g.sample_data["num_calls"] = n - 2
g.sample_data["num_no_calls"] = 2
return g
@pytest.mark.parametrize("version", [3, 5])
def test_roundtrip_canonical(tmp_path, version) -> None: # type: ignore[no-untyped-def]
g = make_gtc(version=version)
p = tmp_path / "s.gtc"
write_gtc(g, p)
h = read_gtc(p)
assert h.toc_order == (CANONICAL_ORDER_V5 if version == 5 else CANONICAL_ORDER_V3)
assert h.sample_name == g.sample_name
assert np.array_equal(h.raw_x, g.raw_x) and np.array_equal(h.genotypes, g.genotypes)
assert np.array_equal(h.genotype_scores, g.genotype_scores)
assert h.scanner_data == g.scanner_data
assert h.gc50 == pytest.approx(0.7) and h.num_calls == 18
assert h.gender_char == "F"
assert h.base_call_strings()[0] == "AG"
assert encode_gtc(h) == p.read_bytes() # byte-identical round-trip
if version == 5:
assert np.array_equal(h.logr_ratios, g.logr_ratios)
def test_noncanonical_layout_preserved(tmp_path) -> None: # type: ignore[no-untyped-def]
g = make_gtc()
g.toc_order = list(reversed(CANONICAL_ORDER_V5))
g.data_order = [t for t in CANONICAL_ORDER_V5 if t > 3][::-1]
data = encode_gtc(g)
p = tmp_path / "s.gtc"
p.write_bytes(data)
h = read_gtc(p)
assert h.toc_order == g.toc_order and h.data_order == g.data_order
assert encode_gtc(h) == data
@settings(max_examples=25, deadline=None)
@given(n=st.integers(0, 200), seed=st.integers(0, 10**6), name=st.text(max_size=300))
def test_property_roundtrip(tmp_path_factory, n, seed, name) -> None: # type: ignore[no-untyped-def]
g = make_gtc(n=n, seed=seed)
g.sample_name = name
data = encode_gtc(g)
p = tmp_path_factory.mktemp("g") / "x.gtc"
p.write_bytes(data)
h = read_gtc(p)
assert h.sample_name == name and h.num_snps == n
assert encode_gtc(h) == data
def test_truncated_and_bad_magic(tmp_path) -> None: # type: ignore[no-untyped-def]
data = encode_gtc(make_gtc())
for label, blob in {
"trunc": data[: len(data) // 3],
"magic": b"xtc" + data[3:],
"tiny": data[:5],
"version": data[:3] + b"\x09" + data[4:],
}.items():
p = tmp_path / f"{label}.gtc"
p.write_bytes(blob)
with pytest.raises(FormatError) as e:
read_gtc(p)
assert e.value.code == "E_GTC_CORRUPT", label
def test_bad_string_length_is_clean_error(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-002
g = make_gtc()
data = bytearray(encode_gtc(g))
header = 8 + 6 * len(CANONICAL_ORDER_V5)
data[header] = 0xFF # sample-name length prefix now runs past EOF
data[header + 1] = 0xFF
data[header + 2] = 0xFF
data[header + 3] = 0x7F
p = tmp_path / "s.gtc"
p.write_bytes(bytes(data))
with pytest.raises(FormatError) as e:
read_gtc(p)
assert e.value.code == "E_GTC_CORRUPT"
def test_incomplete_write_detection(tmp_path) -> None: # type: ignore[no-untyped-def]
g = make_gtc()
g.toc_order = [t for t in CANONICAL_ORDER_V5 if t != 1016]
g.data_order = [t for t in g.toc_order if t > 3]
p = tmp_path / "s.gtc"
p.write_bytes(encode_gtc(g))
with pytest.raises(FormatError):
read_gtc(p)
assert read_gtc(p, check_write_complete=False).num_snps == 20
def test_length_mismatch_rejected_on_write() -> None:
g = make_gtc()
g.raw_x = g.raw_x[:-1]
with pytest.raises(ValueError):
encode_gtc(g)
+128
View File
@@ -0,0 +1,128 @@
"""IDAT reader: synthetic files + malformed-input matrix (spec IN-023, EDGE-004, EDGE-033)."""
from __future__ import annotations
import gzip
import struct
import numpy as np
import pytest
from simurg.core.errors import FormatError
from simurg.formats.idat import check_pair, parse_idat_filename, parse_run_time, read_idat
from simurg.synth.files import SynthIdat, encode_idat
def _synth(n: int = 50, **kw) -> SynthIdat: # type: ignore[no-untyped-def]
rng = np.random.default_rng(1)
ids = np.sort(rng.choice(10_000_000, n, replace=False)).astype(np.int32)
return SynthIdat(
address_ids=ids,
mean=rng.integers(0, 60000, n).astype(np.uint16),
n_beads=rng.integers(0, 30, n).astype(np.uint8),
**kw,
)
def test_roundtrip(tmp_path) -> None: # type: ignore[no-untyped-def]
s = _synth(sample_id="NA12878", plate="P1", well="A01")
p = tmp_path / "200000000001_R01C01_Grn.idat"
p.write_bytes(encode_idat(s))
i = read_idat(p)
assert i.n_addresses == 50
assert np.array_equal(i.address_ids, s.address_ids)
assert np.array_equal(i.mean, s.mean)
assert np.array_equal(i.n_beads, s.n_beads)
assert (i.barcode, i.position, i.sample_id, i.plate, i.well) == ("200000000001", "R01C01", "NA12878", "P1", "A01")
assert i.scan_time_iso == "2023-12-27T17:52:21"
assert i.scanner_software == "4.2.1.729"
assert i.mid_block_itemsize == 4
def test_midblock_uint8_inferred(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "x.idat"
p.write_bytes(encode_idat(_synth(n=40, mid_block_itemsize=1)))
assert read_idat(p).mid_block_itemsize == 1
def test_gzip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "200000000001_R01C01_Red.idat.gz"
p.write_bytes(gzip.compress(encode_idat(_synth())))
assert read_idat(p).n_addresses == 50
def test_metadata_only(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "x.idat"
p.write_bytes(encode_idat(_synth()))
i = read_idat(p, load_arrays=False)
assert i.n_addresses == 50 and len(i.mean) == 0 and i.barcode == "200000000001"
@pytest.mark.parametrize(
"mutate,code",
[
(lambda b: b"XDAT" + b[4:], "E_IDAT_FORMAT"), # wrong magic
(lambda b: b[:4] + struct.pack("<q", 4) + b[12:], "E_IDAT_FORMAT"), # unsupported version
(lambda b: b[:4] + struct.pack("<q", 1 | (7 << 32)) + b[12:], "E_IDAT_ENCRYPTED"), # encrypted v1
(lambda b: b[: len(b) // 2], "E_IDAT_FORMAT"), # truncated
(lambda b: b[:18] + struct.pack("<q", 10**9) + b[26:], "E_IDAT_FORMAT"), # TOC offset outside file
(lambda b: b[:8], "E_IDAT_FORMAT"), # tiny
],
)
def test_malformed(tmp_path, mutate, code) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "bad.idat"
p.write_bytes(mutate(encode_idat(_synth())))
with pytest.raises(FormatError) as e:
read_idat(p)
assert e.value.code == code
def test_pair_mismatch(tmp_path) -> None: # type: ignore[no-untyped-def]
a, b = tmp_path / "a.idat", tmp_path / "b.idat"
a.write_bytes(encode_idat(_synth(n=50)))
b.write_bytes(encode_idat(_synth(n=49)))
with pytest.raises(FormatError) as e:
check_pair(read_idat(a), read_idat(b))
assert e.value.code == "E_IDAT_PAIR_MISMATCH"
@pytest.mark.parametrize(
"name,expected",
[
("207895980019_R03C01_Grn.idat", ("207895980019", "R03C01", "Grn")),
("207895980019_r03c01_red.idat.gz", ("207895980019", "R03C01", "Red")),
("5723646052_R02C02_GRN.IDAT", ("5723646052", "R02C02", "Grn")),
("4019585376_B_Red.idat", None),
("notes.txt", None),
],
)
def test_filename(name, expected) -> None: # type: ignore[no-untyped-def]
assert parse_idat_filename(name) == expected
@pytest.mark.parametrize(
"raw,iso",
[
("12/27/2023 5:53:34 PM", "2023-12-27T17:53:34"),
("1/2/2020 12:00:01 AM", "2020-01-02T00:00:01"),
("2020-01-02 13:14:15", "2020-01-02T13:14:15"),
("", ""),
("garbage", ""),
],
)
def test_run_time(raw, iso) -> None: # type: ignore[no-untyped-def]
assert parse_run_time(raw) == iso
def test_duplicate_addresses_rejected(tmp_path) -> None: # type: ignore[no-untyped-def]
from simurg.formats.idat import has_duplicates
assert not has_duplicates(np.array([1, 5, 9], np.int32))
assert not has_duplicates(np.array([9, 1, 5], np.int32))
assert has_duplicates(np.array([9, 1, 9], np.int32))
s = _synth(n=5)
s.address_ids = np.array([5, 1, 5, 7, 8], np.int32)
p = tmp_path / "dup.idat"
p.write_bytes(encode_idat(s))
with pytest.raises(FormatError):
read_idat(p)
+160
View File
@@ -0,0 +1,160 @@
"""Sample-sheet matrix — spec EDGE-007 (IN-001 … IN-014)."""
from __future__ import annotations
from pathlib import Path
import pytest
from simurg.core.errors import SimurgError
from simurg.formats.samplesheet import output_stem, parse_samplesheet
HEAD = "[Header]\nInvestigator Name,X\n[Manifests]\nA,foo.bpm\n[Data]\n"
def sheet(tmp_path: Path, body: str, name: str = "s.csv", raw: bytes | None = None) -> Path:
p = tmp_path / name
if raw is not None:
p.write_bytes(raw)
else:
p.write_text(body, encoding="utf-8")
return p
def code_of(tmp_path: Path, body: str) -> str:
with pytest.raises(SimurgError) as e:
parse_samplesheet(sheet(tmp_path, body))
return e.value.code
def test_basic_sections_and_passthrough(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(
sheet(
tmp_path,
HEAD + "Sample_ID,SentrixBarcode_A,SentrixPosition_A,Gender,Tissue\na,203790110013,r04c01,Female,blood\n",
)
)
assert s.rows[0].sample_id == "203790110013_R04C01"
assert s.rows[0].reported_sex == "F"
assert s.passthrough_columns() == ["Sample_ID", "Gender", "Tissue"]
assert s.header == {"Investigator Name": "X"}
def test_sectionless(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(sheet(tmp_path, "SentrixBarcode_A,SentrixPosition_A\n203790110013,R01C01\n"))
assert len(s.rows) == 1
def test_bom_crlf_quotes(tmp_path) -> None: # type: ignore[no-untyped-def]
raw = ('[Data]\r\nSentrixBarcode_A,SentrixPosition_A,Note\r\n203790110013,R01C01,"a, b"\r\n').encode()
s = parse_samplesheet(sheet(tmp_path, "", raw=raw))
assert s.rows[0].values["Note"] == "a, b"
def test_trailing_empty_columns(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(sheet(tmp_path, "[Data]\nSentrixBarcode_A,SentrixPosition_A,,\n203790110013,R01C01,,\n"))
assert s.columns == ["SentrixBarcode_A", "SentrixPosition_A"]
assert [c for c, _ in s.warnings] == ["W_SHEET_EMPTY_COLUMN"]
assert "" not in s.rows[0].values
def test_manifests_section_with_values_ignored(tmp_path) -> None: # type: ignore[no-untyped-def]
body = (
"[Header],,\nDate,1,\n[Manifests],GSA.bpm,\nA,,\n[Data],,\nSentrixBarcode_A,SentrixPosition_A,x\n"
"203790110013,R01C01,1\n"
)
s = parse_samplesheet(sheet(tmp_path, body))
assert s.header == {"Date": "1"} and len(s.rows) == 1
@pytest.mark.parametrize(
"body,code",
[
("[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name,sample_name\n1,2,3,4\n", "E_SHEET_DUP_COLUMN"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A,Sex Estimate\n203790110013,R01C01,M\n", "E_SHEET_RESERVED"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A,call_rate\n203790110013,R01C01,1\n", "E_SHEET_RESERVED"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A,x\n203790110013,R01C01," + "a" * 501 + "\n", "E_SHEET_TOO_LONG"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A\n2.04753E+11,R01C01\n", "E_SHEET_BARCODE_FORMAT"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A\n203790110013,R1C1\n", "E_SHEET_BARCODE_FORMAT"),
("[Data]\nSentrixBarcode_A;SentrixPosition_A\n203790110013;R01C01\n", "E_SHEET_DELIMITER"),
('{"samples": []}', "E_SHEET_DELIMITER"),
("[Data]\nSample_ID,Foo\n1,2\n", "E_SHEET_NO_KEY_COLUMNS"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A\n", "E_SHEET_EMPTY"),
("[Header]\nA,B\n", "E_SHEET_EMPTY"),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n203790110013,R01C01,a\n203790110013,R02C01,\n",
"E_SHEET_NAME_EMPTY",
),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n203790110013,R01C01,203790110013_R02C01\n"
"203790110013,R02C01,b\n",
"E_SHEET_NAME_COLLISION",
),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n203790110013,R01C01,a\n203790110013,R02C01,a\n",
"E_SHEET_NAME_COLLISION",
),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A\n203790110013,R01C01\n203790110013,r01c01\n",
"E_SHEET_DUP_SAMPLE",
),
],
)
def test_error_matrix(tmp_path, body, code) -> None: # type: ignore[no-untyped-def]
assert code_of(tmp_path, body) == code
def test_barcode_decimal_repaired(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(sheet(tmp_path, "[Data]\nSentrixBarcode_A,SentrixPosition_A\n204753010023.0,R01C01\n"))
assert s.rows[0].barcode == "204753010023"
assert s.warnings[0][0] == "W_SHEET_BARCODE_REPAIRED"
def test_scientific_notation_hint(tmp_path) -> None: # type: ignore[no-untyped-def]
with pytest.raises(SimurgError) as e:
parse_samplesheet(sheet(tmp_path, '[Data]\nSentrixBarcode_A,SentrixPosition_A\n"2,04753E+11",R01C01\n'))
assert e.value.hint and "0 decimals" in e.value.hint
def test_alias_keys_and_rootfolder(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(
sheet(tmp_path, "[Header]\nRootFolder,idats\n[Data]\nbeadChipName,sampleSectionName\n203790110013,R01C01\n")
)
assert s.barcode_column == "beadChipName"
assert s.root_folder == tmp_path / "idats"
def test_sex_columns(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(
sheet(
tmp_path,
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Gender,Sex\n"
"203790110013,R01C01,Male,F\n203790110013,R02C01,,\n",
)
)
assert [r.reported_sex for r in s.rows] == ["F", "U"]
assert any(c == "W_SHEET_SEX_CONFLICT" for c, _ in s.warnings)
def test_turkish_case_folding_is_locale_independent(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-001
s = parse_samplesheet(sheet(tmp_path, "[Data]\nSENTRIXBARCODE_A,SentrixPosition_A,İl\n203790110013,R01C01,x\n"))
assert s.barcode_column == "SENTRIXBARCODE_A"
def test_ten_thousand_rows_fast(tmp_path) -> None: # type: ignore[no-untyped-def] # PERF-007 (parse part)
import time
lines = ["[Data]", "SentrixBarcode_A,SentrixPosition_A,Sample_Name"]
for i in range(10_000):
lines.append(f"{200000000000 + i // 24},R{(i % 24) + 1:02d}C01,s{i}")
p = sheet(tmp_path, "\n".join(lines) + "\n")
t = time.perf_counter()
s = parse_samplesheet(p)
assert len(s.rows) == 10_000
assert time.perf_counter() - t < 2.0
def test_output_stem() -> None:
assert output_stem("NA 12878/ü") == "NA_12878__"
assert output_stem("..hidden") == "hidden"
assert len(output_stem("x" * 500)) == 200