Artifact b5e626ed80dbc3bbbfbaf97f693ab46a51d949ee6b1e81e747f1fb04b4f4be08:
- File
conformance/repostate_corpus_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: 6528) [annotate] [blame] [check-ins using]
package conformance import ( "fmt" "os" "path/filepath" "sort" "strings" "testing" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/verify" ) // TestRepoStateCorpus derives the tables of real repositories three ways // and compares them, table by table and column by column: `fossil rebuild` // on one copy is the reference; verify.Rebuild runs on a second copy, and a // manifest.Crosslink sweep over emptied tables on a third. It runs only // when CONFORMANCE_REPOS names the repositories, colon-separated, and // fossil(1) is present. The originals are never written. func TestRepoStateCorpus(t *testing.T) { paths := os.Getenv("CONFORMANCE_REPOS") if paths == "" { t.Skip("set CONFORMANCE_REPOS to colon-separated .fossil paths to run") } ref := newFossilReference(t) if ref == nil { t.Skip("fossil(1) not found") } for _, path := range strings.Split(paths, ":") { t.Run(filepath.Base(path), func(t *testing.T) { reference, wrote, err := ref.rebuildCopy(fossilRepo{path: copyRepo(t, path)}) if err != nil { t.Fatal(err) } if wrote { t.Errorf("fossil rebuild stored artifacts of its own; the reference is not " + "derived from the repository's artifacts alone") } want := corpusState(t, reference) for _, way := range []corpusWay{{"verify.Rebuild", goRebuild}, {"Crosslink", goSweep}} { got := corpusState(t, way.derive(t, copyRepo(t, path))) for _, line := range corpusDiff(want, got) { t.Errorf("%s: %s", way.name, line) } } }) } } // corpusWay is one way go-libfossil derives a repository's tables. type corpusWay struct { name string derive func(t *testing.T, path string) string } func goRebuild(t *testing.T, path string) string { r, err := repo.Open(path) if err != nil { t.Fatal(err) } defer r.Close() if _, err := verify.Rebuild(r); err != nil { t.Fatal(err) } return path } // goSweep empties the tables a crosslink fills, as they are in a clone // that has received every artifact, then crosslinks once. func goSweep(t *testing.T, path string) string { r, err := repo.Open(path) if err != nil { t.Fatal(err) } defer r.Close() for _, table := range []string{"event", "plink", "leaf", "mlink", "tagxref", "forumpost", "attachment", "backlink", "cherrypick"} { if _, err := r.DB().Exec("DELETE FROM " + table); err != nil { t.Fatal(err) } } if _, err := manifest.Crosslink(r); err != nil { t.Fatal(err) } return path } // corpusKeys is how many leading columns identify a row of each table; the // rest are compared column by column. var corpusKeys = map[string]int{ "event": 1, "leaf": 1, "mlink": 2, "phantom": 1, "plink": 1, "tagxref": 2, } // corpusColumns names the compared columns, keys first, as rsQueries reads // them. var corpusColumns = map[string][]string{ "event": {"objid", "type", "mtime"}, "leaf": {"rid"}, "mlink": {"mid", "fid", "pid", "pmid", "name", "isaux"}, "phantom": {"uuid"}, "plink": {"pid>cid", "isprim", "mtime", "baseid"}, "tagxref": {"tag", "rid", "tagtype", "srcid", "origid", "value", "mtime"}, } // corpusState reads every table, keyed: rows[table][key] = the other // columns. mlink's key is mid and filename. func corpusState(t *testing.T, path string) map[string]map[string][]string { conn, err := db.OpenSQL(path, db.OpenConfig{}, nil) if err != nil { t.Fatal(err) } defer conn.Close() state := map[string]map[string][]string{} for _, table := range rsAllTables { rows, err := queryColumns(conn, rsQueries[table], nil) if err != nil { t.Fatalf("%s: %v", table, err) } keyed := map[string][]string{} for _, columns := range rows { if table == "mlink" { columns = append([]string{columns[0], columns[4]}, columns[1], columns[2], columns[3], columns[5]) } n := corpusKeys[table] keyed[strings.Join(columns[:n], " ")] = columns[n:] } state[table] = keyed } return state } // corpusDiff summarizes how got differs from want: rows missing and extra // per table, with the commonest first column, and differing values per // column. func corpusDiff(want, got map[string]map[string][]string) []string { var lines []string for _, table := range rsAllTables { missing, extra := map[string]int{}, map[string]int{} differ := map[string]int{} for key, values := range want[table] { other, ok := got[table][key] if !ok { missing[corpusGroup(table, key, values)]++ continue } for i := range values { if values[i] != other[i] { differ[corpusColumns[table][corpusKeys[table]+i]]++ } } } for key, values := range got[table] { if _, ok := want[table][key]; !ok { extra[corpusGroup(table, key, values)]++ } } lines = append(lines, corpusLines(table, missing, extra, differ)...) } return lines } func corpusLines(table string, missing, extra, differ map[string]int) []string { var lines []string if n := total(missing); n > 0 { lines = append(lines, fmt.Sprintf("%s: %d rows missing (%s)", table, n, breakdown(missing))) } if n := total(extra); n > 0 { lines = append(lines, fmt.Sprintf("%s: %d rows extra (%s)", table, n, breakdown(extra))) } for _, column := range sortedKeys(differ) { lines = append(lines, fmt.Sprintf("%s.%s: %d rows differ", table, column, differ[column])) } return lines } // corpusGroup is what a summary counts a row under: a tag row's tag name, // with the per-ticket, per-page and per-technote names folded, an event's // type, and nothing for tables keyed by hashes alone. func corpusGroup(table, key string, values []string) string { switch table { case "tagxref": name := strings.Fields(key)[0] for _, prefix := range []string{"tkt-", "wiki-", "event-"} { if strings.HasPrefix(name, prefix) { return prefix + "*" } } return name case "event": return "type " + values[0] default: return "rows" } } // breakdown lists the counts per group. func breakdown(counts map[string]int) string { var parts []string for _, name := range sortedKeys(counts) { parts = append(parts, fmt.Sprintf("%s %d", name, counts[name])) } return strings.Join(parts, ", ") } func total(counts map[string]int) int { n := 0 for _, c := range counts { n += c } return n } func sortedKeys(counts map[string]int) []string { keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) } sort.Strings(keys) return keys }