Artifact 84aa3fdbec33f5b5bae5b07b100c4e8a8a37df557f36c0ed756b8707f3665ba2:
- File
conformance/repostate_load_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: 12760) [annotate] [blame] [check-ins using]
package conformance import ( "fmt" "io" "os" "path/filepath" "sort" "strconv" "strings" "testing" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/verify" "github.com/danmestas/go-libfossil/simio" ) // The repository-state suite loads a scripted sequence of artifacts into // go-libfossil and into fossil(1), then compares the tables derived from // them. The two repositories number artifacts differently, so every rid is // replaced by its hash, and every hash by the label the script gave it. // rsArt is one artifact of a scripted sequence. Its body may name an earlier // artifact as {label}; a label the script never defines stands for an // artifact that is never loaded, which makes a phantom. type rsArt struct { label string body string // file marks file content, which is loaded as written, with no Z card. file bool } // rsBuilt is an artifact ready to load: its bytes, with the Z card, and the // hash that names it. type rsBuilt struct { label string uuid string data []byte } // rsScript is a built sequence and the label of every hash it mentions. type rsScript struct { arts []rsBuilt labels map[string]string // hash to label hashes map[string]string // label to hash } // rsBuild replaces each {label} with the hash of the artifact it names and // appends the Z card. Bodies are written with their cards already in order. // A body may name an artifact the script loads later, such as a tag that // arrives before its target, so artifacts are hashed in passes: each pass // builds those whose labels are all known. func rsBuild(arts []rsArt) rsScript { script := rsScript{labels: map[string]string{}, hashes: map[string]string{}} defined := map[string]bool{} for _, a := range arts { if defined[a.label] { panic("conformance: label " + a.label + " defined twice") } defined[a.label] = true } built := make([]rsBuilt, len(arts)) for pass, done := 0, 0; done < len(arts); pass++ { if pass == len(arts) { panic("conformance: artifacts name each other in a cycle") } for i, a := range arts { if built[i].data != nil || !script.ready(a.body, defined) { continue } data := []byte(a.body) if !a.file { data = withZ(script.resolve(a.body)) } uuid := hash.SHA3(data) script.hashes[a.label] = uuid script.labels[uuid] = a.label built[i] = rsBuilt{label: a.label, uuid: uuid, data: data} done++ } } script.arts = built return script } // ready reports whether every label body names is hashed, or is one the // script never defines. func (s rsScript) ready(body string, defined map[string]bool) bool { for _, part := range strings.Split(body, "{")[1:] { label, _, _ := strings.Cut(part, "}") if _, ok := s.hashes[label]; defined[label] && !ok { return false } } return true } // resolve substitutes every {label} in text. A label the script never // defines is given the hash of "absent <label>", an artifact no script // loads. func (s rsScript) resolve(text string) string { for { start := strings.IndexByte(text, '{') if start < 0 { return text } end := strings.IndexByte(text[start:], '}') if end < 0 { panic("conformance: unterminated label in " + text) } label := text[start+1 : start+end] uuid, ok := s.hashes[label] if !ok { uuid = hash.SHA3([]byte("absent " + label)) s.hashes[label] = uuid s.labels[uuid] = label } text = strings.ReplaceAll(text, "{"+label+"}", uuid) } } // rsLoad is how artifacts reach the derived tables. type rsLoad int const ( // loadSweep stores every artifact, then crosslinks them in one call. loadSweep rsLoad = iota // loadEach crosslinks after storing each artifact, in script order. loadEach // loadRebuild stores every artifact without crosslinking, then // derives everything with verify.Rebuild. loadRebuild ) func (l rsLoad) String() string { return [...]string{"sweep", "per-artifact", "verify.Rebuild"}[l] } // rsDoctor edits a repository's tables by SQL after the first at artifacts // are loaded, to reach a state no known artifact sequence reaches. type rsDoctor struct { at int sql string } // goLoad loads a script into a new go-libfossil repository and returns its // path. The repository is closed again, so a check can open it its own way. func goLoad(t *testing.T, script rsScript, load rsLoad, doctor *rsDoctor) (string, error) { t.Helper() path := filepath.Join(t.TempDir(), "go.fossil") r, err := repo.Create(path, "u", simio.CryptoRand{}, "") if err != nil { return "", err } loadErr := goStoreAll(r, script, load, doctor) if err := r.Close(); err != nil && loadErr == nil { loadErr = err } return path, loadErr } func goStoreAll(r *repo.Repo, script rsScript, load rsLoad, doctor *rsDoctor) error { for i, a := range script.arts { if doctor != nil && doctor.at == i { if _, err := r.DB().Exec(script.resolve(doctor.sql)); err != nil { return fmt.Errorf("doctoring: %w", err) } } _, uuid, err := blob.Store(r.DB(), a.data) if err != nil { return fmt.Errorf("blob.Store %s: %w", a.label, err) } if uuid != a.uuid { return fmt.Errorf("blob.Store named %s %s, want %s", a.label, uuid, a.uuid) } if load == loadEach { if _, err := manifest.Crosslink(r); err != nil { return fmt.Errorf("Crosslink after %s: %w", a.label, err) } } } switch load { case loadSweep: if _, err := manifest.Crosslink(r); err != nil { return fmt.Errorf("Crosslink: %w", err) } case loadRebuild: if _, err := verify.Rebuild(r); err != nil { return fmt.Errorf("verify.Rebuild: %w", err) } } return nil } // rsState is a repository's derived rows, normalized, per table. type rsState map[string][]string // rsBase is 2026-01-01T00:00:00Z in milliseconds since the epoch. Scripts // date every artifact within the minute after it, so times print as "@S", // the second. const rsBase = 1767225600000 // rsMillis turns a julian-day column into integer milliseconds since the // epoch, so equal times compare equal whatever float each side stored. func rsMillis(column string) string { return "CAST(round((" + column + " - 2440587.5) * 86400000) AS INTEGER)" } // rsUUID replaces a rid column by its blob hash. Qualify the column: a bare // name would bind to the subquery's own blob table. func rsUUID(column string) string { return "coalesce((SELECT b.uuid FROM blob b WHERE b.rid = " + column + "), '#' || " + column + ")" } // rsQueries reads each comparable table. Every rid is a hash, every tagid a // name and every time milliseconds. The cluster tag's mtime is left out: // fossil(1) writes the wall clock there. var rsQueries = map[string]string{ "plink": "SELECT " + rsUUID("plink.pid") + " || '>' || " + rsUUID("plink.cid") + ", isprim, " + rsMillis("plink.mtime") + ", coalesce(" + rsUUID("plink.baseid") + ", '-') FROM plink", "tagxref": "SELECT tag.tagname, " + rsUUID("tagxref.rid") + ", tagtype, " + "CASE WHEN srcid = 0 THEN '0' ELSE " + rsUUID("tagxref.srcid") + " END, " + rsUUID("tagxref.origid") + ", quote(value), " + "CASE WHEN tag.tagname = 'cluster' THEN '*' ELSE " + rsMillis("tagxref.mtime") + " END FROM tagxref JOIN tag USING (tagid)", "leaf": "SELECT " + rsUUID("leaf.rid") + " FROM leaf", "phantom": "SELECT uuid FROM blob WHERE size < 0", "event": "SELECT " + rsUUID("event.objid") + ", type, " + rsMillis("event.mtime") + " FROM event", "mlink": "SELECT " + rsUUID("mlink.mid") + ", " + rsUUID("mlink.fid") + ", " + rsUUID("mlink.pid") + ", " + rsUUID("mlink.pmid") + ", (SELECT name FROM filename WHERE fnid = mlink.fnid), isaux FROM mlink", } // rsAllTables is every table rsQueries reads. var rsAllTables = []string{"event", "leaf", "mlink", "phantom", "plink", "tagxref"} // readState reads tables from the repository at path. When tags is not // empty, tagxref rows are kept only for those tag names. func readState(path string, labels map[string]string, tables, tags []string) (rsState, error) { conn, err := db.OpenSQL(path, db.OpenConfig{}, nil) if err != nil { return nil, err } defer conn.Close() state := rsState{} for _, table := range tables { query, ok := rsQueries[table] if !ok { panic("conformance: no query for table " + table) } rows, err := queryNormalized(conn, query, labels) if err != nil { return nil, fmt.Errorf("%s: %w", table, err) } if table == "tagxref" && len(tags) > 0 { rows = keepTags(rows, tags) } state[table] = rows } return state, nil } func keepTags(rows, tags []string) []string { var kept []string for _, row := range rows { name, _, _ := strings.Cut(row, " ") for _, tag := range tags { if name == tag { kept = append(kept, row) break } } } return kept } // queryNormalized runs query and writes each row as its columns joined by // spaces: a hash as its label, a time as "@S". func queryNormalized(conn db.Querier, query string, labels map[string]string) ([]string, error) { table, err := queryColumns(conn, query, labels) if err != nil { return nil, err } result := make([]string, len(table)) for i, columns := range table { result[i] = strings.Join(columns, " ") } sort.Strings(result) return result, nil } // queryColumns runs query and normalizes each column of each row. func queryColumns(conn db.Querier, query string, labels map[string]string) ([][]string, error) { rows, err := conn.Query(query) if err != nil { return nil, err } defer rows.Close() columns, err := rows.Columns() if err != nil { return nil, err } var result [][]string for rows.Next() { values := make([]any, len(columns)) pointers := make([]any, len(columns)) for i := range values { pointers[i] = &values[i] } if err := rows.Scan(pointers...); err != nil { return nil, err } parts := make([]string, len(values)) for i, value := range values { parts[i] = normalizeValue(value, labels) } result = append(result, parts) } return result, rows.Err() } func normalizeValue(value any, labels map[string]string) string { // A driver may read a column declared BOOLEAN, such as plink.isprim, as // a bool; the other drivers read the integer stored. if b, ok := value.(bool); ok { if b { return "1" } return "0" } if millis, ok := value.(int64); ok && millis >= rsBase && millis < rsBase+3600000 { if offset := millis - rsBase; offset%1000 == 0 { return "@" + strconv.FormatInt(offset/1000, 10) } } text := fmt.Sprint(value) if b, ok := value.([]byte); ok { text = string(b) } // plink rows join two hashes with ">". if left, right, ok := strings.Cut(text, ">"); ok && !strings.HasPrefix(text, "'") { return labelOf(left, labels) + ">" + labelOf(right, labels) } return labelOf(text, labels) } // labelOf names a hash, or a hash inside a quoted value, by its label. func labelOf(text string, labels map[string]string) string { if label, ok := labels[text]; ok { return label } if quoted := strings.Trim(text, "'"); len(quoted)+2 == len(text) { if label, ok := labels[quoted]; ok { return "'{" + label + "}'" } } return text } // diffState reports, per table, the rows only one side has; empty when the // two agree. func diffState(want, got rsState, wantName, gotName string) string { tables := make([]string, 0, len(want)) for table := range want { tables = append(tables, table) } sort.Strings(tables) var parts []string for _, table := range tables { missing, extra := rowsOnlyIn(want[table], got[table]), rowsOnlyIn(got[table], want[table]) for _, row := range missing { parts = append(parts, fmt.Sprintf("%s: %s lacks [%s]", table, gotName, row)) } for _, row := range extra { parts = append(parts, fmt.Sprintf("%s: %s adds [%s]", table, gotName, row)) } } if len(parts) > 0 { parts = append([]string{"vs " + wantName}, parts...) } return strings.Join(parts, "; ") } // rowsOnlyIn is the rows of a, as a multiset, that b lacks. func rowsOnlyIn(a, b []string) []string { count := map[string]int{} for _, row := range b { count[row]++ } var only []string for _, row := range a { if count[row] > 0 { count[row]-- continue } only = append(only, row) } return only } // copyFile copies a repository so a destructive step runs on the copy. func copyFile(from, to string) error { source, err := os.Open(from) if err != nil { return err } defer source.Close() target, err := os.Create(to) if err != nil { return err } if _, err := io.Copy(target, source); err != nil { target.Close() return err } return target.Close() }