Skip to content
quarry Artifact [4b0ee2ce4e]
Login

Artifact [4b0ee2ce4e]

Artifact 4b0ee2ce4ed7e7803e74c9a22033261472e4d2dd44a05c597c1f6a4ff410fee6:


package artifact

import (
	"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 {
		raw, err := os.ReadFile(f)
		if err != nil {
			t.Fatal(err)
		}
		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 +")
}