Artifact e748a3ede578539af3f6e795515251ae4e37b46718eed47c0dfd7cea933fb3ac:
- File
conformance/repostate_test.go
— part of check-in
[7fa0b9b5da]
at
2026-10-11 02:13:19
on branch trunk
— test(conformance): repository-state conformance suite (#262) (#295)
* test(conformance): repository-state conformance suite (#262)
Check go-libfossil against draft-fossil-repo-state-00, requirement by requirement. Each derived-state case is a scripted sequence of artifacts loaded into go-libfossil and into fossil(1); the tables derived from it are compared row by row, joined by artifact hash. Every case runs through manifest.Crosslink, as one sweep and one artifact at a time, and most also run as a ".rebuild" case through verify.Rebuild. fossil's rows must equal each expectation on every run, and each case states what ignoring its requirement would change, so a case cannot pass or fail by accident.
API cases cover name resolution, leaves, the ambiguity test, branch creation and listing, and tagging, each compared with fossil's answer on the same repository. TestRepoStateCorpus compares verify.Rebuild and a Crosslink sweep with fossil rebuild on real repositories named by CONFORMANCE_REPOS.
Known failures are marked with the placeholder Gap #0 until their issues are filed, so TestCoverage refuses them for now.
* test(conformance): repository-state suite review fixes (#262)
- Unsorted T-card cases use a non-hex name, so the ordering rule, not the hex-name rule, is what rejects them. - RS-4.2-7 crosslinks every artifact a second time over the rows the first left (repostate.4.2.relink-unchanged), instead of a sweep that links nothing. - RS-7.3-2, -3 and -4 are checked through manifest.Checkin and branch.Create, which maintain leaves one check-in at a time; a crosslink sweep recomputes the whole table. - retag-branch is built so the retagged check-in's own leaf status changes; branch membership is read through BranchLeaves, and the tagxref case it replaced is reattributed to RS-5.4-5; bad-prefix also compares tagxref. - parentless-untagged follows the draft and marks go-libfossil's crosslink, which follows fossil(1), as a deviation; the unused draft field becomes fossil, checked against fossil's rows. - Every fossil(1) command, in all suites, runs with HOME, FOSSIL_HOME and XDG_CONFIG_HOME in the test's directory, so it no longer records checkouts in the user's global configuration.
* test(conformance): mark repository-state gaps with their issues (#262)
Replace the placeholder gap marks with the issues that track each fix: #278 and #285-#294. (user: [email protected], size: 8293) [annotate] [blame] [check-ins using]
package conformance import ( "fmt" "slices" "sort" "strings" "testing" ) // The repository-state suite checks draft-fossil-repo-state-00. Most of its // cases are a scripted sequence of artifacts and the derived rows the draft // says loading it produces. Every case is also loaded into fossil(1), whose // rows must equal the expectation, so an expectation cannot drift from // fossil unnoticed. Each case runs through manifest.Crosslink, once as one // sweep and once per artifact; most also run as a ".rebuild" case through // verify.Rebuild on the same artifacts. // rsCase is one scripted sequence and the rows it must derive. type rsCase struct { Case arts []rsArt // tables are the tables compared; a table with no want rows must be // empty. tags, when set, limits tagxref to those tag names. tables []string tags []string // want is every expected row of the compared tables, "table: row". want []string // ignoring is what the compared rows would be if go-libfossil ignored // the requirement the case names: "+table: row" for a row it would // add, "-table: row" for one it would lack. TestRepoStateReasons // checks that it differs from want, so a case cannot pass by accident. ignoring []string // each loads one artifact at a time only, for cases where arrival // order is the point. each bool // rebuild adds a ".rebuild" case: the same artifacts, stored without // crosslinking, then derived by verify.Rebuild. rebuild bool // doctor edits the tables partway through a per-artifact load. doctor *rsDoctor // incremental says why fossil's per-artifact crosslink is not the // reference for this case; `fossil rebuild` is used instead, and the // case fails once the two agree. incremental string // rebuildWrites says why `fossil rebuild` is not the reference for the // ".rebuild" case: it stores artifacts of its own. fossil's // per-artifact result is used instead. rebuildWrites string // fossil is set only when fossil(1) derives other rows than want: the // case follows the draft text, and fossil says why and names the // erratum proposed. The case fails once fossil agrees with want. fossil string } // rsVariant is one way of loading a case's artifacts into go-libfossil. type rsVariant struct { Case loads []rsLoad } // variants lists the crosslink case and, when asked for, the rebuild case. func (c rsCase) variants() []rsVariant { crosslink := rsVariant{Case: c.Case, loads: []rsLoad{loadSweep, loadEach}} if c.each || c.doctor != nil { crosslink.loads = []rsLoad{loadEach} } crosslink.Gap = repostateGaps[c.ID] crosslink.Deviation = repostateDeviations[c.ID] all := []rsVariant{crosslink} if c.rebuild { rebuilt := rsVariant{Case: c.Case, loads: []rsLoad{loadRebuild}} rebuilt.ID = c.ID + ".rebuild" rebuilt.Gap = repostateGaps[rebuilt.ID] rebuilt.Deviation = repostateDeviations[rebuilt.ID] all = append(all, rebuilt) } return all } // expected groups want by table. func (c rsCase) expected() rsState { state := rsState{} for _, table := range c.tables { state[table] = nil } for _, line := range c.want { table, row, ok := strings.Cut(line, ": ") if !ok { panic("conformance: want row without a table: " + line) } if _, listed := state[table]; !listed { panic("conformance: " + c.ID + " wants a row of unlisted table " + table) } state[table] = append(state[table], row) } for table := range state { sort.Strings(state[table]) } return state } func TestRepoState(t *testing.T) { ref := newFossilReference(t) loaded := map[string]*rsFossil{} for _, c := range repostateCases() { script := rsBuild(c.arts) for _, v := range c.variants() { check(t, v.Case, func() error { if ref != nil { if err := c.fossilAgrees(ref, script, v, loaded); err != nil { return err } } return c.goConforms(t, script, v) }) } } } // goConforms loads the script each way the variant names and compares the // rows with want. func (c rsCase) goConforms(t *testing.T, script rsScript, v rsVariant) error { var problems []string for _, load := range v.loads { path, err := goLoad(t, script, load, c.doctor) if err != nil { problems = append(problems, fmt.Sprintf("%s: %v", load, err)) continue } got, err := readState(path, script.labels, c.tables, c.tags) if err != nil { return err } if d := diffState(c.expected(), got, "want", load.String()); d != "" { problems = append(problems, d) } } if len(problems) > 0 { return expectf("%s", strings.Join(problems, " | ")) } return nil } // rsFossil is what fossil(1) derived from one case's script: per artifact, // and by `fossil rebuild` on a copy, with whether that rebuild stored // artifacts of its own. type rsFossil struct { incremental rsState rebuilt rsState wrote bool } // fossilAgrees checks want against fossil(1): its per-artifact crosslink // for a crosslink case, its rebuild for a rebuild case, unless the case // says why the other is the reference. loaded caches fossil's rows by case. func (c rsCase) fossilAgrees( ref *fossilReference, script rsScript, v rsVariant, loaded map[string]*rsFossil, ) error { f, err := c.fossilLoad(ref, script, loaded) if err != nil { return err } isRebuild := v.loads[0] == loadRebuild reference, name := f.incremental, "fossil per-artifact" if c.incremental != "" && !isRebuild { if diffState(c.expected(), f.incremental, "want", "fossil") == "" { return expectf("fossil's per-artifact crosslink now gives want; remove incremental: %q", c.incremental) } reference, name = f.rebuilt, "fossil rebuild" } if isRebuild { if f.wrote != (c.rebuildWrites != "") { return expectf("fossil rebuild stored artifacts: %v; the case says %q", f.wrote, c.rebuildWrites) } if !f.wrote { reference, name = f.rebuilt, "fossil rebuild" } } d := diffState(c.expected(), reference, "want", name) switch { case c.fossil != "" && d == "": return expectf("%s now gives want; remove fossil: %q", name, c.fossil) case c.fossil == "" && d != "": return expectf("fossil(1) disagrees with the case: %s", d) } return nil } // fossilLoad loads the script into fossil(1) once per case and reads both // its per-artifact and its rebuilt rows. func (c rsCase) fossilLoad( ref *fossilReference, script rsScript, loaded map[string]*rsFossil, ) (*rsFossil, error) { if f, ok := loaded[c.ID]; ok { return f, nil } repo, err := ref.loadScript(script, c.doctor) if err != nil { return nil, err } f := &rsFossil{} if f.incremental, err = readState(repo.path, script.labels, c.tables, c.tags); err != nil { return nil, err } if c.doctor == nil { path, wrote, err := ref.rebuildCopy(repo) if err != nil { return nil, err } if f.rebuilt, err = readState(path, script.labels, c.tables, c.tags); err != nil { return nil, err } f.wrote = wrote } loaded[c.ID] = f return f, nil } // TestRepoStateReasons checks that every case says what ignoring its // requirement would change, and that the change is visible in the rows it // compares: a row it would add is not wanted, one it would lack is. func TestRepoStateReasons(t *testing.T) { for _, c := range repostateCases() { if len(c.ignoring) == 0 { t.Errorf("%s: no ignoring rows", c.ID) } want := c.expected() for _, line := range c.ignoring { sign, rest := line[:1], line[1:] table, row, ok := strings.Cut(rest, ": ") rows, listed := want[table] switch { case !ok || (sign != "+" && sign != "-"): t.Errorf("%s: ignoring %q is not \"+table: row\" or \"-table: row\"", c.ID, line) case !listed: t.Errorf("%s: ignoring %q names a table the case does not compare", c.ID, line) case sign == "+" && slices.Contains(rows, row): t.Errorf("%s: ignoring adds %q, which the case already wants", c.ID, row) case sign == "-" && !slices.Contains(rows, row): t.Errorf("%s: ignoring drops %q, which the case does not want", c.ID, row) } } } } // repostateSuiteCases is what the coverage test audits for this suite. func repostateSuiteCases() []Case { var all []Case for _, c := range repostateCases() { for _, v := range c.variants() { all = append(all, v.Case) } } for _, c := range rsAPICases() { all = append(all, c.Case) } return all }