Artifact 8ac0e4afa2c5d713a0d480d94e51caf4374527d2fd2736bb89b0d18bcf08ee0e:
- File
conformance/coverage_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: 7405) [annotate] [blame] [check-ins using]
package conformance import ( "bufio" "bytes" "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "strings" "testing" ) // suite is one draft's requirements table and the cases that exercise it. type suite struct { name string // case IDs start with name + "." table string // path of the requirements table, relative to this package cases []Case } // suites lists every suite the coverage test audits. Each draft's test file // adds its suite here. func suites() []suite { return []suite{ {name: "artifact", table: "requirements/artifact.tsv", cases: artifactSuiteCases()}, {name: "delta", table: "requirements/delta.tsv", cases: deltaSuiteCases()}, {name: "repostate", table: "requirements/repostate.tsv", cases: repostateSuiteCases()}, } } // requirement is one row of a requirements table. type requirement struct { ID string Section string Text string // Scope, when set, says what the cases leave out; such a requirement is // at most PARTIAL. Scope string } // source is a document a table was written against, pinned by its SHA-256. type source struct { Path string // relative to rfcs/ SHA256 string } type table struct { sources []source requirements []requirement } // Table format: tab-separated. Lines starting with "#" are comments, except // "# draft\t<file>\t<sha256>" and "# erratum\t<file>\t<sha256>", which pin // the sources. Every other line is "<id>\t<section>\t<requirement>[\t<scope>]". func parseTable(data []byte) (table, error) { var parsed table scanner := bufio.NewScanner(bytes.NewReader(data)) for lineNumber := 1; scanner.Scan(); lineNumber++ { line := scanner.Text() if strings.TrimSpace(line) == "" { continue } fields := strings.Split(line, "\t") if strings.HasPrefix(line, "#") { kind := strings.TrimSpace(strings.TrimPrefix(fields[0], "#")) if kind != "draft" && kind != "erratum" { continue } if len(fields) != 3 { return table{}, fmt.Errorf("line %d: %s pin needs a file and a SHA-256", lineNumber, kind) } path := fields[1] if kind == "erratum" { path = "erratum/" + path } parsed.sources = append(parsed.sources, source{Path: path, SHA256: fields[2]}) continue } if len(fields) != 3 && len(fields) != 4 { return table{}, fmt.Errorf("line %d: want 3 or 4 tab-separated fields, got %d", lineNumber, len(fields)) } row := requirement{ID: fields[0], Section: fields[1], Text: fields[2]} if len(fields) == 4 { row.Scope = fields[3] } if row.ID == "" || row.Section == "" || row.Text == "" { return table{}, fmt.Errorf("line %d: empty ID, section or requirement", lineNumber) } parsed.requirements = append(parsed.requirements, row) } if err := scanner.Err(); err != nil { return table{}, err } if len(parsed.sources) == 0 { return table{}, fmt.Errorf("no draft pinned") } return parsed, nil } // verifySources checks every pinned document still has the hash the table // was written against. A refreshed draft must be re-read before its table's // pin is moved. func verifySources(tbl table, rfcDir string) []error { var problems []error for _, pinned := range tbl.sources { data, err := os.ReadFile(filepath.Join(rfcDir, pinned.Path)) if err != nil { problems = append(problems, err) continue } sum := sha256.Sum256(data) if got := hex.EncodeToString(sum[:]); got != pinned.SHA256 { problems = append(problems, fmt.Errorf( "%s: SHA-256 is %s, table pins %s; re-read the changes, then move the pin", pinned.Path, got, pinned.SHA256)) } } return problems } // audit checks a suite's cases against its table: IDs unique and prefixed by // the suite name, and every case naming a requirement in the table. func audit(s suite, tbl table) []error { var problems []error known := map[string]bool{} for _, row := range tbl.requirements { if known[row.ID] { problems = append(problems, fmt.Errorf("requirement %s listed twice", row.ID)) } known[row.ID] = true } seen := map[string]bool{} for _, c := range s.cases { if !strings.HasPrefix(c.ID, s.name+".") { problems = append(problems, fmt.Errorf("case %s does not start with %q", c.ID, s.name+".")) } if seen[c.ID] { problems = append(problems, fmt.Errorf("case %s listed twice", c.ID)) } seen[c.ID] = true if !known[c.Req] { problems = append(problems, fmt.Errorf("case %s names unknown requirement %q", c.ID, c.Req)) } if c.Gap != "" && !strings.HasPrefix(c.Gap, "#") { problems = append(problems, fmt.Errorf( "case %s: Gap %q must be an issue reference like #270", c.ID, c.Gap)) } // "#0" stands in for an issue not yet filed; it must not reach CI. if c.Gap == "#0" { problems = append(problems, fmt.Errorf("case %s: Gap #0 is a placeholder; file the issue", c.ID)) } } return problems } type status string const ( statusCovered status = "COVERED" statusPartial status = "PARTIAL" statusFailing status = "FAILING" statusDeviation status = "DEVIATION" statusGap status = "GAP" ) // assess derives one requirement's status from its cases. A known failure // outranks a deliberate deviation, which outranks partial coverage. func assess(row requirement, cases []Case) (status, []string) { var failing, deviating []string for _, c := range cases { if c.Gap != "" { failing = append(failing, c.ID+" "+c.Gap) } if c.Deviation != "" { deviating = append(deviating, c.ID+": "+c.Deviation) } } switch { case len(cases) == 0: return statusGap, nil case len(failing) > 0: return statusFailing, failing case len(deviating) > 0: return statusDeviation, deviating case row.Scope != "": return statusPartial, []string{row.Scope} default: return statusCovered, nil } } func TestCoverage(t *testing.T) { var report strings.Builder for _, s := range suites() { data, err := os.ReadFile(s.table) if err != nil { t.Fatal(err) } tbl, err := parseTable(data) if err != nil { t.Fatalf("%s: %v", s.table, err) } for _, problem := range append(verifySources(tbl, "rfcs"), audit(s, tbl)...) { t.Errorf("%s: %v", s.name, problem) } writeSuiteReport(t, &report, s, tbl) } if path := os.Getenv("CONFORMANCE_REPORT"); path != "" { if err := os.WriteFile(path, []byte(report.String()), 0o644); err != nil { t.Fatal(err) } } } // writeSuiteReport logs one line of counts per suite and appends the // requirement-by-requirement table to the Markdown report. func writeSuiteReport(t *testing.T, report *strings.Builder, s suite, tbl table) { t.Helper() byReq := map[string][]Case{} for _, c := range s.cases { byReq[c.Req] = append(byReq[c.Req], c) } counts := map[status]int{} fmt.Fprintf(report, "## %s\n\n| Requirement | Section | Status | Cases | Notes |\n", s.name) report.WriteString("|---|---|---|---|---|\n") for _, row := range tbl.requirements { got, notes := assess(row, byReq[row.ID]) counts[got]++ fmt.Fprintf(report, "| %s | %s | %s | %d | %s |\n", row.ID, row.Section, got, len(byReq[row.ID]), strings.Join(notes, "; ")) } report.WriteString("\n") order := []status{statusCovered, statusPartial, statusDeviation, statusFailing, statusGap} var parts []string for _, st := range order { parts = append(parts, fmt.Sprintf("%s %d", st, counts[st])) } t.Logf("%s: %d requirements, %d cases: %s", s.name, len(tbl.requirements), len(s.cases), strings.Join(parts, ", ")) }