From e985bc5584fe7525dd0250f94246a185a8dac625 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:11:30 +0200 Subject: [PATCH] Assert the door the refresh reads the roster through [#44] roster.Named carried no test. It is the second door on the roster file, the one the refresh reads through before anything can be asked about the repositories a row names, and all three of its refusals were reached by nothing: a file that is not the array of rows, a row naming no repository, and a file holding no row at all. Every one of them could have stopped refusing with the suite green, and what the door hands back goes into the refresh that produces the record the build later reads as answered. Found by reading the survivor and uncovered lists of the mutation run this issue records rather than its score. Of the eight mutants the tool reports as NOT COVERED in this package, four sit in Named. Applied by hand at 3db5bc8, in a clone outside the working tree, each one leaves the suite green: internal/roster/roster.go:217 err != nil -> err == nil internal/roster/roster.go:224 == "" -> != "" internal/roster/roster.go:226 i+1 -> i-1 internal/roster/roster.go:231 len(names) == 0 -> len(names) != 0 The four cases here are one per refusal plus two neighbours: a file breaking nothing reads into the three repositories it declares in order, and a file the parser refuses three times over still comes back out of this door, which is the property that makes it a separate door rather than a flag on the first one. Each of the four mutations above was then applied again with these cases in and produced a red run naming the refusal it removed. The row number is compared against the whole opening of the reason rather than a fragment of it, which is the rule the table beside this one already follows: a reason saying row 1 where the fixture broke the third row still reads as a refusal about a row and sends somebody to the wrong line. The fixture breaks the third row, so counting from zero and counting the wrong row are both separated from the right answer. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- internal/roster/roster_test.go | 99 ++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/internal/roster/roster_test.go b/internal/roster/roster_test.go index b7a53aa..8b9f213 100644 --- a/internal/roster/roster_test.go +++ b/internal/roster/roster_test.go @@ -284,3 +284,102 @@ func TestTheIdentifierRuleLeavesEveryRealShapeAlone(t *testing.T) { } } } + +// The other door, which the refresh reads the file through before anything can +// be asked about the repositories in it. +// +// Nothing asserted any of what follows. `Named` carried no test, so all three +// of its refusals could have stopped refusing with every run staying green, +// and what it hands back goes straight into the refresh that produces the +// record the build then reads as answered. +func TestTheOtherDoorReadsTheRepositoriesARosterNames(t *testing.T) { + got, err := Named([]byte(valid)) + if err != nil { + t.Fatalf("a roster that breaks nothing was refused: %v", err) + } + want := []string{ + "Flowfin/jellyfin-plugin-watchlist", + "Flowfin/jellyfin-plugin-sso", + "Flowfin/jellyfin-plugin-requests", + } + if len(got) != len(want) { + t.Fatalf("the roster read as %d repository(s), and it declares %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("repository %d read as %q, and the file declares %q", i+1, got[i], want[i]) + } + } +} + +// It applies no rule, which is the whole reason it is a second door rather than +// a flag on the first. A file the parser refuses three times over comes back out +// of this one, because the question those rules need answering is the question +// the refresh reading it is on its way to ask. +// +// The neighbour half of this case is the parser refusing the same bytes. Without +// it, a `Named` that had quietly grown a rule would still pass here on a file +// that had stopped being one the parser refuses. +func TestTheOtherDoorJudgesNothingAboutWhatItReads(t *testing.T) { + file := `[{"id":"two words","repository":"Flowfin/jellyfin-plugin-wishes","summary":"","state":"ships"}]` + + if _, err := Parse([]byte(file), there); err == nil { + t.Fatal("the fixture is meant to be one the parser refuses, and the parser read it") + } + got, err := Named([]byte(file)) + if err != nil { + t.Fatalf("a file the parser refuses was refused by the door that applies no rule: %v", err) + } + if len(got) != 1 || got[0] != "Flowfin/jellyfin-plugin-wishes" { + t.Errorf("the file read as %v, and it names one repository", got) + } +} + +// Each thing this door refuses, in the smallest form somebody would actually +// write, each tripping exactly it. +// +// The opening is compared whole rather than by a fragment, for the reason the +// table above gives: a reason saying `row 1` where the fixture broke the third +// row still contains the word row and still reads as a refusal about one, and +// it sends somebody to the wrong line. The two ways of getting that number +// wrong are counting from zero and counting the wrong row, and a fixture on the +// third row separates both from the right answer. +// +// This door stops at the first bad row rather than collecting every reason, so +// one reason is the whole of what a fixture may produce here. That is the +// opposite of the parser, and it is what the refresh needs: the file is being +// read to find out what to ask about, and a list that is wrong anywhere is a +// list nothing should be asked from. +func TestEachRefusalOfTheOtherDoorIsTrippedByItsOwnFixture(t *testing.T) { + for name, c := range map[string]struct{ file, says string }{ + "a file that is not JSON": { + `[{"repository": "Flowfin/jellyfin-plugin-sso",]`, + "the file is not the array of rows this roster has to be"}, + "a file that is an object rather than an array": { + `{"repository": "Flowfin/jellyfin-plugin-sso"}`, + "the file is not the array of rows this roster has to be"}, + "a row naming no repository": { + strings.Replace(valid, `"repository": "Flowfin/jellyfin-plugin-requests"`, `"repository": ""`, 1), + "row 3 names no repository"}, + "a row whose repository is spaces": { + strings.Replace(valid, `"repository": "Flowfin/jellyfin-plugin-requests"`, `"repository": " "`, 1), + "row 3 names no repository"}, + "a file holding no row": { + `[]`, + "the file holds no row"}, + } { + names, err := Named([]byte(c.file)) + if err == nil { + t.Errorf("%s read as %d repository(s) rather than being refused", name, len(names)) + continue + } + got := reasons(t, err) + if len(got) != 1 { + t.Errorf("%s was refused for %d reason(s), and this door stops at the first bad row: %v", name, len(got), got) + continue + } + if !strings.HasPrefix(got[0], c.says) { + t.Errorf("%s was refused with %q, which does not open with %q", name, got[0], c.says) + } + } +}