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:
1 parent
705138d70b
commit
e518ea03d5
40 files changed
+4742
No files matched your search
@@ -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""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user