From 1186434ac99e7d3ef28abb0c7c8d98c01646d806 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:22:11 +0100 Subject: [PATCH 1/4] test(report): expect the LPA null allele and the SMN1 marker in both reports and on the live page The unit tests read made-up 4925G>A and SMN1 marker tables into both reports and the live cards, and step 46 with a crashing 4925G>A reader (fake docker) must keep the count and write a not-assessed table. The genotype-rules-2 e2e cases render both reports on the planted and the reference samples, and run the LPA module's block with a crashing reader. This commit holds the tests alone, so they go red on the code before the fix. Bead: pgp-pfw6.21 --- scripts/ci/e2e-case-paths.tsv | 4 +- tests/e2e/genotype-rules-2-lpa-null.sh | 45 ++++++++- tests/e2e/genotype-rules-2-smn1-marker.sh | 24 ++++- tests/test_collect_summary.py | 37 ++++++- tests/test_lpa_fallback.sh | 112 ++++++++++++++++++++++ tests/test_lpa_kiv2.py | 39 ++++++++ 6 files changed, 254 insertions(+), 7 deletions(-) create mode 100755 tests/test_lpa_fallback.sh diff --git a/scripts/ci/e2e-case-paths.tsv b/scripts/ci/e2e-case-paths.tsv index 4ad412b7..85f31cad 100644 --- a/scripts/ci/e2e-case-paths.tsv +++ b/scripts/ci/e2e-case-paths.tsv @@ -95,8 +95,8 @@ genome-lookup-1 scripts/ci/fake-docker/lib.sh scripts/owngenome-lookup.sh genotype-rule-rows-1-str-extra assets/str_extra_loci.json assets/stub/db/catalog.json scripts/09-expansion-hunter.sh scripts/ci/fake-docker/lib.sh scripts/setup.sh genotype-rules-1-phase assets/genotype_rules.tsv assets/traits.tsv bin/traits_report.py modules/local/traits/* modules/local/vcf_precheck/* scripts/42-traits.sh scripts/ci/check-rule-alleles.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh genotype-rules-2-g2 assets/genotype_rules.tsv bin/collect_summary.py bin/genotype_rules_indels.py bin/traits_report.py -genotype-rules-2-lpa-null bin/cn_loci.tsv bin/copy_number.py bin/lpa_kiv2.py bin/traits_report.py modules/local/lpa/* scripts/46-lpa.sh -genotype-rules-2-smn1-marker bin/copy_number.py bin/traits_report.py modules/local/parascopy/* scripts/35-paralogs.sh +genotype-rules-2-lpa-null bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/lpa_kiv2.py bin/render_report.py bin/traits_report.py modules/local/lpa/* scripts/24-html-report.sh scripts/46-lpa.sh scripts/generate-report.sh +genotype-rules-2-smn1-marker bin/collect_summary.py bin/copy_number.py bin/render_report.py bin/traits_report.py modules/local/parascopy/* scripts/24-html-report.sh scripts/35-paralogs.sh scripts/generate-report.sh haplogroup-explore-1-files assets/callability_genes.tsv assets/live/index.html assets/mito_confirmed.tsv assets/viewer/index.html bin/collect_summary.py bin/y_tree_place.py bin/yleaf_run.py modules/local/indexcov/* modules/local/mito_haplogroup/* modules/local/mito_variants/* modules/local/vcf_precheck/* modules/local/y_haplogroup/* scripts/12-mito-haplogroup.sh scripts/16-indexcov.sh scripts/16b-mosdepth.sh scripts/20-mtoolbox.sh scripts/37-y-haplogroup.sh scripts/ci/fake-docker/lib.sh scripts/generate-report.sh scripts/setup.sh scripts/validate-setup.sh hba-short-read-accuracy-1-nf-report assets/acmg_sf.tsv assets/callability_genes.tsv assets/carrier_genes.tsv assets/mito_confirmed.tsv assets/par_grch38.bed assets/stub/sample.vcf.gz bin/cn_loci.tsv bin/collect_summary.py bin/copy_number.py bin/export_consumer.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/render_report.py bin/results_index.py modules/local/export_consumer/* modules/local/html_report/* modules/local/vcf_precheck/* scripts/24-html-report.sh scripts/40-export-consumer.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh tests/fixtures/consumer_export/sites.tsv.gz hla-second-caller-1-bash assets/pgx_genes.tsv assets/stub/db/hla.dat assets/stub/db/kir.dat bin/cn_loci.tsv bin/copy_number.py bin/cyp2d6_depth_check.py bin/hba_copy_number.py bin/lpa_kiv2.py bin/pgx_outside_calls.py bin/render_report.py scripts/08-hla-typing.sh scripts/24-html-report.sh scripts/32-pypgx.sh scripts/36-pgx-consensus.sh scripts/ci/fake-docker/lib.sh scripts/validate-setup.sh diff --git a/tests/e2e/genotype-rules-2-lpa-null.sh b/tests/e2e/genotype-rules-2-lpa-null.sh index 104fbed5..c287305c 100755 --- a/tests/e2e/genotype-rules-2-lpa-null.sh +++ b/tests/e2e/genotype-rules-2-lpa-null.sh @@ -20,7 +20,12 @@ # The reading goes to lpa/_lpa_kiv2_4925.tsv; step 46's own table # keeps its rows. # - the LPA module's pileup and 4925G>A lines, cut from its main.nf, write the -# same pileup and table in their images, byte for byte, on the planted BAM. +# same pileup and table in their images, byte for byte, on the planted BAM; +# with a 4925G>A reader that crashes, its block writes a table that says +# not assessed and leaves the count; +# - steps 24 and generate-report.sh on the planted and the reference samples: +# both reports print the reading under the count with its caveat +# ('present' with the reads and copies, 'not seen'). . "$(dirname "$0")/lib.sh" G="$GENOME_DIR" T="$CASE_TMP" @@ -128,6 +133,23 @@ check_eq "reference: not seen" "$(null "$R" status)" "not seen" check_eq "reference: no read with T" "$(null "$R" alt_reads)" 0 check "control: the planted and the reference tables differ" bash -c '! cmp -s "$1" "$2"' _ \ "${G}/${P}/lpa/${P}_lpa_kiv2_4925.tsv" "${G}/${R}/lpa/${R}_lpa_kiv2_4925.tsv" +# --- both reports print the reading under the count (steps 24 and generate-report.sh) ----- +for s in "$P" "$R"; do + run_step 24-html-report.sh "$s"; check_step_exit 24-html-report.sh + run_step generate-report.sh "$s"; check_step_exit generate-report.sh +done +rep_has() { grep -qF -- "$2" "${G}/$1/$1_report.$3"; } +check "planted: the HTML report prints the null allele as present, with reads and copies" \ + rep_has "$P" "KIV-2 4925G>A null allele (rs1484919544): present, $(null "$P" alt_reads) of $(null "$P" depth) reads carry it, about $(null "$P" copies) copies" html +check "planted: the text report prints it under the count" \ + bash -c 'sed -n "/KIV-2 repeats (both chromosomes)/,/rs10455872/p" "$1" | grep -qF "KIV-2 4925G>A null allele (rs1484919544): present"' _ "${G}/${P}/${P}_report.txt" +check "planted: both reports carry the caveat" \ + bash -c 'grep -qF "A null allele lowers the Lp(a) made from the copies it sits on" "$1" && grep -qF "A null allele lowers the Lp(a) made from the copies it sits on" "$2"' _ \ + "${G}/${P}/${P}_report.html" "${G}/${P}/${P}_report.txt" +check "reference: both reports print not seen (control: the line follows the table)" \ + bash -c 'grep -qF "KIV-2 4925G>A null allele (rs1484919544): not seen, 0 of" "$1" && grep -qF "KIV-2 4925G>A null allele (rs1484919544): not seen, 0 of" "$2"' _ \ + "${G}/${R}/${R}_report.html" "${G}/${R}/${R}_report.txt" + echo "genotype-rules-2-lpa-null: HG002 KIV-2 4925G>A $(null "$U" status)," \ "$(null "$U" alt_reads) of $(null "$U" depth) reads (fraction $(null "$U" fraction))," \ "KIV-2 count $(metric "$U" kiv2_copies); planted $(null "$P" fraction), copies $(null "$P" copies)" >> "$E2E_NOTES" @@ -140,9 +162,9 @@ unesc() { sed -e 's/\\\\/\\/g' -e 's/\\\$/$/g' -e "s|\${bam}|${G}/${P}/aligned/$ -e "s|\${pileup}|${P}_lpa_kiv2_4925_pileup.tsv|g"; } grep -E '^ *traits_report\.py --marker-regions LPA_KIV2_4925 > lpa_sites\.txt$' "$MOD" | unesc > "${M}/sites.sh" sed -n '/^ *( while read -r r; do samtools mpileup /,/|| echo /p' "$MOD" | unesc > "${M}/pileup.sh" -sed -n '/^ *traits_report\.py --marker LPA_KIV2_4925 --pileup/,/--marker-out/p' "$MOD" | unesc > "${M}/null.sh" +sed -n '/^ *if ! traits_report\.py --marker LPA_KIV2_4925 --pileup/,/^ *fi$/p' "$MOD" | unesc > "${M}/null.sh" check_eq "the module's sites, pileup and 4925G>A lines were cut from main.nf" \ - "$(cat "${M}/sites.sh" "${M}/pileup.sh" "${M}/null.sh" | wc -l | tr -d ' ')" 5 + "$(cat "${M}/sites.sh" "${M}/pileup.sh" "${M}/null.sh" | wc -l | tr -d ' ')" 9 cat "${M}/sites.sh" "${M}/pileup.sh" "${M}/null.sh" cp "${G}/${P}/lpa/${P}_lpa_kiv2.tsv" "$M/" mod() { docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "${E2E_WORK}:${E2E_WORK}" -v "${REPO}/bin:/pgp-bin:ro" \ @@ -154,5 +176,22 @@ check "the module writes step 46's 4925G>A table, byte for byte" \ cmp -s "${G}/${P}/lpa/${P}_lpa_kiv2_4925.tsv" "${M}/${P}_lpa_kiv2_4925.tsv" check "the module leaves step 46's table byte for byte" cmp -s "${G}/${P}/lpa/${P}_lpa_kiv2.tsv" "${M}/${P}_lpa_kiv2.tsv" +# The module's fallback, in its image: a 4925G>A reader that crashes leaves the +# count and a table that says not assessed (scripts/46-lpa.sh writes the same +# table; tests/test_lpa_fallback.sh checks that). Control: the planted table +# above was 'present'. +mkdir -p "${M}/broken" +printf '#!/bin/sh\necho "planted crash" >&2\nexit 1\n' > "${M}/broken/traits_report.py" +chmod +x "${M}/broken/traits_report.py" +cp "${G}/${P}/lpa/${P}_lpa_kiv2.tsv" "${M}/count_before.tsv" +docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "${E2E_WORK}:${E2E_WORK}" -v "${REPO}/bin:/pgp-bin:ro" \ + -w "$M" "$PYTHON_IMAGE" sh -c "PATH=\"${M}/broken:/pgp-bin:\$PATH\"; set -e; . ./null.sh" 2>&1 | tail -n 2 +check_eq "the module with a crashing 4925G>A reader: not assessed" \ + "$(awk -F'\t' '$1 == "status" {print $2}' "${M}/${P}_lpa_kiv2_4925.tsv")" "not assessed" +check "the module with a crashing 4925G>A reader: the message says the check failed" \ + grep -q "not assessed, the check failed" "${M}/${P}_lpa_kiv2_4925.tsv" +check "the module with a crashing 4925G>A reader: the count table stays" \ + cmp -s "${M}/count_before.tsv" "${M}/${P}_lpa_kiv2.tsv" + rm -rf "${G:?}/${U}" "${G:?}/${P}" "${G:?}/${R}" "${M:?}" "${T:?}"/*.sam "${T:?}"/*.bam "${T:?}"/*.bai finish diff --git a/tests/e2e/genotype-rules-2-smn1-marker.sh b/tests/e2e/genotype-rules-2-smn1-marker.sh index 5cdbaf03..a5804dbe 100755 --- a/tests/e2e/genotype-rules-2-smn1-marker.sh +++ b/tests/e2e/genotype-rules-2-smn1-marker.sh @@ -18,7 +18,10 @@ # from scripts/35-paralogs.sh, with scripts/lib/common.sh's run_in), and # the PARASCOPY module's block, cut from its main.nf, writes the same table # in its image, byte for byte, on the planted BAM. -# - every message says the marker does not prove 2+0. +# - every message says the marker does not prove 2+0; +# - steps 24 and generate-report.sh with the planted and the reference +# tables: both reports print the marker under the SMN1 copies with its +# caveat ('present' with its reads, 'not seen'). . "$(dirname "$0")/lib.sh" SRC="https://storage.googleapis.com/deepvariant/case-study-testdata/HG002.novaseq.pcr-free.35x.dedup.grch38_no_alt.bam" @@ -143,5 +146,24 @@ docker run --rm --user "$(id -u):$(id -g)" -v "${GENOME_DIR}:/genome" -v "${REPO "$PARASCOPY_IMAGE" sh -c 'PATH="/pgp-bin:$PATH"; sh -e block.sh' 2>&1 | tail -n 3 check "the module's block writes step 35's table, byte for byte" cmp -s "$L" "${M}/${P}_smn1_marker.tsv" +# --- both reports print the marker under the SMN1 copies (steps 24 and generate-report.sh) +rm -rf "$M" "${W}"/*.bam "${W}"/*.bai +for v in planted reference; do + cp "${CASE_TMP}/${v}.tsv" "${W}/paralogs/${P}_smn1_marker.tsv" + run_step 24-html-report.sh "$P"; check_step_exit 24-html-report.sh + run_step generate-report.sh "$P"; check_step_exit generate-report.sh + cp "${W}/${P}_report.html" "${CASE_TMP}/${v}.html"; cp "${W}/${P}_report.txt" "${CASE_TMP}/${v}.txt" +done +check "planted: the HTML report prints the marker as present, with its reads, under the SMN1 copies" \ + grep -qF "SMN1 g.27134T>G silent-carrier marker (rs143838139)present, $(col "$L" alt_reads) of $(col "$L" depth) reads carry it" "${CASE_TMP}/planted.html" +check "planted: the text report prints it right under the SMN1 copies" \ + bash -c 'grep -A1 "SMN1 copies: " "$1" | grep -qF "SMN1 g.27134T>G silent-carrier marker (rs143838139): present"' _ "${CASE_TMP}/planted.txt" +check "planted: both reports carry the caveat" \ + bash -c 'grep -qF "it does not prove 2+0, and a carrier test in a clinical laboratory confirms it" "$1" && grep -qF "it does not prove 2+0, and a carrier test in a clinical laboratory confirms it" "$2"' _ \ + "${CASE_TMP}/planted.html" "${CASE_TMP}/planted.txt" +check "reference: both reports print not seen (control: the line follows the table)" \ + bash -c 'grep -qF "SMN1 g.27134T>G silent-carrier marker (rs143838139)not seen, 0 of" "$1" && grep -qF "SMN1 g.27134T>G silent-carrier marker (rs143838139): not seen, 0 of" "$2"' _ \ + "${CASE_TMP}/reference.html" "${CASE_TMP}/reference.txt" + rm -rf "$W" finish diff --git a/tests/test_collect_summary.py b/tests/test_collect_summary.py index 5ef81ccc..4e28b325 100644 --- a/tests/test_collect_summary.py +++ b/tests/test_collect_summary.py @@ -132,7 +132,10 @@ folder with neither step 35 nor step 35b prints "Paralog copy number: not checked (neither step 35 nor step 35b ran)" in both reports, a failed step 35 is named in that line, and Paraphase alone leaves the line out; - a table without the psCN columns is unreadable. + a table without the psCN columns is unreadable. The SMN1 g.27134T>G + silent-carrier marker (step 35's _smn1_marker.tsv) prints beside the + SMN1 copies in both reports with its caveat: present with its reads, + not assessed without the table or with no known status in it. 26. step 08's opt-in passes in the HLA section: with HLA_QC=true, one line per class I gene from the concordance table pgx_outside_calls.py writes (agree, disagree with both calls, not compared with the reason), and @@ -1624,6 +1627,38 @@ def parascopy_section(work, schema): and "not a carrier by copy number" in card_s and "CYP21A2 and the gene-dosage loci have no Parascopy model" in card_s and "step 35 counts them from read depth against flanking sequence" in card_s and "Not checked" not in html, card_s) + # SMN1 g.27134T>G beside the psCN (step 35's _smn1_marker.tsv, bin/traits_report.py smn1_marker) + caveat = render_report.SMN1_MARKER_CAVEAT + after_cn = " SMN1 copies: 2 (quality 33, filter PASS)\n SMN1 g.27134T>G silent-carrier marker (rs143838139): " + check("parascopy: without step 35's marker table the marker line says not assessed, beside the psCN, with why", + (pd.get("smn1_marker") or {}).get("status") == "not assessed" + and after_cn + "not assessed\n" in txt and "step 35 wrote no S_smn1_marker.tsv" in txt + and f" SMN1 marker caveat: {caveat}\n" in txt, txt) + import traits_report + mk = f"{d}/paralogs/S_smn1_marker.tsv" + traits_report.write_metric_table(mk, traits_report.smn1_marker((35, 5, 0), f"{d}/paralogs/S_smn_copy_number.tsv")) + summ_m, txt_m, html_m = render(d) + card_m = html_m.split("

