diff --git a/pyproject.toml b/pyproject.toml index c5fbd4ea..80f4ba02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,6 @@ packages = ["src"] # (including node_modules) during collection. testpaths = ["tests"] timeout = 300 +markers = [ + "unit: unit tests that do not require network access", +] diff --git a/src/babel_validation/assertions/README.md b/src/babel_validation/assertions/README.md new file mode 100644 index 00000000..c52712cb --- /dev/null +++ b/src/babel_validation/assertions/README.md @@ -0,0 +1,262 @@ + + +# BabelTest Assertion Types + +This package defines the assertion types that can be embedded in GitHub issue bodies and evaluated against the NodeNorm and NameRes services. + +## Embedding Tests in Issues + +Two syntaxes are supported: + +**Wiki syntax** (one assertion per line): +``` +{{BabelTest|AssertionType|param1|param2|...}} +``` + +**YAML syntax** (multiple assertions, multiple params lists): +```` +```yaml +babel_tests: + AssertionType: + - param1 + - [param1, param2] +``` +```` + +Assertion names are case-insensitive, as is the `{{BabelTest|...}}` marker itself. + +## Params Lists + +Each assertion can be invoked with one or more **params lists** — independent groups of +parameters that are each evaluated separately. + +- **Wiki syntax** — each `{{BabelTest|...}}` line is one params list. +- **YAML syntax** — each list entry under an assertion key is one params list; a bare string + is a single-element params list, a YAML list is a multi-element params list. + +The meaning of each element in a params list depends on the assertion type (see below). +For most assertions the elements are CURIEs; for `HasLabel` the second element is a +label string; for `ResolvesWithType` the first element is a Biolink type. + +--- + +## NodeNorm Assertions + +These assertions test the [NodeNorm](https://nodenorm.transltr.io/docs) service. + +### Resolves + +**Applies to:** NodeNorm + +Each CURIE in each params_list must resolve to a non-null result in NodeNorm. + +**Parameters:** One or more CURIEs per params_list. + +**Wiki syntax:** +``` +{{BabelTest|Resolves|CHEBI:15365}} +{{BabelTest|Resolves|MONDO:0005015|DOID:9351}} +``` + +**YAML syntax:** +```yaml +babel_tests: + Resolves: + - CHEBI:15365 + - [MONDO:0005015, DOID:9351] +``` + +--- + +### DoesNotResolve + +**Applies to:** NodeNorm + +Each CURIE in each params_list must fail to resolve (return null) in NodeNorm. Use this to confirm that an identifier is intentionally not normalizable. + +**Parameters:** One or more CURIEs per params_list. + +**Wiki syntax:** +``` +{{BabelTest|DoesNotResolve|FAKENS:99999}} +``` + +**YAML syntax:** +```yaml +babel_tests: + DoesNotResolve: + - FAKENS:99999 +``` + +--- + +### ResolvesWith + +**Applies to:** NodeNorm + +All CURIEs within each params_list must resolve to the identical normalized result. Use this to assert that two identifiers are equivalent. + +**Parameters:** Two or more CURIEs per params_list. All must resolve to the same result. + +**Wiki syntax:** +``` +{{BabelTest|ResolvesWith|CHEBI:15365|PUBCHEM.COMPOUND:1}} +``` + +**YAML syntax:** +```yaml +babel_tests: + ResolvesWith: + - [CHEBI:15365, PUBCHEM.COMPOUND:1] + - [MONDO:0005015, DOID:9351] +``` + +--- + +### DoesNotResolveWith + +**Applies to:** NodeNorm + +The CURIEs within each params_list must NOT all resolve to the same normalized result. Use this to assert that two identifiers are intentionally distinct entities. + +**Parameters:** Two or more CURIEs per params_list. They must not all resolve to the same result. + +**Wiki syntax:** +``` +{{BabelTest|DoesNotResolveWith|CHEBI:15365|CHEBI:16856}} +``` + +**YAML syntax:** +```yaml +babel_tests: + DoesNotResolveWith: + - [CHEBI:15365, CHEBI:16856] +``` + +--- + +### HasLabel + +**Applies to:** NodeNorm + +The CURIE must resolve in NodeNorm and its primary label (id.label) must match the expected label exactly (case-sensitive). + +**Parameters:** Exactly two elements per params_list: a CURIE, then the expected label string. + +**Wiki syntax:** +``` +{{BabelTest|HasLabel|CHEBI:15365|aspirin}} +``` + +**YAML syntax:** +```yaml +babel_tests: + HasLabel: + - [CHEBI:15365, aspirin] +``` + +--- + +### ResolvesWithType + +**Applies to:** NodeNorm + +Each params_list must have at least two elements: the first is the expected Biolink type (e.g. 'biolink:Gene'), and the remainder are CURIEs that must resolve with that type. + +**Parameters:** Each params_list: first element is the expected Biolink type (e.g. `biolink:Gene`), remaining elements are CURIEs. + +**Wiki syntax:** +``` +{{BabelTest|ResolvesWithType|biolink:Gene|NCBIGene:1}} +``` + +**YAML syntax:** +```yaml +babel_tests: + ResolvesWithType: + - [biolink:Gene, NCBIGene:1, HGNC:5] +``` + +--- + +## NameRes Assertions + +These assertions test the [NameRes](https://name-lookup.transltr.io/docs) service. + +### SearchByName + +**Applies to:** NameRes + +Each params_list must have exactly two elements: a search query string and an expected CURIE. The test passes if the CURIE's normalized identifier appears within the top N results (default N=5) when NameRes looks up the search query. + +**Parameters:** Each params_list: the **search query string** and the **expected CURIE**. The CURIE is normalized via NodeNorm before matching. + +**Wiki syntax:** +``` +{{BabelTest|SearchByName|water|CHEBI:15377}} +``` + +**YAML syntax:** +```yaml +babel_tests: + SearchByName: + - [water, CHEBI:15377] + - [diabetes, MONDO:0005015] +``` + +--- + +## Special Assertions + +### Needed + +**Applies to:** NodeNorm and NameRes + +Marks an issue as needing a test — always fails as a reminder to add real assertions. + +**Wiki syntax:** +``` +{{BabelTest|Needed}} +``` + +**YAML syntax:** +```yaml +babel_tests: + Needed: + - placeholder +``` + +--- + +## Adding a New Assertion Type + +1. Choose the right module: + - `nodenorm.py` — for NodeNorm-only assertions (subclass `NodeNormTest`, override `test_params_list`) + - `nameres.py` — for NameRes-only assertions (subclass `NameResTest`, override `test_params_list`) + - `common.py` — for assertions that apply to both services (subclass `AssertionHandler`, override `test_with_nodenorm` and/or `test_with_nameres`) + +2. Give the class its five documentation attributes: + - `NAME` — **must be all lowercase.** Assertions are matched case-insensitively by + lowercasing whatever the issue wrote, so a `NAME` containing any uppercase could + never be matched. Registration rejects it rather than letting it fail silently. + - `DESCRIPTION` — one line, shown under the heading here. + - `PARAMETERS` — what each element of a params_list means, and how many are expected. + - `WIKI_EXAMPLES` — complete `{{BabelTest|...}}` lines, reproduced verbatim. + - `YAML_PARAMS` — indented list entries for the YAML example. + + These are rendered into this file, so write them for someone reading this README + rather than for someone reading the class. + +3. Implement `test_params_list()` (or both `test_with_*` methods for `AssertionHandler` + subclasses). It receives one params_list at a time, already stripped and — unless the + handler sets `VALIDATE_CURIES = False` — with its CURIEs validated and pre-warmed in + the NodeNorm cache. Yield one result per thing checked, usually one per CURIE, so a + failure names the CURIE that failed. Override `curie_params()` if some params are not + CURIEs; see `HasLabel` and `SearchByName`. + +4. Import it in `__init__.py` and add an instance to `ASSERTION_HANDLERS`. Order does not + matter — this file groups handlers by the service they test. + +5. Run `uv run python -m src.babel_validation.assertions.gen_docs` to regenerate `README.md`, + and `uv run pytest -m unit` to confirm the checked-in copy is in sync. diff --git a/src/babel_validation/assertions/__init__.py b/src/babel_validation/assertions/__init__.py new file mode 100644 index 00000000..a99b85be --- /dev/null +++ b/src/babel_validation/assertions/__init__.py @@ -0,0 +1,331 @@ +""" +babel_validation.assertions +=========================== + +This package defines the assertion types that can be embedded in GitHub issue bodies +and evaluated against the NodeNorm and NameRes services. + +Supported assertion types are registered in ASSERTION_HANDLERS. To see everything +that is currently supported, scan that dict or read assertions/README.md. + +**Adding a new assertion type: see the "Adding a New Assertion Type" section of +assertions/README.md.** That section is generated from gen_docs.ADDING_NEW, and is +the one place those instructions live — a second copy here would drift out of step +with it, which is exactly what happened to the copy this note replaced. + +The layout, for orientation while reading the code: + +- AssertionHandler — the base class, and the strip/validate/warm machinery every + assertion shares (prepare_params_lists). +- NodeNormTest / NameResTest — specialize it per service; subclasses override + test_params_list() and are handed one params_list at a time. +- nodenorm.py, nameres.py, common.py — the concrete handlers. +- gen_docs.py — renders README.md from the handler classes. +""" + +import re +from dataclasses import dataclass +from typing import Iterator + +from src.babel_validation.core.testrow import TestResult, TestStatus +from src.babel_validation.services.nameres import NameResService +from src.babel_validation.services.nodenorm import NodeNormService + +# The parameters of a single assertion invocation, e.g. ["CHEBI:15365", "aspirin"] +# for {{BabelTest|HasLabel|CHEBI:15365|aspirin}}. What each element means depends +# on the assertion, and position is significant: ResolvesWithType takes its +# Biolink type first, HasLabel is [curie, label]. See the handler's PARAMETERS. +ParamsList = list[str] + + +@dataclass(frozen=True) +class PreparedParamsList: + """One params_list after stripping and validation, ready to be evaluated. + + *failure* is None when the params_list is usable. When it is set, the + params_list was rejected before reaching the service and *failure* is the + TestResult to report in its place. + """ + params: ParamsList + failure: TestResult | None = None + + +class AssertionHandler: + """Base class for all BabelTest assertion handlers. + + A handler is a stateless singleton: one instance per assertion type lives in + ASSERTION_HANDLERS and is shared by every issue being evaluated. Do not store + per-evaluation state on ``self``. + + Every handler declares the five documentation attributes below; gen_docs.py + renders README.md from them, so they are part of the handler's contract + rather than optional commentary. + """ + + NAME: str # lowercase assertion name as used in issue bodies + DESCRIPTION: str # one-line human-readable description + PARAMETERS: str # markdown describing what each param means + WIKI_EXAMPLES: list[str] # complete {{BabelTest|...}} lines, shown verbatim + YAML_PARAMS: str # indented YAML list entries for the babel_tests example + + # Whether CURIE params should be rejected up front if they are not well-formed. + # Assertions about deliberately-invalid identifiers turn this off. + VALIDATE_CURIES = True + + _CURIE_RE = re.compile(r'^[A-Za-z][A-Za-z0-9._-]*:[^\s]+$') + + def passed(self, message: str) -> TestResult: + """Build a passing TestResult. Handlers use this rather than TestResult directly.""" + return TestResult(status=TestStatus.Passed, message=message) + + def failed(self, message: str) -> TestResult: + """Build a failing TestResult. Handlers use this rather than TestResult directly.""" + return TestResult(status=TestStatus.Failed, message=message) + + def curie_params(self, params: ParamsList) -> ParamsList: + """Return the subset of params that are CURIEs (for prewarming and validation). + Default: all params are CURIEs. Subclasses override when some params are non-CURIEs.""" + return params + + def prepare_params_lists(self, params_lists: list[ParamsList], + nodenorm: NodeNormService, + label: str = "") -> list[PreparedParamsList]: + """Strip params, reject unusable params_lists, and warm the NodeNorm cache. + + :param params_lists: the params_lists to prepare, as parsed from the issue. + :param nodenorm: service whose cache is warmed with every CURIE about to be + looked up, so the per-params_list evaluation costs no further HTTP calls. + :param label: human-readable identifier for the source being evaluated (an + issue number, a test name); appears in failure messages so a reader can + tell which assertion produced them. + :returns: one PreparedParamsList per input params_list, in order, each either + carrying stripped params or a failure explaining why it was rejected. + + Rejected params_lists are excluded from cache warming, so (unless + VALIDATE_CURIES is off) malformed CURIEs are never sent to NodeNorm. + """ + prepared = [] + for index, params in enumerate(params_lists): + stripped = [param.strip() for param in params] + prepared.append(PreparedParamsList(stripped, self._rejection(index, stripped, label))) + + # Warm the cache in a single request, deduplicated; skip if empty + # (normalize_curies raises ValueError on an empty list). + curies_to_warm = list({ + curie + for p in prepared if p.failure is None + for curie in self.curie_params(p.params) + }) + if curies_to_warm: + nodenorm.normalize_curies(curies_to_warm) + + return prepared + + def _rejection(self, index: int, params: ParamsList, label: str) -> TestResult | None: + """Why *params* cannot be evaluated, or None if it can be.""" + if not params: + return self.failed(f"No parameters in params_list {index} in {label}") + if not self.VALIDATE_CURIES: + return None + invalid = [c for c in self.curie_params(params) if not self._CURIE_RE.match(c)] + if invalid: + return self.failed( + f"Malformed CURIE(s) {invalid} in params_list {index} in {label}: " + f"expected format PREFIX:LOCAL_ID (e.g. CHEBI:15365)" + ) + return None + + def test_with_nodenorm(self, params_lists: list[ParamsList], + nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + """Evaluate this assertion against NodeNorm, yielding one TestResult per check. + + The base implementation yields nothing, which is how an assertion declares + it has no NodeNorm meaning: a caller runs every handler against both + services and an empty iterator simply contributes no results. + + :param params_lists: every params_list this assertion was invoked with; each + is evaluated independently, so one bad params_list does not sink the rest. + :param nodenorm: the NodeNorm service to evaluate against. Typically a + CachedNodeNorm for a specific deployment (dev, prod, ...), which is what + makes the same assertion runnable against several environments. + :param label: human-readable identifier for the source being evaluated; see + prepare_params_lists(). + """ + return iter([]) + + def test_with_nameres(self, params_lists: list[ParamsList], + nodenorm: NodeNormService, nameres: NameResService, + pass_if_found_in_top: int = 5, + label: str = "") -> Iterator[TestResult]: + """Evaluate this assertion against NameRes, yielding one TestResult per check. + + As with test_with_nodenorm(), yielding nothing means "not applicable". + + NameRes assertions get *both* services: NameRes answers the lookup, and + NodeNorm normalizes the expected CURIE so that a lookup result can be + compared against it by canonical identifier rather than by exact string. + + :param params_lists: every params_list this assertion was invoked with. + :param nodenorm: used to normalize expected CURIEs before comparison. + :param nameres: the NameRes service to evaluate against. + :param pass_if_found_in_top: how far down the ranked results the expected + CURIE may appear and still count as a pass. Also caps the number of + results requested from NameRes. + :param label: human-readable identifier for the source being evaluated. + """ + return iter([]) + + +class NodeNormTest(AssertionHandler): + """Base class for assertions that test NodeNorm. + + Subclasses implement test_params_list() instead of test_with_nodenorm(). + """ + + def test_with_nodenorm(self, params_lists: list[ParamsList], + nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + if not params_lists: + yield self.failed(f"No parameters provided in {label}") + return + results = [] + for prepared in self.prepare_params_lists(params_lists, nodenorm, label): + if prepared.failure: + results.append(prepared.failure) + continue + results.extend(self.test_params_list(prepared.params, nodenorm, label)) + if not results: + yield self.failed(f"No test results returned in {label}") + return + yield from results + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + """Override this to implement the assertion. Called once per params_list. + + *params* is non-empty and already stripped, and (unless VALIDATE_CURIES is + off) every param that curie_params() selects is a well-formed CURIE, so + implementations need only check assertion-specific shape such as arity. + Every CURIE is also pre-warmed in *nodenorm*'s cache, so normalize_curie() + calls here are free. + + Yield one TestResult per thing checked — usually one per CURIE — rather + than a single aggregate, so a failure report names the CURIE that failed. + + :param params: this params_list's parameters; see the handler's PARAMETERS. + :param nodenorm: the NodeNorm service to evaluate against. + :param label: human-readable identifier for the source being evaluated. + """ + raise NotImplementedError + + # Stand-in for the Biolink type in a message when NodeNorm returned none. + # Deliberately unlike any real type: current ones are prefixed ("biolink:Gene") + # and older ones were lowercase prose ("chemical entity"), so shouting it in + # caps keeps a reader from mistaking the placeholder for a type Babel returned. + NO_TYPE = 'NO TYPE RETURNED' + + @staticmethod + def first_type(result: dict) -> str: + """First Biolink type of a resolved node, or NO_TYPE if the node has none. + + NodeNorm normally returns a non-empty `type` list, but guard against an empty + (or missing) one so message formatting never raises IndexError/KeyError.""" + types = result.get('type') or [] + return types[0] if types else NodeNormTest.NO_TYPE + + def resolved_message(self, curie: str, result: dict, + nodenorm: NodeNormService) -> str: + """Standard pass-message when a CURIE resolves. + + *result* is one entry of a NodeNorm get_normalized_nodes response, i.e. a + non-None value from normalize_curie()/normalize_curies(). + """ + return (f"Resolved {curie} to {result['id']['identifier']} " + f"({self.first_type(result)}, \"{result['id'].get('label', '')}\") " + f"with NodeNormalization service {nodenorm}") + + +class NameResTest(AssertionHandler): + """Base class for assertions that test NameRes. + + Subclasses implement test_params_list() instead of test_with_nameres(). + """ + + def test_with_nameres(self, params_lists: list[ParamsList], + nodenorm: NodeNormService, nameres: NameResService, + pass_if_found_in_top: int = 5, + label: str = "") -> Iterator[TestResult]: + if not params_lists: + yield self.failed(f"No parameters provided in {label}") + return + results = [] + for prepared in self.prepare_params_lists(params_lists, nodenorm, label): + if prepared.failure: + results.append(prepared.failure) + continue + results.extend( + self.test_params_list(prepared.params, nodenorm, nameres, pass_if_found_in_top, label)) + if not results: + yield self.failed(f"No test results returned in {label}") + return + yield from results + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + nameres: NameResService, pass_if_found_in_top: int, + label: str = "") -> Iterator[TestResult]: + """Override this to implement the assertion. Called once per params_list. + + *params* is non-empty and already stripped, with the params that + curie_params() selects validated as CURIEs and pre-warmed in *nodenorm*'s + cache. See NodeNormTest.test_params_list() for the shared contract; the + arguments are documented on test_with_nameres(). + """ + raise NotImplementedError + + +# Registry — import submodules after base classes are defined to avoid circular imports. +from src.babel_validation.assertions.nodenorm import ( # noqa: E402 + ResolvesHandler, DoesNotResolveHandler, ResolvesWithHandler, + ResolvesWithTypeHandler, DoesNotResolveWithHandler, HasLabelHandler, +) +from src.babel_validation.assertions.nameres import SearchByNameHandler # noqa: E402 +from src.babel_validation.assertions.common import NeededHandler # noqa: E402 + +def _register(handlers: list[AssertionHandler]) -> dict[str, AssertionHandler]: + """Index *handlers* by NAME, rejecting what a dict comprehension would hide. + + Assertion names are matched case-insensitively by lowercasing the name used + in the issue, so a NAME that is not already lowercase can never be looked up. + A duplicate NAME would silently drop one of the two handlers. Both are + mistakes only made while adding an assertion, so fail loudly at import. + """ + registry: dict[str, AssertionHandler] = {} + for handler in handlers: + name = handler.NAME + if not name or name != name.lower(): + raise ValueError( + f"{type(handler).__name__}.NAME must be a non-empty lowercase string, got {name!r}" + ) + if name in registry: + raise ValueError( + f"{type(handler).__name__}.NAME {name!r} is already registered " + f"by {type(registry[name]).__name__}" + ) + registry[name] = handler + return registry + + +# Every assertion type the parser will recognise, keyed by its lowercase NAME. +# Registration order is irrelevant — README.md groups handlers by the service they +# test, not by their position here. +ASSERTION_HANDLERS: dict[str, AssertionHandler] = _register([ + ResolvesHandler(), + DoesNotResolveHandler(), + ResolvesWithHandler(), + DoesNotResolveWithHandler(), + HasLabelHandler(), + ResolvesWithTypeHandler(), + SearchByNameHandler(), + NeededHandler(), +]) diff --git a/src/babel_validation/assertions/common.py b/src/babel_validation/assertions/common.py new file mode 100644 index 00000000..a170386e --- /dev/null +++ b/src/babel_validation/assertions/common.py @@ -0,0 +1,28 @@ +from typing import Iterator + +from src.babel_validation.assertions import AssertionHandler, ParamsList +from src.babel_validation.core.testrow import TestResult +from src.babel_validation.services.nameres import NameResService +from src.babel_validation.services.nodenorm import NodeNormService + + +class NeededHandler(AssertionHandler): + """Placeholder assertion indicating that a test still needs to be written for this issue.""" + NAME = "needed" + DESCRIPTION = "Marks an issue as needing a test — always fails as a reminder to add real assertions." + PARAMETERS = "" + WIKI_EXAMPLES = ["{{BabelTest|Needed}}"] + YAML_PARAMS = " - placeholder" + + # Applies to both services, and ignores its params entirely: the assertion + # records that a test is missing, so there is nothing to evaluate. + def test_with_nodenorm(self, params_lists: list[ParamsList], + nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + yield self.failed("Test needed for issue") + + def test_with_nameres(self, params_lists: list[ParamsList], + nodenorm: NodeNormService, nameres: NameResService, + pass_if_found_in_top: int = 5, + label: str = "") -> Iterator[TestResult]: + yield self.failed("Test needed for issue") diff --git a/src/babel_validation/assertions/gen_docs.py b/src/babel_validation/assertions/gen_docs.py new file mode 100644 index 00000000..3d4eb413 --- /dev/null +++ b/src/babel_validation/assertions/gen_docs.py @@ -0,0 +1,186 @@ +"""Generate assertions/README.md from handler class attributes. + +Run: + uv run python -m src.babel_validation.assertions.gen_docs +""" + +from pathlib import Path + +from src.babel_validation.assertions import ( + ASSERTION_HANDLERS, AssertionHandler, NodeNormTest, NameResTest, +) + +README_PATH = Path(__file__).parent / "README.md" + +INTRO = """\ + + +# BabelTest Assertion Types + +This package defines the assertion types that can be embedded in GitHub issue bodies and evaluated against the NodeNorm and NameRes services. + +## Embedding Tests in Issues + +Two syntaxes are supported: + +**Wiki syntax** (one assertion per line): +``` +{{BabelTest|AssertionType|param1|param2|...}} +``` + +**YAML syntax** (multiple assertions, multiple params lists): +```` +```yaml +babel_tests: + AssertionType: + - param1 + - [param1, param2] +``` +```` + +Assertion names are case-insensitive, as is the `{{BabelTest|...}}` marker itself. + +## Params Lists + +Each assertion can be invoked with one or more **params lists** — independent groups of +parameters that are each evaluated separately. + +- **Wiki syntax** — each `{{BabelTest|...}}` line is one params list. +- **YAML syntax** — each list entry under an assertion key is one params list; a bare string + is a single-element params list, a YAML list is a multi-element params list. + +The meaning of each element in a params list depends on the assertion type (see below). +For most assertions the elements are CURIEs; for `HasLabel` the second element is a +label string; for `ResolvesWithType` the first element is a Biolink type. + +--- +""" + +ADDING_NEW = """\ +## Adding a New Assertion Type + +1. Choose the right module: + - `nodenorm.py` — for NodeNorm-only assertions (subclass `NodeNormTest`, override `test_params_list`) + - `nameres.py` — for NameRes-only assertions (subclass `NameResTest`, override `test_params_list`) + - `common.py` — for assertions that apply to both services (subclass `AssertionHandler`, override `test_with_nodenorm` and/or `test_with_nameres`) + +2. Give the class its five documentation attributes: + - `NAME` — **must be all lowercase.** Assertions are matched case-insensitively by + lowercasing whatever the issue wrote, so a `NAME` containing any uppercase could + never be matched. Registration rejects it rather than letting it fail silently. + - `DESCRIPTION` — one line, shown under the heading here. + - `PARAMETERS` — what each element of a params_list means, and how many are expected. + - `WIKI_EXAMPLES` — complete `{{BabelTest|...}}` lines, reproduced verbatim. + - `YAML_PARAMS` — indented list entries for the YAML example. + + These are rendered into this file, so write them for someone reading this README + rather than for someone reading the class. + +3. Implement `test_params_list()` (or both `test_with_*` methods for `AssertionHandler` + subclasses). It receives one params_list at a time, already stripped and — unless the + handler sets `VALIDATE_CURIES = False` — with its CURIEs validated and pre-warmed in + the NodeNorm cache. Yield one result per thing checked, usually one per CURIE, so a + failure names the CURIE that failed. Override `curie_params()` if some params are not + CURIEs; see `HasLabel` and `SearchByName`. + +4. Import it in `__init__.py` and add an instance to `ASSERTION_HANDLERS`. Order does not + matter — this file groups handlers by the service they test. + +5. Run `uv run python -m src.babel_validation.assertions.gen_docs` to regenerate `README.md`, + and `uv run pytest -m unit` to confirm the checked-in copy is in sync. +""" + +_GROUP_HEADERS: dict[str, str] = { + "NodeNorm": ( + "## NodeNorm Assertions\n\n" + "These assertions test the [NodeNorm](https://nodenorm.transltr.io/docs) service." + ), + "NameRes": ( + "## NameRes Assertions\n\n" + "These assertions test the [NameRes](https://name-lookup.transltr.io/docs) service." + ), + "NodeNorm and NameRes": "## Special Assertions", +} + + +def _display_name(h: AssertionHandler) -> str: + """The assertion name as written in issues (ResolvesHandler -> "Resolves"). + + Derived from the class name rather than NAME, which is lowercased for + case-insensitive matching and so reads poorly as a heading. + """ + return type(h).__name__.removesuffix("Handler") + + +def _applies_to(h: AssertionHandler) -> str: + """Which service(s) this handler tests; also the key into _GROUP_HEADERS. + + A handler that subclasses neither base overrides the test_with_* methods + directly and so applies to both. + """ + if isinstance(h, NodeNormTest): + return "NodeNorm" + if isinstance(h, NameResTest): + return "NameRes" + return "NodeNorm and NameRes" + + +def _render_handler(h: AssertionHandler) -> str: + """Render one handler's README section from its documentation attributes. + + Reads them with getattr defaults so that a handler missing one still renders + (as an empty section) instead of breaking the whole README. + """ + name = _display_name(h) + service = _applies_to(h) + description = getattr(h, "DESCRIPTION", "") + parameters = getattr(h, "PARAMETERS", "") + wiki_examples = getattr(h, "WIKI_EXAMPLES", []) + yaml_params = getattr(h, "YAML_PARAMS", "") + + parts = [] + parts.append(f"### {name}\n") + parts.append(f"**Applies to:** {service}\n") + parts.append(f"{description}\n") + + if parameters: + parts.append(f"**Parameters:** {parameters}\n") + + wiki_block = "\n".join(wiki_examples) + parts.append(f"**Wiki syntax:**\n```\n{wiki_block}\n```\n") + + parts.append( + f"**YAML syntax:**\n```yaml\nbabel_tests:\n {name}:\n{yaml_params}\n```\n" + ) + + parts.append("---\n") + + return "\n".join(parts) + + +def generate_readme() -> str: + """Render the complete README.md content. Pure — writing it is the caller's job. + + Kept side-effect free so test_assertions_docs.py can compare the rendered + output against the checked-in file without touching the filesystem. + """ + sections = [INTRO] + + # Group by service rather than by registration order, so a handler added + # anywhere in ASSERTION_HANDLERS still renders under the right heading. + for service, header in _GROUP_HEADERS.items(): + handlers = [h for h in ASSERTION_HANDLERS.values() if _applies_to(h) == service] + if not handlers: + continue + sections.append(header + "\n") + sections.extend(_render_handler(h) for h in handlers) + + sections.append(ADDING_NEW) + return "\n".join(sections) + + +if __name__ == "__main__": + content = generate_readme() + README_PATH.write_text(content, encoding="utf-8") + print(f"Written to {README_PATH}") diff --git a/src/babel_validation/assertions/nameres.py b/src/babel_validation/assertions/nameres.py new file mode 100644 index 00000000..19d9f99d --- /dev/null +++ b/src/babel_validation/assertions/nameres.py @@ -0,0 +1,67 @@ +import json +import logging +from typing import Iterator + +from src.babel_validation.assertions import NameResTest, ParamsList +from src.babel_validation.core.testrow import TestResult +from src.babel_validation.services.nameres import NameResService +from src.babel_validation.services.nodenorm import NodeNormService + + +class SearchByNameHandler(NameResTest): + """Test that a name search returns an expected CURIE in the top-N results in NameRes.""" + NAME = "searchbyname" + DESCRIPTION = ( + "Each params_list must have exactly two elements: a search query string and an expected CURIE. " + "The test passes if the CURIE's normalized identifier appears within the top N results " + "(default N=5) when NameRes looks up the search query." + ) + PARAMETERS = ( + "Each params_list: the **search query string** and the **expected CURIE**. " + "The CURIE is normalized via NodeNorm before matching." + ) + WIKI_EXAMPLES = ["{{BabelTest|SearchByName|water|CHEBI:15377}}"] + YAML_PARAMS = " - [water, CHEBI:15377]\n - [diabetes, MONDO:0005015]" + + def curie_params(self, params: ParamsList) -> ParamsList: + # params[0] is a free-text search query; only the expected CURIE is a CURIE. + # Slicing (rather than indexing) keeps malformed params_lists out of validation + # so test_params_list() can report the arity problem instead. + return params[1:2] + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + nameres: NameResService, pass_if_found_in_top: int = 5, + label: str = "") -> Iterator[TestResult]: + if len(params) != 2: + yield self.failed( + f"SearchByName requires exactly two parameters (search query, expected CURIE) in {label}, " + f"but got {len(params)}: {params}" + ) + return + + [search_query, expected_curie_from_test] = params + expected_curie_result = nodenorm.normalize_curie(expected_curie_from_test) + if not expected_curie_result: + yield self.failed(f"Unable to normalize CURIE {expected_curie_from_test} in {label}") + return + + expected_curie = expected_curie_result['id']['identifier'] + expected_curie_label = expected_curie_result['id'].get('label', '') + expected_curie_string = f"Expected CURIE {expected_curie_from_test}, normalized to {expected_curie} '{expected_curie_label}'" + + results = nameres.lookup(search_query, autocomplete='false', limit=pass_if_found_in_top) + if not results: + yield self.failed(f"No results found for '{search_query}' on NameRes {nameres} ({expected_curie_string})") + return + + curies = [result['curie'] for result in results] + if expected_curie not in curies: + logging.getLogger(__name__).debug( + "%s not found in top %d results for '%s' in NameRes %s: %s", + expected_curie_string, pass_if_found_in_top, search_query, nameres, + json.dumps(results, indent=2, sort_keys=True) + ) + yield self.failed(f"{expected_curie_string} not found in top {pass_if_found_in_top} results for '{search_query}' in NameRes {nameres}") + return + + yield self.passed(f"{expected_curie_string} found at index {curies.index(expected_curie) + 1} on NameRes {nameres}") diff --git a/src/babel_validation/assertions/nodenorm.py b/src/babel_validation/assertions/nodenorm.py new file mode 100644 index 00000000..1e6f0306 --- /dev/null +++ b/src/babel_validation/assertions/nodenorm.py @@ -0,0 +1,260 @@ +from typing import Iterator + +from src.babel_validation.assertions import NodeNormTest, ParamsList +from src.babel_validation.core.testrow import TestResult +from src.babel_validation.services.nodenorm import NodeNormService + + +class ResolvesHandler(NodeNormTest): + """Test that every CURIE in every params_list resolves in NodeNorm.""" + NAME = "resolves" + DESCRIPTION = "Each CURIE in each params_list must resolve to a non-null result in NodeNorm." + PARAMETERS = "One or more CURIEs per params_list." + WIKI_EXAMPLES = [ + "{{BabelTest|Resolves|CHEBI:15365}}", + "{{BabelTest|Resolves|MONDO:0005015|DOID:9351}}", + ] + YAML_PARAMS = " - CHEBI:15365\n - [MONDO:0005015, DOID:9351]" + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + for curie in params: + result = nodenorm.normalize_curie(curie) + if not result: + yield self.failed(f"Could not resolve {curie} with NodeNormalization service {nodenorm}") + else: + yield self.passed(self.resolved_message(curie, result, nodenorm)) + + +class DoesNotResolveHandler(NodeNormTest): + """Test that every CURIE in every params_list does NOT resolve in NodeNorm.""" + NAME = "doesnotresolve" + DESCRIPTION = ( + "Each CURIE in each params_list must fail to resolve (return null) in NodeNorm. " + "Use this to confirm that an identifier is intentionally not normalizable." + ) + PARAMETERS = "One or more CURIEs per params_list." + WIKI_EXAMPLES = ["{{BabelTest|DoesNotResolve|FAKENS:99999}}"] + YAML_PARAMS = " - FAKENS:99999" + + # A param that isn't even a well-formed CURIE trivially does not resolve, and + # asserting that is the whole point of this assertion — so don't reject it. + VALIDATE_CURIES = False + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + for curie in params: + result = nodenorm.normalize_curie(curie) + if not result: + yield self.passed(f"Could not resolve {curie} with NodeNormalization service {nodenorm} as expected") + else: + yield self.failed(f"Resolved {curie} to {result['id']['identifier']} ({self.first_type(result)}, \"{result['id'].get('label', '')}\") with NodeNormalization service {nodenorm}, but expected not to resolve") + + +def _compare_resolutions( + params: ParamsList, nodenorm: NodeNormService +) -> tuple[dict | None, dict[str, dict | None]]: + """Resolve all params; return (first_good_result, per_curie_results). + + Shared by ResolvesWith and DoesNotResolveWith, which ask the same question + (do these CURIEs agree?) and differ only in which answer they expect. + + first_good_result is None if every CURIE failed to resolve; otherwise it is + the result of the earliest param that resolved, and serves as the canonical + result the others are compared against. + per_curie_results maps each CURIE to its result (None if unresolvable). + """ + # normalize_curies() guarantees one entry per requested CURIE, in the order + # requested, so first_good is deterministically the first param that resolved. + per_curie = nodenorm.normalize_curies(params) + first_good = next((r for r in per_curie.values() if r is not None), None) + return first_good, per_curie + + +class ResolvesWithHandler(NodeNormTest): + """Test that all CURIEs in a params_list resolve to the same normalized result in NodeNorm.""" + NAME = "resolveswith" + DESCRIPTION = ( + "All CURIEs within each params_list must resolve to the identical normalized result. " + "Use this to assert that two identifiers are equivalent." + ) + PARAMETERS = "Two or more CURIEs per params_list. All must resolve to the same result." + WIKI_EXAMPLES = ["{{BabelTest|ResolvesWith|CHEBI:15365|PUBCHEM.COMPOUND:1}}"] + YAML_PARAMS = " - [CHEBI:15365, PUBCHEM.COMPOUND:1]\n - [MONDO:0005015, DOID:9351]" + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + if len(params) < 2: + yield self.failed( + f"ResolvesWith requires at least two CURIEs per params_list in {label}, " + f"but got {len(params)}: {params}" + ) + return + + first_good, results = _compare_resolutions(params, nodenorm) + + if first_good is None: + yield self.failed(f"None of the CURIEs {params} could be resolved on {nodenorm}") + return + + canonical_id = first_good['id']['identifier'] + + for curie, result in results.items(): + if result is None: + yield self.failed( + f"CURIE {curie} could not be resolved on {nodenorm}" + ) + elif result['id']['identifier'] == canonical_id: + yield self.passed( + f"Resolved {curie} to the expected canonical identifier {canonical_id}" + ) + else: + yield self.failed( + f"Resolved {curie} to {result['id']['identifier']} " + f"({self.first_type(result)}, \"{result['id'].get('label', '')}\"), but expected " + f"{canonical_id} " + f"({self.first_type(first_good)}, \"{first_good['id'].get('label', '')}\") on {nodenorm}" + ) + + +class DoesNotResolveWithHandler(NodeNormTest): + """Test that not all CURIEs in a params_list resolve to the same result in NodeNorm.""" + NAME = "doesnotresolvewith" + DESCRIPTION = ( + "The CURIEs within each params_list must NOT all resolve to the same normalized " + "result. Use this to assert that two identifiers are intentionally distinct entities." + ) + PARAMETERS = "Two or more CURIEs per params_list. They must not all resolve to the same result." + WIKI_EXAMPLES = ["{{BabelTest|DoesNotResolveWith|CHEBI:15365|CHEBI:16856}}"] + YAML_PARAMS = " - [CHEBI:15365, CHEBI:16856]" + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + if len(params) < 2: + yield self.failed( + f"DoesNotResolveWith requires at least two CURIEs per params_list in {label}, " + f"but got {len(params)}: {params}" + ) + return + + first_good, results = _compare_resolutions(params, nodenorm) + + # Every CURIE must resolve — an unresolved CURIE is a configuration error. + unresolved = [curie for curie, result in results.items() if result is None] + if unresolved: + yield self.failed( + f"CURIEs {unresolved} could not be resolved on {nodenorm}; " + f"all CURIEs in a DoesNotResolveWith params_list must resolve" + ) + return + + # All resolved — check that they don't all map to the same canonical identifier. + canonical_ids = {result['id']['identifier'] for result in results.values()} + + if len(canonical_ids) == 1: + # Every CURIE maps to the same result — assertion fails. + shared = first_good + yield self.failed( + f"All CURIEs {params} resolved to the same result " + f"{shared['id']['identifier']} " + f"({self.first_type(shared)}, \"{shared['id'].get('label', '')}\") on {nodenorm}, " + f"but expected them to resolve differently" + ) + else: + summary = ", ".join( + f"{curie} → {result['id']['identifier']}" + for curie, result in results.items() + ) + yield self.passed( + f"CURIEs resolve to different results as expected: {summary} on {nodenorm}" + ) + + +class HasLabelHandler(NodeNormTest): + """Test that a CURIE resolves to a specific primary label in NodeNorm.""" + NAME = "haslabel" + DESCRIPTION = ( + "The CURIE must resolve in NodeNorm and its primary label (id.label) must " + "match the expected label exactly (case-sensitive)." + ) + PARAMETERS = "Exactly two elements per params_list: a CURIE, then the expected label string." + WIKI_EXAMPLES = ["{{BabelTest|HasLabel|CHEBI:15365|aspirin}}"] + YAML_PARAMS = " - [CHEBI:15365, aspirin]" + + def curie_params(self, params: ParamsList) -> ParamsList: + return params[:1] + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + if len(params) != 2: + yield self.failed( + f"HasLabel requires exactly two parameters (CURIE, expected label) in {label}, " + f"but got {len(params)}: {params}" + ) + return + + curie = params[0] + expected_label = params[1].strip() + + result = nodenorm.normalize_curie(curie) + if not result: + yield self.failed( + f"Could not resolve {curie} on {nodenorm}" + ) + return + + if 'label' not in result['id']: + yield self.failed( + f"CURIE {curie} has no label but expected '{expected_label}' on {nodenorm}" + ) + return + + actual_label = result['id']['label'] + if actual_label == expected_label: + yield self.passed( + f"CURIE {curie} has expected label '{actual_label}' on {nodenorm}" + ) + else: + yield self.failed( + f"CURIE {curie} has label '{actual_label}', " + f"but expected '{expected_label}' on {nodenorm}" + ) + + +class ResolvesWithTypeHandler(NodeNormTest): + """Test that CURIEs resolve with a specific Biolink type in NodeNorm.""" + NAME = "resolveswithtype" + DESCRIPTION = ( + "Each params_list must have at least two elements: the first is the expected Biolink type " + "(e.g. 'biolink:Gene'), and the remainder are CURIEs that must resolve with that type." + ) + PARAMETERS = ( + "Each params_list: first element is the expected Biolink type (e.g. `biolink:Gene`), " + "remaining elements are CURIEs." + ) + WIKI_EXAMPLES = ["{{BabelTest|ResolvesWithType|biolink:Gene|NCBIGene:1}}"] + YAML_PARAMS = " - [biolink:Gene, NCBIGene:1, HGNC:5]" + + def curie_params(self, params: ParamsList) -> ParamsList: + return params[1:] + + def test_params_list(self, params: ParamsList, nodenorm: NodeNormService, + label: str = "") -> Iterator[TestResult]: + if len(params) < 2: + yield self.failed(f"Too few parameters provided in params_list in {label}: {params}") + return + + expected_biolink_type = params[0] + curies = params[1:] + + results = nodenorm.normalize_curies(curies) + for curie in curies: + node = results.get(curie) + if not node: + yield self.failed(f"Could not resolve {curie} with NodeNormalization service {nodenorm}") + continue + biolink_types = node.get('type') or [] + if expected_biolink_type in biolink_types: + yield self.passed(f"Biolink types {biolink_types} for CURIE {curie} includes expected Biolink type {expected_biolink_type}") + else: + yield self.failed(f"Biolink types {biolink_types} for CURIE {curie} does not include expected Biolink type {expected_biolink_type}") diff --git a/src/babel_validation/services/nodenorm.py b/src/babel_validation/services/nodenorm.py index 9ba3242b..abf4006b 100644 --- a/src/babel_validation/services/nodenorm.py +++ b/src/babel_validation/services/nodenorm.py @@ -63,15 +63,18 @@ def normalize_curies(self, curies: list[str], **params) -> dict[str, dict | None """Normalize *curies* in bulk, returning a ``{curie: result}`` mapping. Already-cached CURIEs are served from the cache; the remainder are - fetched from NodeNorm in a single HTTP POST to ``get_normalized_nodes``. - The response is merged with the cached results before returning. + fetched from NodeNorm in a single HTTP POST to ``get_normalized_nodes`` + and cached. The return value is then assembled from the cache. *curies* must be a non-empty list — the NodeNorm API rejects empty requests, so this method raises ``ValueError`` immediately. - Values in the returned dict are ``None`` for CURIEs NodeNorm could not - resolve. Use this as the cache-warming call; subsequent - ``normalize_curie()`` calls for these identifiers will be free. + The returned dict has exactly one entry per requested CURIE, in the + order requested, with a value of ``None`` for CURIEs NodeNorm could not + resolve or silently omitted from its response. Callers may therefore + iterate it and trust that every CURIE they asked about is represented. + Use this as the cache-warming call; subsequent ``normalize_curie()`` + calls for these identifiers will be free. """ if not curies: raise ValueError(f"curies must not be empty when calling normalize_curies({curies}, {params}) on {self}") @@ -85,7 +88,6 @@ def normalize_curies(self, curies: list[str], **params) -> dict[str, dict | None curies_to_be_queried = curies_set - cached_curies # Make query. - result = {} if curies_to_be_queried: api_params = dict(params) api_params['curies'] = list(curies_to_be_queried) @@ -98,14 +100,15 @@ def normalize_curies(self, curies: list[str], **params) -> dict[str, dict | None for curie in curies_to_be_queried: self.cache[(curie, params_key)] = result.get(curie, None) - for curie in cached_curies: - result[curie] = self.cache[(curie, params_key)] - time_taken_sec = (time.time_ns() - time_started) / 1E9 self.logger.info("Normalizing %d CURIEs %s (with %d CURIEs cached) with params %s on %s in %.3fs", len(curies_to_be_queried), curies_to_be_queried, len(cached_curies), params, self, time_taken_sec) - return result + # Build the result from *curies*, not from the response: NodeNorm may + # silently omit a requested CURIE, and a missing key is invisible to a + # caller that iterates the returned dict. Every CURIE is in the cache by + # this point, either from a previous call or from the loop above. + return {curie: self.cache[(curie, params_key)] for curie in curies} def normalize_curie(self, curie: str, **params) -> dict | None: """Normalize a single *curie*, returning the NodeNorm result or ``None``. diff --git a/tests/test_environment/test_assertions.py b/tests/test_environment/test_assertions.py new file mode 100644 index 00000000..9359b888 --- /dev/null +++ b/tests/test_environment/test_assertions.py @@ -0,0 +1,166 @@ +"""Unit tests for the assertion handlers, with NodeNorm's HTTP layer stubbed out. + +These run against the real CachedNodeNorm so that the bulk-normalization contract +(one entry per requested CURIE) is exercised, not just re-stated by a fake. +""" + +import pytest + +from src.babel_validation.assertions import ASSERTION_HANDLERS, NodeNormTest, _register +from src.babel_validation.assertions.gen_docs import generate_readme +from src.babel_validation.assertions.nodenorm import ( + DoesNotResolveHandler, DoesNotResolveWithHandler, ResolvesHandler, ResolvesWithHandler, +) +from src.babel_validation.core.testrow import TestStatus +from src.babel_validation.services import nodenorm as nodenorm_service + + +def _node(identifier, label): + return {'id': {'identifier': identifier, 'label': label}, 'type': ['biolink:SmallMolecule']} + + +# A:1 and B:1 are equivalent; C:1 is a distinct entity; D:1 is dropped from the +# response entirely, which is what NodeNorm does for some unknown identifiers. +FAKE_NODENORM_DB = { + 'A:1': _node('A:1', 'alpha'), + 'B:1': _node('A:1', 'alpha'), + 'C:1': _node('C:1', 'gamma'), +} + + +@pytest.fixture +def nodenorm(monkeypatch): + """A CachedNodeNorm backed by FAKE_NODENORM_DB, with a .post_count attribute.""" + calls = [] + + class FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + def fake_post(url, json=None, timeout=None): + calls.append(json['curies']) + return FakeResponse({c: FAKE_NODENORM_DB[c] for c in json['curies'] if c in FAKE_NODENORM_DB}) + + monkeypatch.setattr(nodenorm_service.requests, 'post', fake_post) + service = nodenorm_service.CachedNodeNorm('http://fake-nodenorm.example/') + service.post_calls = calls + return service + + +def _messages(results): + return [(r.status, r.message) for r in results] + + +@pytest.mark.unit +def test_normalize_curies_covers_every_requested_curie(nodenorm): + """NodeNorm omitting a CURIE must surface as None, not as a missing key.""" + results = nodenorm.normalize_curies(['A:1', 'D:1']) + assert list(results) == ['A:1', 'D:1'] + assert results['D:1'] is None + + +@pytest.mark.unit +def test_resolves_with_fails_on_omitted_curie(nodenorm): + results = list(ResolvesWithHandler().test_with_nodenorm([['A:1', 'D:1']], nodenorm, 'test')) + failures = [m for status, m in _messages(results) if status == TestStatus.Failed] + assert any('D:1' in m for m in failures), _messages(results) + + +@pytest.mark.unit +def test_does_not_resolve_with_fails_on_omitted_curie(nodenorm): + """The 'every CURIE must resolve' guard must see the dropped CURIE.""" + results = list(DoesNotResolveWithHandler().test_with_nodenorm([['A:1', 'C:1', 'D:1']], nodenorm, 'test')) + assert all(status == TestStatus.Failed for status, _ in _messages(results)), _messages(results) + assert any('D:1' in m for _, m in _messages(results)) + + +@pytest.mark.unit +def test_resolves_with_blames_the_odd_curie_out(nodenorm): + """The canonical identifier comes from the first param, so C:1 is the failure.""" + results = list(ResolvesWithHandler().test_with_nodenorm([['A:1', 'B:1', 'C:1']], nodenorm, 'test')) + failures = [m for status, m in _messages(results) if status == TestStatus.Failed] + assert len(failures) == 1 and failures[0].startswith('Resolved C:1'), _messages(results) + + +@pytest.mark.unit +def test_does_not_resolve_accepts_a_malformed_identifier(nodenorm): + """A junk identifier is exactly what DoesNotResolve exists to assert about.""" + results = list(DoesNotResolveHandler().test_with_nodenorm([['not a curie']], nodenorm, 'test')) + assert [status for status, _ in _messages(results)] == [TestStatus.Passed], _messages(results) + + +@pytest.mark.unit +def test_surrounding_whitespace_is_stripped(nodenorm): + results = list(ResolvesHandler().test_with_nodenorm([[' A:1 ']], nodenorm, 'test')) + assert [status for status, _ in _messages(results)] == [TestStatus.Passed], _messages(results) + + +@pytest.mark.unit +def test_search_by_name_validates_and_warms_before_calling_nodenorm(nodenorm): + """The NameRes path gets the same CURIE validation as the NodeNorm path.""" + handler = ASSERTION_HANDLERS['searchbyname'] + results = list(handler.test_with_nameres([['water', 'not a curie']], nodenorm, None, 5, 'test')) + assert [status for status, _ in _messages(results)] == [TestStatus.Failed], _messages(results) + assert nodenorm.post_calls == [] + + +class TempGroupingHandler(NodeNormTest): + """Registered last, after the NameRes handlers, only by test_docs_group_handlers_*.""" + NAME = 'tempgrouping' + DESCRIPTION = 'Temporary handler used to check README grouping.' + PARAMETERS = '' + WIKI_EXAMPLES = ['{{BabelTest|TempGrouping|A:1}}'] + YAML_PARAMS = ' - A:1' + + +@pytest.mark.unit +def test_docs_group_handlers_by_service_not_registration_order(): + """A NodeNorm handler registered last must still render under NodeNorm.""" + ASSERTION_HANDLERS[TempGroupingHandler.NAME] = TempGroupingHandler() + try: + readme = generate_readme() + finally: + del ASSERTION_HANDLERS[TempGroupingHandler.NAME] + assert '### TempGrouping' in readme + assert readme.index('### TempGrouping') < readme.index('## NameRes Assertions') + + +@pytest.mark.unit +def test_missing_biolink_type_placeholder_cannot_pass_for_a_real_type(): + """Nodes do carry types normally, but the stand-in must not read as one of them.""" + assert NodeNormTest.first_type({'type': ['biolink:Gene']}) == 'biolink:Gene' + for typeless in ({}, {'type': []}, {'type': None}): + placeholder = NodeNormTest.first_type(typeless) + assert placeholder == NodeNormTest.NO_TYPE + # Unlike a current type (biolink:Gene) or a legacy one (chemical entity). + assert not placeholder.startswith('biolink:') + assert placeholder.isupper() + + +@pytest.mark.unit +def test_registration_rejects_names_that_could_never_be_matched(): + """Assertion lookup lowercases the issue's name, so an uppercase NAME is unreachable.""" + class UppercaseHandler(TempGroupingHandler): + NAME = 'Resolves' + + with pytest.raises(ValueError, match='lowercase'): + _register([UppercaseHandler()]) + + +@pytest.mark.unit +def test_registration_rejects_a_duplicate_name(): + """A dict comprehension would silently drop one of the two handlers.""" + with pytest.raises(ValueError, match='already registered'): + _register([TempGroupingHandler(), TempGroupingHandler()]) + + +@pytest.mark.unit +def test_registered_handlers_satisfy_those_rules(): + assert all(name == name.lower() for name in ASSERTION_HANDLERS) + assert len(ASSERTION_HANDLERS) == 8 diff --git a/tests/test_environment/test_assertions_docs.py b/tests/test_environment/test_assertions_docs.py new file mode 100644 index 00000000..03e5b908 --- /dev/null +++ b/tests/test_environment/test_assertions_docs.py @@ -0,0 +1,14 @@ +import pytest + +from src.babel_validation.assertions.gen_docs import generate_readme, README_PATH + + +@pytest.mark.unit +def test_assertions_readme_is_up_to_date(): + expected = generate_readme() + actual = README_PATH.read_text(encoding="utf-8").replace("\r\n", "\n") + assert actual == expected, ( + "assertions/README.md is out of date.\n" + "Regenerate it with:\n" + " uv run python -m src.babel_validation.assertions.gen_docs" + )