package artifact
import (
"bytes"
"crypto/md5"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// zseal appends a valid Z card to a card body — test artifacts must pass
// the §6.2 self-checksum gate.
func zseal(body string) []byte {
sum := md5.Sum([]byte(body))
return []byte(body + "Z " + hex.EncodeToString(sum[:]) + "\n")
}
func parseOK(t *testing.T, body string) *Artifact {
t.Helper()
a, err := Parse(zseal(body))
if err != nil {
t.Fatal(err)
}
if a == nil {
t.Fatal("classified as content")
}
return a
}
func wantReject(t *testing.T, body, msg string) {
t.Helper()
a, err := Parse(zseal(body))
if err == nil {
t.Fatalf("%s: want reject, got %+v", msg, a)
}
}
const h40 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
const h64 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
// --- corpus: every stored artifact of the synthetic fixture ---
func TestCorpus(t *testing.T) {
files, err := filepath.Glob("testdata/*")
if err != nil || len(files) == 0 {
t.Fatalf("no corpus: %v", err)
}
counts := map[string]int{}
for _, f := range files {
if fi, err := os.Stat(f); err != nil || fi.IsDir() {
continue
}
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
// testdata is stored sterilized (§4.1.2): a '#' first line keeps
// these artifacts from joining THIS repository's DAG when
// committed. Strip it to recover the exact stored bytes.
if i := bytes.IndexByte(raw, '\n'); i < 0 || raw[0] != '#' {
t.Fatalf("%s: testdata not sterilized", f)
} else {
raw = raw[i+1:]
}
a, err := Parse(raw)
if err != nil {
t.Errorf("%s: %v", filepath.Base(f), err)
continue
}
if a == nil {
counts["blob"]++
continue
}
counts[a.Type.String()]++
if a.Date == "" && a.Type != Cluster {
t.Errorf("%s: %s with no date", filepath.Base(f), a.Type)
}
}
// The fixture builds 5 check-ins, 4 wiki artifacts (3 creates + 1
// edit), 4 ticket artifacts (3 adds + 1 change), and 4 content blobs
// (two README versions, main.go, NOTES).
want := map[string]int{"checkin": 5, "wiki": 4, "ticket": 4, "blob": 4}
for k, n := range want {
if counts[k] != n {
t.Errorf("corpus %s: got %d, want %d (all: %v)", k, counts[k], n, counts)
}
}
}
// --- classification ---
func TestClassifyManifest(t *testing.T) {
a := parseOK(t, "C fix\\sbug\nD 2026-08-15T10:00:00\nF src/main.go "+h64+"\nP "+h64+"\nU dan\n")
if a.Type != Manifest || a.Comment != "fix bug" || a.Files[0].Name != "src/main.go" {
t.Fatalf("%+v", a)
}
}
func TestClassifyWiki(t *testing.T) {
a := parseOK(t, "D 2026-08-15T10:00:00\nL Home\\sPage\nU dan\nW 6\nhello\n\n")
if a.Type != Wiki || a.WikiTitle != "Home Page" || string(a.Body) != "hello\n" {
t.Fatalf("%+v", a)
}
}
func TestClassifyTicket(t *testing.T) {
a := parseOK(t, "D 2026-08-15T10:00:00\nJ status Open\nJ title crash\nK "+h40+"\nU dan\n")
if a.Type != Ticket || a.TicketID != h40 || len(a.Fields) != 2 {
t.Fatalf("%+v", a)
}
}
func TestClassifyControlFallback(t *testing.T) {
a := parseOK(t, "D 2026-08-15T10:00:00\nT +closed "+h64+"\nU dan\n")
if a.Type != Control {
t.Fatalf("want control, got %s", a.Type)
}
}
func TestClassifyManifestFallbackViaComment(t *testing.T) {
a := parseOK(t, "C empty\nD 2026-08-15T10:00:00\nT *branch * trunk\nT *sym-trunk *\nU dan\n")
if a.Type != Manifest {
t.Fatalf("want checkin, got %s", a.Type)
}
}
// --- framing / opaque fallthrough ---
func TestOpaqueBlobs(t *testing.T) {
for _, blob := range []string{
"",
"plain text\n",
"no trailing newline",
"c lowercase first\n",
"# sterilized manifest\nD 2026-08-15T10:00:00\n",
"X\n", // line 1 too short
} {
a, err := Parse([]byte(blob))
if a != nil || err != nil {
t.Errorf("%q: want (nil,nil), got (%v,%v)", blob, a, err)
}
}
}
func TestZMismatchIsContent(t *testing.T) {
bad := "C hi\nD 2026-08-15T10:00:00\nU dan\nZ 00000000000000000000000000000000\n"
a, err := Parse([]byte(bad))
if a != nil || err != nil {
t.Fatalf("Z mismatch must classify as content, got (%v,%v)", a, err)
}
}
func TestMissingZRejected(t *testing.T) {
// Passes framing (Z absent is tolerated there) but every type
// requires Z (§5.2).
a, err := Parse([]byte("C hi\nD 2026-08-15T10:00:00\nU dan\n"))
if err == nil {
t.Fatalf("want reject for missing Z, got %+v", a)
}
}
// --- grammar rejections ---
func TestGrammarRejections(t *testing.T) {
d := "D 2026-08-15T10:00:00\n"
for name, body := range map[string]string{
"undefined card": d + "S what\nU dan\n",
"card out of order": "U dan\n" + d,
"duplicate single": "C a\nC b\n" + d + "U dan\n",
"excess arity B": "B " + h64 + " extra\n" + d + "F a " + h64 + "\n",
"bad hash length": "B abcdef\n" + d + "F a " + h64 + "\n",
"F not ascending": d + "F b.txt " + h64 + "\nF a.txt " + h64 + "\n",
"F duplicate name": d + "F a.txt " + h64 + "\nF a.txt " + h64 + "\n",
"hashless F no B": d + "F gone.txt\n",
"bad pathname": d + "F /abs " + h64 + "\n",
"dotdot pathname": d + "F a/../b " + h64 + "\n",
"bad timestamp": "C a\nD yesterday\nU dan\n",
"day zero": "C a\nD 2026-08-00T10:00:00\nU dan\n",
"ticket id 64hex": d + "J a b\nK " + h64 + "\nU dan\n",
"J not ascending": d + "J b 1\nJ a 2\nK " + h40 + "\nU dan\n",
"forum H and I": d + "G " + h64 + "\nH t\nI " + h64 + "\nU dan\nW 0\n\n",
"forum merge parents": d + "I " + h64 + "\nP " + h64 + " " + h40 + "\nU dan\nW 0\n\n",
"card illegal in type": d + "K " + h40 + "\nJ a b\nU dan\nW 2\nhi\n",
"tag without sign": d + "T closed " + h64 + "\nU dan\n",
} {
wantReject(t, body, name)
}
}
func TestJOrderCheckPrecedesK(t *testing.T) {
// J cards sort before K; the ascending check must not depend on K.
a := parseOK(t, "D 2026-08-15T10:00:00\nJ alpha 1\nJ beta 2\nK "+h40+"\nU dan\n")
if len(a.Fields) != 2 {
t.Fatalf("%+v", a)
}
}
// --- decoding ---
func TestDecode(t *testing.T) {
for in, want := range map[string]string{
`a\sb`: "a b",
`a\nb`: "a\nb",
`a\tb`: "a\tb",
`a\\b`: `a\b`,
`a\qb`: "aqb", // unrecognized: drop backslash
`trail\`: `trail\`,
`plain`: "plain",
`\0`: "\x00",
`\r\v\f`: "\r\v\f",
} {
if got := decode(in); got != want {
t.Errorf("decode(%q) = %q, want %q", in, got, want)
}
}
}
func TestFieldNameNotDecoded(t *testing.T) {
a := parseOK(t, "D 2026-08-15T10:00:00\nJ has\\sslash v\\salue\nK "+h40+"\nU dan\n")
if a.Fields[0].Name != `has\sslash` {
t.Fatalf("field name was decoded: %q", a.Fields[0].Name)
}
if a.Fields[0].Value != "v alue" {
t.Fatalf("field value not decoded: %q", a.Fields[0].Value)
}
}
// --- timestamps ---
func TestTimestamps(t *testing.T) {
ok := []string{
"2026-08-15T10:00:00", "2026-08-15T10:00:00.604", "2026-08-15",
"2026-08-15T10:00", "2026-08-15T10:00:00Z", "2026-08-15T10:00:00+05:30",
"10:30", "10:30:00.5",
}
bad := []string{"now", "2454.5", "2026-8-15", "2026-08-15T25:00:00", "2026-13-01"}
for _, s := range ok {
if !validTimestamp(s) {
t.Errorf("rejected valid %q", s)
}
}
for _, s := range bad {
if validTimestamp(s) {
t.Errorf("accepted %q", s)
}
}
}
// --- misc semantics ---
func TestAnonymousUserDefault(t *testing.T) {
a := parseOK(t, "C x\nD 2026-08-15T10:00:00\n")
if a.User != "anonymous" {
t.Fatalf("user = %q", a.User)
}
}
func TestWBodyWithEmbeddedCards(t *testing.T) {
// The W body may contain lines that look like cards; the size framing
// must keep them as body bytes.
body := "D 2026-08-15\nU dan\n"
a := parseOK(t, fmt.Sprintf("D 2026-08-15T10:00:00\nL X\nU dan\nW %d\n%s\n", len(body), body))
if string(a.Body) != body {
t.Fatalf("body = %q", a.Body)
}
}
func TestDeltaManifestDeletion(t *testing.T) {
a := parseOK(t, "B "+h64+"\nC del\nD 2026-08-15T10:00:00\nF gone.txt\nU dan\n")
if a.Baseline != h64 || a.Files[0].Hash != "" {
t.Fatalf("%+v", a)
}
}
func TestTechnoteTagRule(t *testing.T) {
base := "C n\nD 2026-08-15T10:00:00\nE 2026-08-15T09:00:00 " + h40 + "\n"
parseOK(t, base+"T +tagged *\nU dan\nW 2\nhi\n")
wantReject(t, base+"T +tagged "+h64+"\nU dan\nW 2\nhi\n", "technote tag must be self-ref")
wantReject(t, strings.Replace(base+"T -tagged *\nU dan\nW 2\nhi\n", "", "", 1), "technote tag must be +")
}