SMN1/SMN2 Copy Number (Parascopy)

", 1)[-1].split("

", 1)[0] + check("parascopy: a marker table with 5 of 40 reads reads as present beside the psCN in the text report, " + "with the reader's message and the caveat", + after_cn + "present, 5 of 40 reads carry it, about 0.5 copies\n" in txt_m + and " SMN1 marker reading: SMN1 g.27134T>G (rs143838139): present" in txt_m + and f" SMN1 marker caveat: {caveat}\n" in txt_m, txt_m) + check("parascopy: the HTML card shows the marker, its message and the caveat", + ">SMN1 g.27134T>G silent-carrier marker (rs143838139)<" in card_m + and "present, 5 of 40 reads carry it" in card_m and render_report.E(caveat) in card_m + and "does not prove 2+0" in card_m, card_m) + import live_results + lv = live_results.a_paralogs(summ_m["sections"], {}) + check("live: the paralog card has the SMN1 copies and the marker as facts, and its lines with the caveat", + ["SMN1 g.27134T>G silent-carrier marker", "present, 5 of 40 reads carry it, about 0.5 copies"] in lv["facts"] + and ["SMN1 copies", "2 (quality 33, filter PASS)"] in lv["facts"] + and f"SMN1 marker caveat: {caveat}" in lv["lines"], lv) + put(mk, "metric\tvalue\nstatus\tmaybe\n") + _, txt_b, _ = render(d) + check("parascopy: a marker table with no known status reads as not assessed, never as a result", + after_cn + "not assessed\n" in txt_b and "S_smn1_marker.tsv has no status row" in txt_b, txt_b) + os.remove(mk) check("parascopy: HBA1/HBA2 listed as not assessed without step 35's HBA table or step 35b", any(x.startswith("HBA1/HBA2 copy number") for x in summ["not_assessed"]) and "HBA1/HBA2 copy number (alpha-thalassaemia deletions): neither step 35's read-depth count" in txt diff --git a/tests/test_lpa_fallback.sh b/tests/test_lpa_fallback.sh new file mode 100755 index 00000000..acb0f7e2 --- /dev/null +++ b/tests/test_lpa_fallback.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Step 46 [4/4]: a KIV-2 4925G>A reader that crashes never costs the KIV-2 +# count. scripts/46-lpa.sh then writes lpa/_lpa_kiv2_4925.tsv saying +# 'not assessed', exits 0 and keeps lpa/_lpa_kiv2.tsv; the LPA Nextflow +# process (modules/local/lpa/main.nf) writes the same fallback table, byte for +# byte. The e2e case genotype-rules-2-lpa-null runs the module's block with a +# crashing reader in its image. +# +# A fake `docker` on PATH stands in for the containers: the count call writes +# a table, the 4925G>A call exits 1 (or 0 for the control, writing a +# 'present' table), everything else succeeds. +# +# Controls: with the reader working the table is the reader's ('present'), so +# the not-assessed check can fail; with the fallback removed from a copy of +# the step, the step exits non-zero, so the exit check can fail. +set -euo pipefail + +REPO=$(cd "$(dirname "$0")/.." && pwd) +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +FAILS=0 +fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); } +pass() { echo "ok: $*"; } + +mkdir -p "${WORK}/bin" +cat > "${WORK}/bin/docker" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG" +host() { printf '%s' "${GENOME_DIR}${1#/genome}"; } +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + --table) printf 'metric\tvalue\nstatus\testimated\nkiv2_copies\t38.0\n' > "$(host "${args[i + 1]}")" ;; + --marker-out) + if [ "${FAKE_READER:-crash}" = crash ]; then echo "planted crash" >&2; exit 1; fi + printf 'metric\tvalue\nstatus\tpresent\n' > "$(host "${args[i + 1]}")" ;; + esac +done +exit 0 +FAKE +chmod +x "${WORK}/bin/docker" + +GD="${WORK}/genome" +mkdir -p "${GD}/S1/aligned" "${GD}/reference" +: > "${GD}/S1/aligned/S1_sorted.bam" +: > "${GD}/S1/aligned/S1_sorted.bam.bai" +: > "${GD}/reference/GRCh38_no_alt_analysis_set.fasta" +: > "${GD}/reference/GRCh38_no_alt_analysis_set.fasta.fai" +LOG="${WORK}/docker.log" +NULL="${GD}/S1/lpa/S1_lpa_kiv2_4925.tsv" +COUNT="${GD}/S1/lpa/S1_lpa_kiv2.tsv" + +# run_step