AGENTS.md has always said that a skill shipping scripts/ puts its tests in
tests/<name>/, but nothing checked it: 54 of the 100 such skills had no suite
at all, including docx, pptx, xlsx, pdf and scanpy. All 100 now do.
tests/_meta is the guard. It runs the shared structural contract across every
skill in a single process -- safe because it parses scripts with ast and never
imports them -- and fails when a skill ships scripts/ without a suite or
without a [skills.<name>] entry in skill-requirements.toml. It needs no
scientific packages and finishes in seconds, so skill-tests.yml blocks every
pull request on it, plus the packages=[] suites.
tests/_contract holds what the per-skill suites were each reimplementing:
frontmatter conformance, the 500-line limit, no tests or bytecode under
skills/, local links resolving, scripts parsing, no eval/exec/os.system, no
standard-library shadowing, no hardcoded local paths, valid shell scripts. Also
the --help contract, which skips when a skill's packages are absent and runs
for real under --isolated, and shared behaviour for the files docx/pptx/xlsx
and five schematic-shipping skills carry byte-identical copies of, with drift
detection so they cannot diverge silently.
67 new suites, 3325 test functions. The existing suites were retrofitted: 18
no longer pin an exact skill version, so a version bump no longer breaks a
test; 11 duplicated structural methods removed; 19 wired to the --help
contract; and 5 that failed collection without their packages now skip
cleanly. run_all.py in the bare project environment goes from 6 failures to 0.
Writing the tests surfaced 19 defects in the skills, fixed here with version
bumps. The ones that changed scientific output:
- openpiv reported vorticity 0 for a rotating flow, from a sign error in
openpiv's y-up coordinate relabelling; solid-body rotation now gives 2w
exactly, on grids of either orientation
- deepchem returned solubility predictions in z-scored space while labelling
them log(mol/L), because it transformed a y-less dataset instead of
untransforming the output
- neuropixels-analysis had the Allen and IBL ISI thresholds swapped,
contradicting its own references/QUALITY_METRICS.md and inverting the two
standards' relative strictness
- scanpy's summarize() raised TypeError on every AnnData under anndata 0.13,
which reports an unnamed None key on .layers; scanpy convert was broken
- experimental-design's Latin hypercube was never reproducible: pyDOE3 draws
from its own default_rng and ignores numpy's global seed
- primekg shipped a hardcoded path naming a person, which is why
no_personal_paths is now a contract rule
The remainder is upstream API drift, each verified against the installed
package: retired symbols in bioservices 1.16, gget helpers that returned lists
where a string was written, ArviZ 1.x kwargs in pymc, a positional-only
factory in pymoo, ReduceLROnPlateau(verbose=) in torch 2.13, a removed scvelo
parameter, and PyPDF2 in scientific-slides.
Two manifest environments could not build and are pinned: gget, where an
unpinned scanpy walked back to 1.9.8 and pulled llvmlite 0.36 which does not
compile on 3.13, and pymatgen, pinned to the snapshot its own _common.py
enforces rather than loosening that check. deepchem gains torch, without which
no model class exists.
python tests/run_all.py --isolated: 101 passed, 0 failed.
385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""Tests for the bulk RNA-seq samplesheet validator and counts-matrix builder.
|
|
|
|
`validate_samplesheet` is the gate in front of an expensive pipeline run, and
|
|
the distinction it draws between an error and a warning is the whole product:
|
|
a duplicated FASTQ is fatal, the same sample across lanes is not. Every test
|
|
here asserts on that split, not merely that "something was reported".
|
|
|
|
The design checks matter just as much. A group with one replicate cannot
|
|
estimate variance, and a batch nested inside condition cannot be separated
|
|
from the biology -- both are silent disasters downstream, so both are pinned.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import skill_contract
|
|
|
|
SKILL_ROOT = Path(__file__).resolve().parents[2] / "skills" / "bulk-rnaseq"
|
|
SCRIPTS = SKILL_ROOT / "scripts"
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
pd = pytest.importorskip("pandas", reason="bulk-rnaseq needs pandas")
|
|
|
|
import build_counts_matrix # noqa: E402
|
|
import validate_samplesheet # noqa: E402
|
|
|
|
CliHelpTests = skill_contract.cli.help_test_case(SKILL_ROOT)
|
|
|
|
GOOD_SHEET = """\
|
|
sample,fastq_1,fastq_2,strandedness
|
|
ctrl_1,ctrl_1_R1.fastq.gz,ctrl_1_R2.fastq.gz,auto
|
|
ctrl_2,ctrl_2_R1.fastq.gz,ctrl_2_R2.fastq.gz,auto
|
|
treat_1,treat_1_R1.fastq.gz,treat_1_R2.fastq.gz,auto
|
|
treat_2,treat_2_R1.fastq.gz,treat_2_R2.fastq.gz,auto
|
|
"""
|
|
|
|
GOOD_METADATA = """\
|
|
sample,condition,batch
|
|
ctrl_1,control,b1
|
|
ctrl_2,control,b2
|
|
treat_1,treated,b1
|
|
treat_2,treated,b2
|
|
"""
|
|
|
|
|
|
class SamplesheetTestCase(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._temporary.cleanup)
|
|
self.root = Path(self._temporary.name)
|
|
self.report = validate_samplesheet.Report()
|
|
|
|
def write(self, name: str, text: str) -> Path:
|
|
path = self.root / name
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
def validate(self, text: str, check_files: bool = False):
|
|
return validate_samplesheet.validate_samplesheet(
|
|
self.write("samplesheet.csv", text), check_files, self.report
|
|
)
|
|
|
|
|
|
class SamplesheetShapeTests(SamplesheetTestCase):
|
|
def test_a_well_formed_sheet_passes_cleanly(self) -> None:
|
|
sheet = self.validate(GOOD_SHEET)
|
|
self.assertIsNotNone(sheet)
|
|
self.assertEqual(self.report.errors, [])
|
|
self.assertEqual(self.report.warnings, [])
|
|
self.assertEqual(len(sheet), 4)
|
|
|
|
def test_a_missing_file_is_an_error_not_a_crash(self) -> None:
|
|
result = validate_samplesheet.validate_samplesheet(
|
|
self.root / "absent.csv", False, self.report
|
|
)
|
|
self.assertIsNone(result)
|
|
self.assertTrue(any("not found" in e for e in self.report.errors))
|
|
|
|
def test_the_required_columns_are_sample_and_fastq_1(self) -> None:
|
|
self.validate("sample,reads\nctrl_1,x.fastq.gz\n")
|
|
self.assertTrue(
|
|
any("must have at least" in e for e in self.report.errors)
|
|
)
|
|
|
|
def test_whitespace_around_headers_and_values_is_stripped(self) -> None:
|
|
sheet = self.validate(
|
|
" sample , fastq_1 \n ctrl_1 , ctrl_1_R1.fastq.gz \n"
|
|
)
|
|
self.assertEqual(list(sheet.columns), ["sample", "fastq_1"])
|
|
self.assertEqual(sheet.loc[0, "sample"], "ctrl_1")
|
|
self.assertEqual(self.report.errors, [])
|
|
|
|
def test_a_missing_strandedness_column_is_only_advisory(self) -> None:
|
|
self.validate("sample,fastq_1\nctrl_1,a.fastq.gz\n")
|
|
self.assertEqual(self.report.errors, [])
|
|
self.assertTrue(any("strandedness" in w for w in self.report.warnings))
|
|
|
|
|
|
class SamplesheetContentTests(SamplesheetTestCase):
|
|
def test_empty_sample_or_read_cells_are_errors_with_a_row_number(self) -> None:
|
|
self.validate("sample,fastq_1\n,a.fastq.gz\nctrl_2,\n")
|
|
self.assertTrue(any("row 2" in e and "'sample'" in e for e in self.report.errors))
|
|
self.assertTrue(any("row 3" in e and "'fastq_1'" in e for e in self.report.errors))
|
|
|
|
def test_the_same_file_in_both_read_columns_is_an_error(self) -> None:
|
|
# A copy-paste that would silently halve the library.
|
|
self.validate("sample,fastq_1,fastq_2\nctrl_1,same.fastq.gz,same.fastq.gz\n")
|
|
self.assertTrue(any("the same file" in e for e in self.report.errors))
|
|
|
|
def test_a_reused_read_one_file_is_an_error(self) -> None:
|
|
self.validate(
|
|
"sample,fastq_1\nctrl_1,shared.fastq.gz\nctrl_2,shared.fastq.gz\n"
|
|
)
|
|
self.assertTrue(any("appears 2 times" in e for e in self.report.errors))
|
|
|
|
def test_unknown_strandedness_values_are_rejected(self) -> None:
|
|
self.validate("sample,fastq_1,strandedness\nctrl_1,a.fastq.gz,sideways\n")
|
|
self.assertTrue(any("strandedness 'sideways'" in e for e in self.report.errors))
|
|
|
|
def test_every_documented_strandedness_value_is_accepted(self) -> None:
|
|
rows = "\n".join(
|
|
f"s{i},r{i}.fastq.gz,{value}"
|
|
for i, value in enumerate(sorted(validate_samplesheet.VALID_STRANDEDNESS))
|
|
)
|
|
self.validate(f"sample,fastq_1,strandedness\n{rows}\n")
|
|
self.assertEqual(self.report.errors, [])
|
|
|
|
def test_strandedness_is_matched_case_insensitively(self) -> None:
|
|
self.validate("sample,fastq_1,strandedness\nctrl_1,a.fastq.gz,REVERSE\n")
|
|
self.assertEqual(self.report.errors, [])
|
|
|
|
def test_one_sample_across_lanes_warns_but_does_not_fail(self) -> None:
|
|
# nf-core merges lanes, so this is normal -- it must stay a warning.
|
|
self.validate(
|
|
"sample,fastq_1,fastq_2\n"
|
|
"ctrl_1,L1_R1.fastq.gz,L1_R2.fastq.gz\n"
|
|
"ctrl_1,L2_R1.fastq.gz,L2_R2.fastq.gz\n"
|
|
)
|
|
self.assertEqual(self.report.errors, [])
|
|
self.assertTrue(any("lane-merged" in w for w in self.report.warnings))
|
|
|
|
def test_mixing_paired_and_single_rows_for_one_sample_is_an_error(self) -> None:
|
|
self.validate(
|
|
"sample,fastq_1,fastq_2\n"
|
|
"ctrl_1,L1_R1.fastq.gz,L1_R2.fastq.gz\n"
|
|
"ctrl_1,L2_R1.fastq.gz,\n"
|
|
)
|
|
self.assertTrue(
|
|
any("mixes paired-end and single-end" in e for e in self.report.errors)
|
|
)
|
|
|
|
|
|
class FileExistenceTests(SamplesheetTestCase):
|
|
def test_absent_local_reads_are_errors_when_checking_is_on(self) -> None:
|
|
self.validate("sample,fastq_1\nctrl_1,missing.fastq.gz\n", check_files=True)
|
|
self.assertTrue(any("fastq_1 not found" in e for e in self.report.errors))
|
|
|
|
def test_present_local_reads_pass(self) -> None:
|
|
(self.root / "real.fastq.gz").write_bytes(b"")
|
|
self.validate(
|
|
f"sample,fastq_1\nctrl_1,{self.root / 'real.fastq.gz'}\n", check_files=True
|
|
)
|
|
self.assertEqual(self.report.errors, [])
|
|
|
|
def test_an_unusual_extension_on_an_existing_file_is_only_a_warning(self) -> None:
|
|
(self.root / "reads.txt").write_bytes(b"")
|
|
self.validate(
|
|
f"sample,fastq_1\nctrl_1,{self.root / 'reads.txt'}\n", check_files=True
|
|
)
|
|
self.assertEqual(self.report.errors, [])
|
|
self.assertTrue(any("unusual extension" in w for w in self.report.warnings))
|
|
|
|
def test_remote_urls_are_skipped_rather_than_reported_missing(self) -> None:
|
|
for prefix in validate_samplesheet.REMOTE_PREFIXES:
|
|
with self.subTest(prefix=prefix):
|
|
report = validate_samplesheet.Report()
|
|
path = self.write(
|
|
"remote.csv", f"sample,fastq_1\nctrl_1,{prefix}bucket/r1.fastq.gz\n"
|
|
)
|
|
validate_samplesheet.validate_samplesheet(path, True, report)
|
|
self.assertEqual(report.errors, [])
|
|
self.assertTrue(any("remote" in w for w in report.warnings))
|
|
|
|
def test_nothing_is_checked_when_file_checking_is_off(self) -> None:
|
|
self.validate("sample,fastq_1\nctrl_1,missing.fastq.gz\n", check_files=False)
|
|
self.assertEqual(self.report.errors, [])
|
|
|
|
|
|
class MetadataTests(SamplesheetTestCase):
|
|
def _validate_metadata(self, metadata: str, sheet_text: str = GOOD_SHEET,
|
|
condition_col: str = "condition", min_rep: int = 3):
|
|
sheet = validate_samplesheet.validate_samplesheet(
|
|
self.write("sheet.csv", sheet_text), False, validate_samplesheet.Report()
|
|
)
|
|
validate_samplesheet.validate_metadata(
|
|
self.write("meta.csv", metadata), sheet, condition_col, min_rep, self.report
|
|
)
|
|
return sheet
|
|
|
|
def test_a_matching_design_passes_apart_from_the_replicate_advisory(self) -> None:
|
|
self._validate_metadata(GOOD_METADATA)
|
|
self.assertEqual(self.report.errors, [])
|
|
|
|
def test_a_missing_condition_column_names_the_columns_it_found(self) -> None:
|
|
self._validate_metadata("sample,group\nctrl_1,control\n")
|
|
self.assertTrue(any("no 'condition' column" in e for e in self.report.errors))
|
|
self.assertTrue(any("group" in e for e in self.report.errors))
|
|
|
|
def test_samples_missing_from_the_metadata_are_an_error(self) -> None:
|
|
self._validate_metadata("sample,condition\nctrl_1,control\nctrl_2,control\n")
|
|
self.assertTrue(
|
|
any("missing from metadata" in e for e in self.report.errors)
|
|
)
|
|
|
|
def test_extra_metadata_samples_are_only_a_warning(self) -> None:
|
|
self._validate_metadata(GOOD_METADATA + "spare,control,b3\n")
|
|
self.assertEqual(self.report.errors, [])
|
|
self.assertTrue(
|
|
any("not in samplesheet" in w for w in self.report.warnings)
|
|
)
|
|
|
|
def test_a_singleton_group_cannot_estimate_variance_and_is_fatal(self) -> None:
|
|
sheet_text = (
|
|
"sample,fastq_1\n"
|
|
"ctrl_1,c1.fastq.gz\nctrl_2,c2.fastq.gz\ntreat_1,t1.fastq.gz\n"
|
|
)
|
|
self._validate_metadata(
|
|
"sample,condition\nctrl_1,control\nctrl_2,control\ntreat_1,treated\n",
|
|
sheet_text=sheet_text,
|
|
)
|
|
self.assertTrue(
|
|
any("need >=2 to estimate variance" in e for e in self.report.errors)
|
|
)
|
|
|
|
def test_two_replicates_pass_but_warn_below_the_recommended_minimum(self) -> None:
|
|
self._validate_metadata(GOOD_METADATA, min_rep=3)
|
|
self.assertEqual(self.report.errors, [])
|
|
self.assertTrue(any("recommended" in w for w in self.report.warnings))
|
|
|
|
def test_an_entirely_empty_condition_column_is_fatal(self) -> None:
|
|
self._validate_metadata(
|
|
"sample,condition\nctrl_1,\nctrl_2,\ntreat_1,\ntreat_2,\n"
|
|
)
|
|
self.assertTrue(any("is empty for all samples" in e for e in self.report.errors))
|
|
|
|
def test_batch_fully_nested_in_condition_is_flagged_as_confounded(self) -> None:
|
|
# Each batch holding a single condition means the batch effect and the
|
|
# biology cannot be told apart -- the most expensive silent mistake here.
|
|
self._validate_metadata(
|
|
"sample,condition,batch\n"
|
|
"ctrl_1,control,b1\nctrl_2,control,b1\n"
|
|
"treat_1,treated,b2\ntreat_2,treated,b2\n"
|
|
)
|
|
self.assertTrue(any("confounded" in w for w in self.report.warnings))
|
|
|
|
def test_a_crossed_batch_design_is_not_flagged(self) -> None:
|
|
self._validate_metadata(GOOD_METADATA)
|
|
self.assertFalse(any("confounded" in w for w in self.report.warnings))
|
|
|
|
|
|
class ReportTests(unittest.TestCase):
|
|
def test_errors_fail_the_run_and_warnings_do_not(self) -> None:
|
|
clean = validate_samplesheet.Report()
|
|
self.assertEqual(clean.summarize(), 0)
|
|
|
|
warned = validate_samplesheet.Report()
|
|
warned.warn("advisory")
|
|
self.assertEqual(warned.summarize(), 0)
|
|
|
|
failed = validate_samplesheet.Report()
|
|
failed.error("fatal")
|
|
self.assertEqual(failed.summarize(), 1)
|
|
|
|
|
|
class SampleNameTests(unittest.TestCase):
|
|
def test_common_quantifier_suffixes_are_stripped(self) -> None:
|
|
clean = build_counts_matrix._clean_sample_name
|
|
for raw in ("ctrl_1", "ctrl_1.bam", "ctrl_1_S1_L001"):
|
|
with self.subTest(raw=raw):
|
|
self.assertTrue(clean(raw).startswith("ctrl_1"))
|
|
|
|
def test_cleaning_is_idempotent(self) -> None:
|
|
clean = build_counts_matrix._clean_sample_name
|
|
once = clean("ctrl_1.bam")
|
|
self.assertEqual(clean(once), once)
|
|
|
|
def test_the_star_strand_columns_match_the_documented_layout(self) -> None:
|
|
# STAR's ReadsPerGene.out.tab puts unstranded/forward/reverse in
|
|
# columns 1-3; picking the wrong one silently halves the counts.
|
|
self.assertEqual(
|
|
build_counts_matrix.STAR_STRAND_COL,
|
|
{"unstranded": 1, "forward": 2, "reverse": 3},
|
|
)
|
|
|
|
|
|
class CountsMatrixTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._temporary.cleanup)
|
|
self.root = Path(self._temporary.name)
|
|
|
|
def _star_table(self, sample: str, rows: str) -> None:
|
|
# STAR tables sit flat in the directory, named <sample>.ReadsPerGene.out.tab.
|
|
(self.root / f"{sample}.ReadsPerGene.out.tab").write_text(
|
|
"N_unmapped\t1\t1\t1\n"
|
|
"N_multimapping\t2\t2\t2\n"
|
|
"N_noFeature\t3\t3\t3\n"
|
|
"N_ambiguous\t4\t4\t4\n" + rows,
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def test_star_counts_are_assembled_from_per_sample_tables(self) -> None:
|
|
self._star_table("ctrl_1", "GENE1\t10\t0\t0\nGENE2\t20\t0\t0\n")
|
|
self._star_table("treat_1", "GENE1\t30\t0\t0\nGENE2\t40\t0\t0\n")
|
|
|
|
counts = build_counts_matrix.build_from_star(self.root, "unstranded")
|
|
self.assertEqual(sorted(counts.index), ["GENE1", "GENE2"])
|
|
self.assertEqual(set(counts.columns), {"ctrl_1", "treat_1"})
|
|
self.assertEqual(counts.loc["GENE1", "ctrl_1"], 10)
|
|
self.assertEqual(counts.loc["GENE2", "treat_1"], 40)
|
|
|
|
def test_star_summary_rows_are_excluded_from_the_gene_matrix(self) -> None:
|
|
# The first four rows are alignment statistics, not genes; counting
|
|
# them would inflate the library size for every sample.
|
|
self._star_table("s1", "GENE1\t5\t0\t0\n")
|
|
counts = build_counts_matrix.build_from_star(self.root, "unstranded")
|
|
self.assertEqual(list(counts.index), ["GENE1"])
|
|
|
|
def test_genes_absent_from_one_sample_become_zero_not_nan(self) -> None:
|
|
self._star_table("s1", "GENE1\t5\t0\t0\nGENE2\t7\t0\t0\n")
|
|
self._star_table("s2", "GENE1\t9\t0\t0\n")
|
|
counts = build_counts_matrix.build_from_star(self.root, "unstranded")
|
|
self.assertEqual(counts.loc["GENE2", "s2"], 0)
|
|
self.assertEqual(str(counts.dtypes.unique()[0]), "int64")
|
|
|
|
def test_the_strand_choice_selects_a_different_column(self) -> None:
|
|
self._star_table("s1", "GENE1\t100\t60\t40\n")
|
|
for strand, expected in (("unstranded", 100), ("forward", 60), ("reverse", 40)):
|
|
with self.subTest(strand=strand):
|
|
counts = build_counts_matrix.build_from_star(self.root, strand)
|
|
self.assertEqual(counts.loc["GENE1", "s1"], expected)
|
|
|
|
def test_an_empty_directory_exits_with_a_message(self) -> None:
|
|
with self.assertRaises(SystemExit) as raised:
|
|
build_counts_matrix.build_from_star(self.root, "unstranded")
|
|
self.assertIn("ReadsPerGene.out.tab", str(raised.exception))
|
|
|
|
def test_featurecounts_output_is_reduced_to_genes_by_samples(self) -> None:
|
|
path = self.root / "counts.txt"
|
|
path.write_text(
|
|
"# Program:featureCounts\n"
|
|
"Geneid\tChr\tStart\tEnd\tStrand\tLength\tctrl_1.bam\ttreat_1.bam\n"
|
|
"GENE1\tchr1\t1\t100\t+\t100\t10\t30\n"
|
|
"GENE2\tchr1\t200\t300\t+\t100\t20\t40\n",
|
|
encoding="utf-8",
|
|
)
|
|
counts = build_counts_matrix.build_from_featurecounts(path)
|
|
self.assertEqual(list(counts.index), ["GENE1", "GENE2"])
|
|
self.assertEqual(len(counts.columns), 2)
|
|
self.assertEqual(counts.iloc[0, 0], 10)
|
|
# The annotation columns must not survive as samples.
|
|
self.assertFalse({"Chr", "Start", "End", "Strand", "Length"} & set(counts.columns))
|
|
|
|
def test_outputs_are_written_where_downstream_steps_expect_them(self) -> None:
|
|
counts = pd.DataFrame(
|
|
{"ctrl_1": [10, 20], "treat_1": [30, 40]}, index=["GENE1", "GENE2"]
|
|
)
|
|
output = self.root / "out"
|
|
build_counts_matrix.write_outputs(counts, output)
|
|
written = sorted(path.name for path in output.iterdir())
|
|
self.assertTrue(written, "write_outputs produced nothing")
|
|
for name in written:
|
|
with self.subTest(file=name):
|
|
self.assertGreater((output / name).stat().st_size, 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|