Artifact 1341bd76d59c3f8d36af72013994c61f5c6a2351e1274ffd2912607e50e912a4:
- File
conformance/artifact_corpus_test.go
— part of check-in
[d52a6fe050]
at
2026-10-11 00:42:57
on branch trunk
— test(conformance): artifact-format conformance suite (#263) (#276)
* test(conformance): artifact-format conformance suite (#263)
Adds the suite for draft-fossil-artifact-format-00 with errata 4.1.2, 4.2 and 4.6.4, so the parser, writer, naming, hash policy and delta manifest code are checked requirement by requirement instead of by scattered unit tests that each encode one reading of fossil.
- requirements/artifact.tsv: 101 requirements from Sections 3 to 8, pinned to the draft and the three errata. - Parse cases are a known-good base artifact per type plus one edit, with the Z card recomputed, so the edit alone explains a rejection. Every case also runs through fossil test-parse-manifest; fossil 2.28 agrees with all of them. Card legality, duplicates, arity and hash positions are generated from the bases and the draft's own tables. - Accepted inputs must re-encode byte for byte unless marked non-canonical. - API cases cover naming, verification, the comparator, the writer, the R card, the hash policy and delta-manifest enumeration. - FuzzArtifactRoundTrip checks parse/marshal/parse stability, and TestArtifactCorpus (CONFORMANCE_REPOS) parses every artifact fossil crosslinked in real repositories.
267 cases go-libfossil fails are marked with a "#0" placeholder in artifact_gaps_test.go, grouped by the code at fault, for issues to be filed against.
* test(conformance): tighten the artifact suite after review (#263)
A rejection only counts when the rule the case names makes it, so every rejection case that is not about the Z card must now pass deck.VerifyZ first. Inputs go-libfossil rejected only through the Z check (framing, residue, sterilizer, unstripped wrappers, an extra Z token) become gaps, along with VerifyZ itself, which rejects an absent Z card and reads the last byte where Section 6.2 says it must not.
- Cases can name the substring fossil's rejection reason must contain; the unknown-letter cases sit in sorted position, the sterilizer cases are filed under card order, which is what fossil rejects them for, and generated legality cases take their requirement from the draft's table. - The ratchet that never persists the hash policy is a gap: fossil opening the repository still reads auto. - New rows for 4.7.9, 6.2 (absent Z, last byte), 6.3.4 and 7.1. - The audit refuses the #0 placeholder, a test keeps the gap map and the fuzz test's forgiven losses tied to live gap cases, and one Z-card helper replaces two.
* test(conformance): mark artifact gaps with their issues (#263)
Replace the placeholder gap marks with the issues that track each fix (#265-#275), and keep coverage_test.go within 100 columns. (user: [email protected], size: 6183) [annotate] [blame] [check-ins using]
package conformance import ( "fmt" "io" "os" "path/filepath" "reflect" "regexp" "sort" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" ) // FuzzArtifactRoundTrip checks that whatever deck.Parse accepts, Marshal // writes back as an artifact that parses to the same deck. Equal bytes are // not required: accepted input need not be in the form Marshal writes. func FuzzArtifactRoundTrip(f *testing.F) { for _, b := range artifactBases { f.Add(withZ(strings.Join(b.cards, ""))) } for _, c := range artifactCases() { f.Add(c.input) } f.Fuzz(func(t *testing.T, data []byte) { if data == nil { data = []byte{} } first, err := deck.Parse(data) if err != nil { return } encoded, err := first.Marshal() if err != nil { t.Fatalf("Marshal of an accepted artifact: %v", err) } second, err := deck.Parse(encoded) if err != nil { t.Fatalf("deck.Parse rejects what Marshal wrote, %q: %v", encoded, err) } for _, loss := range knownLosses { loss.forgive(first) loss.forgive(second) } if !reflect.DeepEqual(first, second) { t.Fatalf("round trip changed the deck:\n%+v\n%+v", first, second) } }) } // knownLosses are the changes a round trip is already known to make, each // tracked by a gap case, so the fuzz test looks for new ones. // TestArtifactGapKeys fails once a case's gap is removed, so the matching // loss is removed with it. var knownLosses = []struct { caseID string forgive func(*deck.Deck) }{ // Marshal writes D to the millisecond. {"artifact.4.6.4.fraction-round-trip", func(d *deck.Deck) { d.D = d.D.Truncate(time.Millisecond) }}, // Marshal writes E to the second. {"artifact.4.6.4.e-fraction-round-trip", func(d *deck.Deck) { if d.E != nil { d.E.Date = d.E.Date.Truncate(time.Second) } }}, // Marshal drops a W card with an empty body. {"artifact.4.7.18.w-empty", func(d *deck.Deck) { if len(d.W) == 0 { d.W = nil } }}, } // corpusBudget bounds TestArtifactCorpus as a whole. const corpusBudget = 20 * time.Minute // fossilParsed lists the rids fossil(1) crosslinked as structural // artifacts, read from the tables crosslinking fills. A table a repository // lacks is skipped. var fossilParsed = []string{ "SELECT objid FROM event", "SELECT srcid FROM tagxref WHERE srcid > 0", "SELECT rid FROM tagxref WHERE tagid = (SELECT tagid FROM tag WHERE tagname = 'cluster')", "SELECT attachid FROM attachment", "SELECT tkt_rid FROM ticketchng", "SELECT fpid FROM forumpost", } // TestArtifactCorpus parses, with deck.Parse, every artifact fossil(1) // parsed in real repositories. It runs only when CONFORMANCE_REPOS names // them, colon-separated. Each repository is copied first, so the original // is never written. func TestArtifactCorpus(t *testing.T) { paths := os.Getenv("CONFORMANCE_REPOS") if paths == "" { t.Skip("set CONFORMANCE_REPOS to colon-separated .fossil paths to run") } deadline := time.Now().Add(corpusBudget) if d, ok := t.Deadline(); ok && d.Before(deadline) { deadline = d.Add(-30 * time.Second) } for _, path := range strings.Split(paths, ":") { t.Run(filepath.Base(path), func(t *testing.T) { parseCorpus(t, copyRepo(t, path), deadline) }) } } func copyRepo(t *testing.T, path string) string { t.Helper() source, err := os.Open(path) if err != nil { t.Fatal(err) } defer source.Close() copied := filepath.Join(t.TempDir(), filepath.Base(path)) target, err := os.Create(copied) if err != nil { t.Fatal(err) } if _, err := io.Copy(target, source); err != nil { t.Fatal(err) } if err := target.Close(); err != nil { t.Fatal(err) } return copied } // parseCorpus parses each artifact fossil parsed and reports every // rejection, grouped by cause. func parseCorpus(t *testing.T, path string, deadline time.Time) { r, err := repo.Open(path) if err != nil { t.Fatal(err) } defer r.Close() rids := corpusRids(t, r) cache := content.NewCache(64 << 20) causes := map[string][]libfossil.FslID{} parsed := 0 for _, rid := range rids { if time.Now().After(deadline) { t.Errorf("out of time after %d of %d artifacts", parsed, len(rids)) break } data, err := cache.Expand(r.DB(), rid) if err != nil { cause := "expand: " + generalize(err.Error()) causes[cause] = append(causes[cause], rid) continue } parsed++ if _, err := deck.Parse(data); err != nil { cause := generalize(err.Error()) causes[cause] = append(causes[cause], rid) } } reportCauses(t, len(rids), parsed, causes) } func corpusRids(t *testing.T, r *repo.Repo) []libfossil.FslID { seen := map[libfossil.FslID]bool{} for _, query := range fossilParsed { rows, err := r.DB().Query(query) if err != nil { if strings.Contains(err.Error(), "no such table") { continue } t.Fatal(err) } for rows.Next() { var rid int64 if err := rows.Scan(&rid); err != nil { t.Fatal(err) } seen[libfossil.FslID(rid)] = true } if err := rows.Close(); err != nil { t.Fatal(err) } } rids := make([]libfossil.FslID, 0, len(seen)) for rid := range seen { rids = append(rids, rid) } sort.Slice(rids, func(i, j int) bool { return rids[i] < rids[j] }) return rids } // generalize strips the artifact-specific parts of an error, so rejections // with the same cause group together. var specifics = regexp.MustCompile(`"[^"]*"|'[^']*'|[0-9a-f]{8,}|\d+`) func generalize(message string) string { return specifics.ReplaceAllString(message, "_") } func reportCauses(t *testing.T, total, parsed int, causes map[string][]libfossil.FslID) { rejected := 0 var lines []string for cause, rids := range causes { rejected += len(rids) examples := rids if len(examples) > 5 { examples = examples[:5] } lines = append(lines, fmt.Sprintf("%s: %d, e.g. rids %v", cause, len(rids), examples)) } sort.Strings(lines) t.Logf("%d artifacts fossil parsed, %d expanded, %d not accepted", total, parsed, rejected) for _, line := range lines { t.Errorf("rejected: %s", line) } }