Files
scientific-agent-skills/tests/flowio/test_scripts.py
Timothy Kassis 4fb7e0bc29 Test every script-bearing skill, and enforce that it stays that way
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.
2026-07-28 10:20:12 -07:00

459 lines
20 KiB
Python

"""Tests for the FlowIO inspection helper.
`inspect_fcs` exists to read an untrusted FCS file without letting it decide how
much memory to allocate, so the guards are the product: the byte-size ceiling,
the estimated-array ceiling that is enforced *before* DATA is loaded, and the
multi-dataset offset walk that refuses a negative, non-increasing, or
out-of-file `$NEXTDATA` chain. Those tests build the pathological offsets by
stubbing `FlowData`, because a well-formed FCS file cannot express them.
The rest of the suite pins the reported numbers against a file this test wrote:
channel classification (a scatter channel silently reported as fluorescence
misleads every downstream gate), and the per-channel statistics, which must
ignore NaN and +/-inf rather than propagate them -- the values below are
hand-computed from the events written, not read back from the code.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch
import pytest
import skill_contract
SKILL_ROOT = Path(__file__).resolve().parents[2] / "skills" / "flowio"
SCRIPTS = SKILL_ROOT / "scripts"
sys.path.insert(0, str(SCRIPTS))
flowio = pytest.importorskip("flowio", reason="flowio skill needs flowio")
np = pytest.importorskip("numpy", reason="inspect_fcs computes statistics with numpy")
import inspect_fcs # noqa: E402
CliHelpTests = skill_contract.cli.help_test_case(SKILL_ROOT)
NAN = float("nan")
INF = float("inf")
def write_fcs(
path: Path,
events: list[float],
channels: list[str],
optional: list[str] | None = None,
) -> Path:
"""Write a real FCS 3.1 file; `events` is the flattened row-major matrix."""
with path.open("wb") as handle:
flowio.create_fcs(
handle, events, channel_names=channels, opt_channel_names=optional
)
return path
def namespace(**overrides) -> argparse.Namespace:
"""The parsed-argument defaults `inspect_file` reads, overridable per test."""
defaults = dict(
input=Path("unused.fcs"),
output=None,
include_text=False,
include_analysis=False,
stats=False,
raw=False,
sha256=False,
max_bytes=inspect_fcs.DEFAULT_MAX_BYTES,
max_array_bytes=inspect_fcs.DEFAULT_MAX_ARRAY_BYTES,
max_datasets=inspect_fcs.DEFAULT_MAX_DATASETS,
null_channel=[],
ignore_offset_error=False,
ignore_offset_discrepancy=False,
use_header_offsets=False,
)
defaults.update(overrides)
return argparse.Namespace(**defaults)
class ArgumentValidatorTests(unittest.TestCase):
def test_nonnegative_int_admits_zero_because_zero_disables_a_limit(self) -> None:
# --max-bytes 0 is documented as "no ceiling", so zero must parse.
self.assertEqual(inspect_fcs.nonnegative_int("0"), 0)
self.assertEqual(inspect_fcs.nonnegative_int("512"), 512)
def test_nonnegative_int_refuses_a_negative_ceiling(self) -> None:
with self.assertRaises(argparse.ArgumentTypeError):
inspect_fcs.nonnegative_int("-1")
def test_positive_int_refuses_zero_and_below(self) -> None:
# --max-datasets 0 would make the dataset walk yield nothing at all.
self.assertEqual(inspect_fcs.positive_int("1"), 1)
for value in ("0", "-3"):
with self.subTest(value=value):
with self.assertRaises(argparse.ArgumentTypeError):
inspect_fcs.positive_int(value)
def test_non_numeric_limits_raise(self) -> None:
for validator in (inspect_fcs.nonnegative_int, inspect_fcs.positive_int):
with self.subTest(validator=validator.__name__):
with self.assertRaises(ValueError):
validator("lots")
class FcsFileTestCase(unittest.TestCase):
def setUp(self) -> None:
self._temporary = tempfile.TemporaryDirectory()
self.addCleanup(self._temporary.cleanup)
self.root = Path(self._temporary.name)
class ChannelClassificationTests(FcsFileTestCase):
"""`channel_kind` decides what each column *means* to a downstream gate."""
CHANNELS = ["FSC-A", "SSC-A", "FL1-A", "Time"]
def flow(self, null: list[str] | None = None):
path = write_fcs(
self.root / "kinds.fcs",
[1.0, 2.0, 3.0, 4.0],
self.CHANNELS,
optional=["fsc", "ssc", "CD3", "time"],
)
return flowio.FlowData(str(path), null_channel_list=null or [])
def test_scatter_time_and_fluorescence_are_told_apart(self) -> None:
flow = self.flow()
kinds = [inspect_fcs.channel_kind(flow, i) for i in range(len(self.CHANNELS))]
self.assertEqual(kinds, ["scatter", "scatter", "fluorescence", "time"])
def test_a_declared_null_channel_outranks_its_detected_kind(self) -> None:
# --null-channel exists to retire a channel; if the kind still said
# "fluorescence" the column would keep being analysed.
flow = self.flow(null=["FL1-A"])
self.assertEqual(inspect_fcs.channel_kind(flow, 2), "null")
# Nulling one channel must not reclassify the others.
self.assertEqual(inspect_fcs.channel_kind(flow, 0), "scatter")
self.assertEqual(inspect_fcs.channel_kind(flow, 3), "time")
def test_channel_records_number_parameters_from_one(self) -> None:
# FCS $PnN keywords are 1-based; the numpy column index is 0-based.
# Conflating them shifts every reported gain and range by one channel.
records = inspect_fcs.channel_records(self.flow())
self.assertEqual([r["array_index"] for r in records], [0, 1, 2, 3])
self.assertEqual([r["parameter_number"] for r in records], [1, 2, 3, 4])
self.assertEqual([r["pnn"] for r in records], self.CHANNELS)
self.assertEqual([r["pns"] for r in records], ["fsc", "ssc", "CD3", "time"])
def test_channel_records_carry_the_scaling_keywords_as_json_types(self) -> None:
# $PnE is a tuple in FlowIO; JSON cannot hold a tuple, so the record
# must convert it -- otherwise `write_report` fails on real files.
record = inspect_fcs.channel_records(self.flow())[0]
self.assertIsInstance(record["pne"], list)
self.assertEqual(record["pne"], [0.0, 0.0]) # linear scaling
self.assertEqual(record["png"], 1.0) # unity gain
json.dumps(record) # would raise if a tuple survived
class FiniteStatisticsTests(unittest.TestCase):
"""Every value here is computed by hand from the array below."""
EVENTS = np.array(
[
[1.0, NAN, 10.0],
[3.0, INF, 20.0],
[5.0, -INF, 30.0],
[NAN, NAN, 40.0],
]
)
LABELS = ["mixed", "unusable", "clean"]
def setUp(self) -> None:
self.records = inspect_fcs.finite_statistics(self.EVENTS, self.LABELS)
def test_one_record_is_emitted_per_channel_in_order(self) -> None:
self.assertEqual([r["pnn"] for r in self.records], self.LABELS)
self.assertEqual([r["array_index"] for r in self.records], [0, 1, 2])
def test_statistics_are_computed_over_the_finite_values_only(self) -> None:
mixed = self.records[0]
# Finite values are 1, 3, 5: mean 3, min 1, max 5, one NaN dropped.
self.assertEqual(mixed["finite_count"], 3)
self.assertEqual(mixed["nan_count"], 1)
self.assertEqual(mixed["minimum"], 1.0)
self.assertEqual(mixed["maximum"], 5.0)
self.assertEqual(mixed["mean"], 3.0)
def test_infinities_are_counted_by_sign_and_excluded_from_the_range(self) -> None:
unusable = self.records[1]
self.assertEqual(unusable["positive_infinity_count"], 1)
self.assertEqual(unusable["negative_infinity_count"], 1)
self.assertEqual(unusable["nan_count"], 2)
self.assertEqual(unusable["finite_count"], 0)
def test_a_channel_with_no_finite_value_reports_none_not_nan(self) -> None:
# json.dumps(..., allow_nan=False) in write_report rejects NaN, so the
# statistics must degrade to null rather than to a NaN float.
unusable = self.records[1]
for key in ("minimum", "maximum", "mean"):
with self.subTest(key=key):
self.assertIsNone(unusable[key])
json.dumps(self.records, allow_nan=False)
def test_a_fully_finite_channel_keeps_every_event(self) -> None:
clean = self.records[2]
self.assertEqual(clean["finite_count"], 4)
self.assertEqual(clean["nan_count"], 0)
self.assertEqual(clean["mean"], 25.0) # (10+20+30+40)/4
class FakeText(dict):
"""A `FlowData.text` stand-in exposing only what `iter_datasets` reads."""
class FakeFlow:
def __init__(self, nextdata: str) -> None:
self.text = FakeText(nextdata=nextdata)
class DatasetWalkTests(FcsFileTestCase):
"""The `$NEXTDATA` chain is attacker-controlled; the walk must bound it."""
def setUp(self) -> None:
super().setUp()
self.path = self.root / "chain.fcs"
self.path.write_bytes(b"x" * 1000)
def walk(self, nextdata_values: list[str], max_datasets: int = 8):
"""Run `iter_datasets` with FlowData replaced by a scripted stub."""
seen: list[int] = []
remaining = list(nextdata_values)
def fake_flow_data(path, **kwargs):
seen.append(kwargs["nextdata_offset"])
return FakeFlow(remaining.pop(0) if remaining else "0")
with patch.object(inspect_fcs, "FlowData", fake_flow_data):
datasets = list(
inspect_fcs.iter_datasets(
self.path,
max_datasets=max_datasets,
null_channel_list=[],
ignore_offset_error=False,
ignore_offset_discrepancy=False,
use_header_offsets=False,
)
)
return datasets, seen
def test_a_single_dataset_file_stops_after_one(self) -> None:
datasets, offsets = self.walk(["0"])
self.assertEqual(len(datasets), 1)
self.assertEqual(offsets, [0])
self.assertEqual(datasets[0][0], 0) # dataset_index
self.assertEqual(datasets[0][1], 0) # byte offset
def test_nextdata_is_relative_so_offsets_accumulate(self) -> None:
# FCS $NEXTDATA is relative to the start of the current dataset. Two
# hops of 100 must land at 100 then 200, not 100 twice.
datasets, offsets = self.walk(["100", "100", "0"])
self.assertEqual(offsets, [0, 100, 200])
self.assertEqual([index for index, _, _ in datasets], [0, 1, 2])
self.assertEqual([offset for _, offset, _ in datasets], [0, 100, 200])
def test_a_negative_relative_offset_is_refused(self) -> None:
with self.assertRaisesRegex(
flowio.exceptions.MultipleDataSetsError, "negative relative offset"
):
self.walk(["-8", "0"])
def test_an_offset_past_the_end_of_the_file_is_refused(self) -> None:
# The file is 1000 bytes; seeking to 5000 would read foreign memory.
with self.assertRaisesRegex(
flowio.exceptions.MultipleDataSetsError, "outside the input file"
):
self.walk(["5000", "0"])
def test_the_dataset_count_ceiling_is_enforced(self) -> None:
# A chain of 10-byte hops never terminates on its own; --max-datasets
# is the only thing that stops it.
with self.assertRaisesRegex(
flowio.exceptions.MultipleDataSetsError, r"--max-datasets limit \(3\)"
):
self.walk(["10"] * 20, max_datasets=3)
class InspectFileTests(FcsFileTestCase):
"""`inspect_file` is the whole report; its guards run before DATA loads."""
def setUp(self) -> None:
super().setUp()
# 3 events x 2 channels of known values.
self.path = write_fcs(
self.root / "sample.fcs",
[1.0, 10.0, 2.0, 20.0, 3.0, 30.0],
["FSC-A", "FL1-A"],
)
def test_the_report_describes_the_file_that_was_written(self) -> None:
report = inspect_fcs.inspect_file(namespace(input=self.path))
self.assertEqual(report["dataset_count"], 1)
self.assertEqual(report["file_name"], "sample.fcs")
self.assertEqual(report["size_bytes"], self.path.stat().st_size)
dataset = report["datasets"][0]
self.assertEqual(dataset["event_count"], 3)
self.assertEqual(dataset["channel_count"], 2)
self.assertEqual(dataset["fcs_version"], "3.1")
def test_the_array_estimate_is_events_times_channels_times_eight(self) -> None:
# float64 is 8 bytes; this estimate is what --max-array-bytes compares
# against, so an understated one defeats the ceiling.
report = inspect_fcs.inspect_file(namespace(input=self.path))
self.assertEqual(report["datasets"][0]["estimated_array_bytes"], 3 * 2 * 8)
def test_metadata_only_is_the_default_and_says_so(self) -> None:
report = inspect_fcs.inspect_file(namespace(input=self.path))
self.assertTrue(report["parse_options"]["metadata_only"])
dataset = report["datasets"][0]
self.assertNotIn("statistics", dataset)
self.assertEqual(dataset["event_semantics"], "not loaded; metadata-only inspection")
def test_stats_loads_events_and_reports_the_array_shape(self) -> None:
report = inspect_fcs.inspect_file(namespace(input=self.path, stats=True))
dataset = report["datasets"][0]
self.assertEqual(dataset["array_shape"], [3, 2])
self.assertFalse(report["parse_options"]["metadata_only"])
first, second = dataset["statistics"]
self.assertEqual((first["minimum"], first["maximum"]), (1.0, 3.0))
self.assertEqual((second["minimum"], second["maximum"]), (10.0, 30.0))
def test_raw_stats_are_labelled_as_encoded_values(self) -> None:
# The semantics string is how a reader knows whether gain/log scaling
# was applied; mislabelling it invalidates every downstream comparison.
report = inspect_fcs.inspect_file(namespace(input=self.path, stats=True, raw=True))
self.assertIn("preprocess=False", report["datasets"][0]["event_semantics"])
scaled = inspect_fcs.inspect_file(namespace(input=self.path, stats=True))
self.assertIn("uncompensated", scaled["datasets"][0]["event_semantics"])
def test_text_and_analysis_are_withheld_unless_requested(self) -> None:
# TEXT can carry patient identifiers, so it is opt-in.
default = inspect_fcs.inspect_file(namespace(input=self.path))
self.assertNotIn("text", default["datasets"][0])
self.assertGreater(default["datasets"][0]["metadata_summary"]["text_key_count"], 0)
# FlowIO normalises TEXT keywords to lowercase without the `$`.
included = inspect_fcs.inspect_file(namespace(input=self.path, include_text=True))
self.assertEqual(included["datasets"][0]["text"]["p1n"], "FSC-A")
def test_a_file_over_the_byte_ceiling_is_refused_before_parsing(self) -> None:
with self.assertRaisesRegex(ValueError, "--max-bytes"):
inspect_fcs.inspect_file(namespace(input=self.path, max_bytes=10))
def test_a_zero_byte_ceiling_disables_the_check(self) -> None:
report = inspect_fcs.inspect_file(namespace(input=self.path, max_bytes=0))
self.assertEqual(report["parse_options"]["max_bytes"], 0)
def test_the_array_ceiling_only_applies_with_stats(self) -> None:
# Metadata-only inspection allocates no array, so a tiny ceiling must
# not block it -- and must block --stats.
tiny = namespace(input=self.path, max_array_bytes=8)
inspect_fcs.inspect_file(tiny)
with self.assertRaisesRegex(ValueError, "--max-array-bytes"):
inspect_fcs.inspect_file(namespace(input=self.path, stats=True, max_array_bytes=8))
def test_a_ceiling_equal_to_the_estimate_is_accepted(self) -> None:
# The comparison is strictly-greater, so the boundary must pass.
report = inspect_fcs.inspect_file(
namespace(input=self.path, stats=True, max_array_bytes=48)
)
self.assertEqual(report["datasets"][0]["array_shape"], [3, 2])
def test_a_missing_input_raises_file_not_found(self) -> None:
with self.assertRaises(FileNotFoundError):
inspect_fcs.inspect_file(namespace(input=self.root / "absent.fcs"))
def test_a_directory_is_not_a_regular_file(self) -> None:
with self.assertRaisesRegex(ValueError, "not a regular file"):
inspect_fcs.inspect_file(namespace(input=self.root))
def test_the_checksum_is_opt_in_and_matches_the_streaming_helper(self) -> None:
without = inspect_fcs.inspect_file(namespace(input=self.path))
self.assertNotIn("sha256", without)
with_sum = inspect_fcs.inspect_file(namespace(input=self.path, sha256=True))
self.assertEqual(with_sum["sha256"], inspect_fcs.sha256_file(self.path))
def test_the_checksum_matches_hashlib_on_the_same_bytes(self) -> None:
expected = hashlib.sha256(self.path.read_bytes()).hexdigest()
self.assertEqual(inspect_fcs.sha256_file(self.path), expected)
class ReportWritingTests(FcsFileTestCase):
def test_stdout_receives_parseable_json(self) -> None:
buffer = io.StringIO()
with redirect_stdout(buffer):
inspect_fcs.write_report({"schema_version": "1.0"}, None)
self.assertEqual(json.loads(buffer.getvalue()), {"schema_version": "1.0"})
def test_an_existing_output_file_is_never_overwritten(self) -> None:
output = self.root / "report.json"
output.write_text("keep me", encoding="utf-8")
with self.assertRaises(FileExistsError):
inspect_fcs.write_report({"a": 1}, output)
self.assertEqual(output.read_text(encoding="utf-8"), "keep me")
def test_a_non_finite_number_is_refused_rather_than_written_as_nan(self) -> None:
# Bare NaN is not valid JSON; writing it would produce a file that
# every conforming parser rejects.
with self.assertRaises(ValueError):
inspect_fcs.write_report({"mean": NAN}, self.root / "nan.json")
class MainTests(FcsFileTestCase):
def setUp(self) -> None:
super().setUp()
self.path = write_fcs(self.root / "main.fcs", [1.0, 2.0], ["FSC-A", "FL1-A"])
def test_a_clean_run_exits_zero_and_writes_the_requested_file(self) -> None:
output = self.root / "out.json"
self.assertEqual(inspect_fcs.main([str(self.path), "--output", str(output)]), 0)
report = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(report["datasets"][0]["event_count"], 1)
def test_raw_without_stats_is_rejected_by_the_parser(self) -> None:
# --raw only changes how events are decoded, so it is meaningless
# without --stats; accepting it would silently do nothing.
with self.assertRaises(SystemExit) as raised:
inspect_fcs.main([str(self.path), "--raw"])
self.assertEqual(raised.exception.code, 2)
def test_writing_the_report_over_the_input_fcs_is_rejected(self) -> None:
with self.assertRaises(SystemExit) as raised:
inspect_fcs.main([str(self.path), "--output", str(self.path)])
self.assertEqual(raised.exception.code, 2)
# The input must survive intact.
self.assertGreater(self.path.stat().st_size, 0)
def test_a_missing_input_exits_two_with_a_message_not_a_traceback(self) -> None:
errors = io.StringIO()
with redirect_stderr(errors):
status = inspect_fcs.main([str(self.root / "absent.fcs")])
self.assertEqual(status, 2)
self.assertIn("inspect_fcs:", errors.getvalue())
self.assertNotIn("Traceback", errors.getvalue())
def test_a_file_that_is_not_fcs_at_all_exits_two(self) -> None:
junk = self.root / "notes.txt"
junk.write_text("this is not an FCS file", encoding="utf-8")
with redirect_stderr(io.StringIO()):
self.assertEqual(inspect_fcs.main([str(junk)]), 2)
if __name__ == "__main__":
unittest.main()