# Testing ## Pairs that must not collapse Every correctness bug this tool has had is one of a small set of pairs being treated as one thing. They are listed here because the list is short, it has not changed in a long time, and reading it before touching a read path is cheaper than rediscovering an entry: - **absence and ignorance.** "No record says so" and "no record I could see says so" are different facts. A reading that concluded a ticket was open because the index had not caught up wrote over a completion it never saw. A ticket listed without its versions read as a ticket with nothing to check out. - **decided and unknown.** A knot that refuses has done nothing; a knot that dies may have merged. Both were reported as "retry later". - **stale and current.** A write compared a version's recorded base against a branch that a rewrite had already moved. - **identity and incidental context.** Two reports of the same missing record differed only by which source the walk started from, and the writer read that as a new problem. - **one authority for two questions.** A command that reads a ticket's state and a command that writes it used different sources, so a refusal named a rule the write did not enforce. The tool already has the abstraction for the first of these — `Evidence`, `Reach`, `Listing`, and the `?` a listing prints for a state it cannot know — and every instance of it has been in code that did not use one. A value derived from a partial read should carry its reach; where a function returns a bare `Vec` or a bare enum from a listing, that is the thing to check. ## Naming a thing and matching a thing are different questions `repo_did_of` and `repo_label_did_of` read the same record for the same subject and must not be merged. A pull written before repos had DIDs names its target with a `targetRepo` at-uri whose authority is the repo *owner's* account DID: fine to print, because it is the only identifier that record has, and wrong to filter on, because an owner's DID stands for every repo that owner has. The consolidation that merged them passed a build and a clippy run and was caught by `a_pre_target_record_matches_no_repo_did`, a test written for exactly this and now three years of arguments older than the person merging them. Add it to the list at the top: **a label and a key are a pair that must not collapse**, and the tell is a fallback that is "close enough" for one caller. ## The best fix is not to have the partial view `pr checkout` sidesteps "absence and ignorance" for the commit range itself: a version names its base and head oids, so the command fetches the head from the knot that holds it and starts a branch there. An unreadable version stops the checkout rather than producing a working tree for some other commit. Finding that version still involves a listing: the default source is Bobbin, which pages `listPatches`, so the index can be behind and `--source knot` reads the repo's own records instead. Carrying `Evidence` is the right answer when a listing is genuinely needed — "every pull aimed at this repo" cannot be walked link by link. Where the question is "what does this record point at", the links are the answer and no listing should be involved. Ask which kind of question is being asked before reaching for a listing. ## The rule about the rules Every entry above was one rule with several copies, or one rule in the wrong place. So the fix for an instance is not finished when the instance stops happening — it is finished when there is one copy of the rule, somewhere that owns it. Two of these were written twice on the same day. "Absence and ignorance" was fixed in `pr close` and then found again in a listing's claim that a pull stood alone. "Decided and unknown" was fixed in a knot call and then implemented a second time, by hand, in `repo delete`. Both second copies were written by somebody who had just written the first, which is the strongest evidence available that noticing a pattern is not the same as removing it. `knot::decided` is the one definition now, and it takes a tag and a status rather than an error, because its two callers hold different things and the rule is about neither. ## Fixing one copy is not fixing the bug **Every fix starts by grepping for the other implementations.** Not after — before. This was learned the expensive way, seven times: - `target_branch_of` was made strict on one merge path while `pr merge` kept `unwrap_or("main")`, feeding the same `MergePlan` to the same knot call. The doc comment on the strict one even named the bug it was fixing. - "Newest state wins" had *five* orderings; three decided state and all three differed. Two carried doc comments asserting they agreed with each other. - "No PDS endpoint in the DID document" is one sentence with an `Exit::NotFound` owner and six hand-rolled copies that exit `1`. - The `@handle`-or-DID rule was centralised, and copies kept turning up for days afterward. A subagent sweep found three of these in one pass, and grepping properly found two orderings the sweep had missed. So: name the rule, `grep -rn` for every place that decides it, fix them together or write down why one of them is deliberately different. A fix applied to one copy leaves the codebase *less* consistent than before, because now the copies disagree. **Then ask why there were copies.** Grepping is the fix for the instance; it is not the fix for the pattern, and it had been written down here for a while before the pattern kept happening anyway. Every rule on that list is a rule about what a *record* means, and `src/` was organised by verb, so none of them had anywhere to live: `cmd/` is forty thousand lines, and a rule you cannot find is a rule you rewrite. `model/` is where they go now — see docs/module-layout.md. Duplication that keeps coming back is usually a missing module, not a missing habit, and a rule that has an obvious home gets found instead of rewritten. ## A mock that ignores who is asking cannot catch asking the wrong one The mock PDS served every blob to every `did`: `getBlob` looked the CID up in one global map and never read the `did` parameter. Everything about that is convenient and it silently voided a whole class of test. A comment's image is uploaded to the **repo's** knot, while the commenter's PDS holds the attestation and nothing else. A mock that answered the image from the commenter's host would let every test pass, because the harness never had a wrong host to ask. The mock now records who uploaded each blob and 404s anyone else. The rule generalises past blobs: **when a mock collapses an axis the real service distinguishes — which account, which host, which repo — no test can fail along that axis**, and a green suite says nothing about it. A service that answers every question the same way is the empty-service mistake in a costume: see the next section. ## Three accounts, three hosts, and a repo none of them is Tangled is multi-account by construction: tickets, versions and comments live in the repo's account on its knot, SSH keys and attestations live in each author's own PDS, and the knot accepts a write from anyone it lets push. A fixture that does not model that cannot fail along it. The harness used to have two accounts sharing **one** PDS endpoint, so: - "ask the right account's PDS" was a question with one answer; - "write only into your own repository" was unenforced — the mock checked that *a* token was present, never that it belonged to the repo being written to. Now every account is served by its own mock on its own port, answering only for itself; a request naming anyone else gets a 404, and a write whose bearer is not the repo's owner gets a 403. Both are what the real services do. The axes are the point: `Scenario::new` gives Alice, Bob and Carol, and `Scenario::upstream` moves the repo to Carol so the two contributors are aiming at a repository *neither of them owns* — the case where "the repo's account" and "my PDS" stop being the same host, and every rule that tells them apart finally has to be right. Prefer `Scenario::upstream` for anything about ownership, standing, merging somebody else's work, or which host holds a record. A test written against a fixture where the acting account owns everything is a test that cannot tell a correct answer from a lucky one. ## Practices these came out of - **Mutate before believing a test.** Three tests in this suite passed for the wrong reason and were caught by deliberately breaking the code they covered. A green test is evidence only once it has been seen to fail. - **Model a service that fails, not only one that is empty.** An empty answer is evidence about the world; a refused request is evidence about nothing. `World::bobbin_fails`, `knot_fails` and `Checkout::refuse_pushes` exist for this and each found untested handling. `cargo test` runs everything and takes about eight seconds. No spindle is attached to this repo, so `prek run --all-files` plus `cargo test` locally is the actual gate, whatever `.tangled/workflows/ci.yml` claims. **What earns a test.** Three things. Parsers of other people's data. Every `org.tangled.*` record, DID document, Bobbin response and knot redirect is written by software this project does not control, and the code is permissive about them on purpose, which is the kind of parsing that fails quietly. Things that are fiddly out of proportion to their size, like a base64url decoder or character-safe truncation. Short enough to look obviously right, and with no natural manual check. Anything a commit message had to justify, because the paragraph will not survive the next refactor but the test will. Glue does not earn one: `main.rs` dispatch, the layout of a `println!`, the four lines around a request whose parser is already tested. That kind of coverage costs the same to maintain as a real test, goes red for unrelated reasons, and creates the impression that a green suite means the tool works. **Hermetic, non-negotiable.** No network, no OAuth, no session, no reading or writing the real `~/.config/atgc`, no touching the user's git config, no assuming a particular Tangled repo exists. Two traps are specific to this crate. `HOME` cannot be redirected inside the test process, because that needs `std::env::set_var`, which is `unsafe` in edition 2024 and the crate forbids unsafe code. So a function that reads `HOME` itself is not unit-testable; take the directory as a parameter and leave one untested wrapper that names `$HOME`. And the working directory is process-wide while cargo runs tests on threads, so `testutil::TempRepo` takes a module-level mutex and must stay the only thing in the tree that moves the process. Network access is allowed in exactly one place: a test marked `#[ignore]` whose message says why. If an `#[ignore]` ever pins a known bug rather than a slow socket, its message must say so, because "ignored" reads as "unimportant" and a green suite standing over a broken function is worse than a red one. **Fixtures for reads, mocks for writes.** Test a parser against real bytes captured off the real service, never against a mock. A mock encodes what we believe the service returns, so a test of a reader against it passes exactly when our belief is self-consistent, which is the thing already known. A write command asks the opposite question: what atgc *sent*, in what order, as whom. That is atgc's own behaviour, which no fixture can observe. `tests/support/` is the environment for it, with mock services on loopback ports, a session that restores with no network, and the real binary spawned as a child. Spawning a child is what makes an alternate `HOME` possible at all, since `Command::env` is not `unsafe`. Reach for it whenever the bug is about how two commands interact rather than about one function's output: a version submitted against the wrong ticket, a comment written to the wrong repo's knot, a write made with the wrong account's credentials. **Keep this page this short.** Do not add per-feature notes, a list of what is verified by hand, or an inventory of what is not covered. That kind of writing goes stale the week after it lands and nothing checks it. This page once ran to 535 lines and named six source files that no longer existed. A fact about one test belongs in that test's doc comment; how a fixture was captured belongs beside the code that loads it; a claim that something is unproven belongs at the claim, in the code, where a reader is standing when it matters.