ZDDC/zddc/internal/convert/convert_test.go
ZDDC 1d816ae43a feat(server): multi-template MD→HTML with .zddc.d/templates cascade
The convert engine renders markdown→HTML/PDF through named doctype templates
selected by the document's `template:` front matter, with per-project/per-party
overrides.

convert package:
- embed.go now embeds the whole templates/ dir (all: prefix so _-prefixed
  partials are included) as an embed.FS; drop the single viewer-template.html +
  custom.css embeds. New TemplateSet type + DefaultTemplateSet(name) returning the
  chosen doctype + its partials.
- ToHTML/ToPDF take a TemplateSet; writeTemplateSetToScratch materialises the
  template + partials flat into the per-call scratch dir (pandoc resolves
  $partial()$ from the template's own directory).

handler:
- converttemplate.go: templateNameFromFrontMatter (YAML front-matter scan,
  sanitized to a bare basename) + resolveTemplateSet, which overlays
  <level>/.zddc.d/templates/<name>.html overrides onto the embedded defaults,
  walking docDir→fsRoot so a party dir beats the project-global dir. An override
  may replace a doctype, a partial, or add a brand-new doctype.
- buildAndStore threads fsRoot + source into the html/pdf paths.

build: pandoc/templates/ is the single source of truth; shared/build-lib.sh
sync_pandoc_templates mirrors it into the embed dir on every build (cmp-guarded,
stale-pruning). convert.TestEmbeddedTemplatesMatchSource fails on drift.

Tests: drift + DefaultTemplateSet (convert); front-matter parse + cascade
override precedence (handler). Full ./... suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:18:40 -05:00

303 lines
8.6 KiB
Go

package convert
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
)
// fakeRunner records the args it was invoked with and replays canned
// responses. Lets us assert command lines + binary refs + scratch
// dirs without needing actual pandoc.
type fakeRunner struct {
mu sync.Mutex
calls [][]string
binaries []string
stdin [][]byte
scratchDir []string
resp []byte
err error
}
func (f *fakeRunner) Run(_ context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, append([]string(nil), cmd...))
f.binaries = append(f.binaries, binary)
f.stdin = append(f.stdin, append([]byte(nil), stdin...))
f.scratchDir = append(f.scratchDir, scratchDir)
return f.resp, f.err
}
func (f *fakeRunner) lastCall() (string, []string) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.calls) == 0 {
return "", nil
}
return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1]
}
func TestToDocx_UsesPandocBinary(t *testing.T) {
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser")
out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
Title: "Hello",
Client: "Acme",
})
if err != nil {
t.Fatalf("ToDocx: %v", err)
}
if string(out) != "FAKE-DOCX" {
t.Errorf("unexpected output: %q", out)
}
binary, call := f.lastCall()
if binary != "pandoc" {
t.Errorf("expected pandoc binary, got %q", binary)
}
if !contains(call, "--to=docx") {
t.Errorf("missing --to=docx: %v", call)
}
if !contains(call, "title=Hello") {
t.Errorf("missing title metadata: %v", call)
}
if !contains(call, "client=Acme") {
t.Errorf("missing client metadata: %v", call)
}
// Last arg must be "-" so pandoc reads from stdin.
if call[len(call)-1] != "-" {
t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
}
// ToDocx is stdin → stdout — no scratch dir needed.
if f.scratchDir[len(f.scratchDir)-1] != "" {
t.Errorf("ToDocx should not need a scratch dir, got %q", f.scratchDir[len(f.scratchDir)-1])
}
}
func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
f := &fakeRunner{resp: []byte("<html>fake</html>")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser")
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}, TemplateSet{})
if err != nil {
t.Fatalf("ToHTML: %v", err)
}
binary, call := f.lastCall()
if binary != "pandoc" {
t.Errorf("expected pandoc binary, got %q", binary)
}
// Template flag must reference an absolute path under the scratch
// dir (no /tpl indirection anymore — the wrapper bind-mounts the
// scratch dir at its own path, so absolute host paths just work).
scratch := f.scratchDir[len(f.scratchDir)-1]
if scratch == "" {
t.Fatalf("ToHTML must pass a scratch dir to the runner")
}
wantTpl := "--template=" + scratch + "/report.html"
if !contains(call, wantTpl) {
t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
}
if !contains(call, "--toc") {
t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
}
}
func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
f := &fakeRunner{resp: []byte("<html/>")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}, TemplateSet{})
_, call := f.lastCall()
if contains(call, "--toc") {
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
}
if !contains(call, "no-toc=true") {
t.Errorf("no-toc metadata variable missing: %v", call)
}
}
// recordingRunner records every call and returns canned responses in
// sequence. Lets ToPDF tests assert the two-stage pipeline (pandoc
// then chromium).
type recordingRunner struct {
mu sync.Mutex
calls []recordedCall
resp [][]byte
err []error
cursor int
}
type recordedCall struct {
binary string
cmd []string
scratch string
}
func (r *recordingRunner) Run(_ context.Context, binary string, _ []byte, scratch string, cmd []string) ([]byte, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, recordedCall{
binary: binary,
cmd: append([]string(nil), cmd...),
scratch: scratch,
})
if r.cursor >= len(r.resp) {
return nil, nil
}
out := r.resp[r.cursor]
var e error
if r.cursor < len(r.err) {
e = r.err[r.cursor]
}
r.cursor++
return out, e
}
func TestScratchDir_UsedByToHTML(t *testing.T) {
f := &fakeRunner{resp: []byte("<html/>")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil); SetScratchDir("") })
scratchRoot := t.TempDir()
SetScratchDir(scratchRoot)
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
if err != nil {
t.Fatalf("ToHTML: %v", err)
}
if len(f.scratchDir) == 0 {
t.Fatalf("expected a scratch dir to be passed to the runner")
}
got := f.scratchDir[0]
if !strings.HasPrefix(got, scratchRoot+"/") {
t.Errorf("scratch dir not under configured root: %q (root=%q)", got, scratchRoot)
}
}
func TestToPDF_TwoStagePipeline(t *testing.T) {
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
// the scratch dir and writes out.pdf there. The fake runner can't
// actually write the PDF, so we expect ToPDF to fail at the
// read-back step — but we can still assert the two-stage call
// shape and the right binary per stage.
r := &recordingRunner{
resp: [][]byte{
[]byte("<html><body>fake</body></html>"), // stage 1 stdout
nil, // stage 2 stdout (chromium writes PDF to scratch)
},
}
InstallRunner(r)
t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser")
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
// PDF read-back will fail (fake runner didn't write the file) —
// that's expected for this test which only inspects the call shape.
if err == nil {
t.Fatalf("expected error from PDF read-back; got nil")
}
if len(r.calls) != 2 {
t.Fatalf("expected 2 calls (pandoc + chromium); got %d", len(r.calls))
}
if r.calls[0].binary != "pandoc" {
t.Errorf("stage 1 binary: got %q want pandoc", r.calls[0].binary)
}
if r.calls[1].binary != "chromium-browser" {
t.Errorf("stage 2 binary: got %q want chromium-browser", r.calls[1].binary)
}
// Stage 2 must include --print-to-pdf pointing at an absolute
// path under the scratch dir.
stage2 := r.calls[1]
if stage2.scratch == "" {
t.Fatalf("chromium call must have a scratch dir")
}
wantPDF := "--print-to-pdf=" + stage2.scratch + "/out.pdf"
if !contains(stage2.cmd, wantPDF) {
t.Errorf("chromium call missing --print-to-pdf=%s/out.pdf: %v", stage2.scratch, stage2.cmd)
}
if !contains(stage2.cmd, "--no-sandbox") {
t.Errorf("chromium call missing --no-sandbox: %v", stage2.cmd)
}
// Stage 2 chromium reads file://<scratch>/in.html.
wantHTML := "file://" + stage2.scratch + "/in.html"
if !contains(stage2.cmd, wantHTML) {
t.Errorf("chromium call missing file:// URL: %v", stage2.cmd)
}
}
func TestErrUnavailable_WhenNoRunner(t *testing.T) {
InstallRunner(nil)
_, err := ToDocx(context.Background(), []byte("x"), Metadata{})
if !errors.Is(err, ErrUnavailable) {
t.Errorf("expected ErrUnavailable, got %v", err)
}
}
func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
args := metadataArgs(Metadata{
Title: "T",
Project: "P",
GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC),
})
want := []string{
"-V", "title=T",
"-V", "project=P",
}
for i, w := range want {
if i >= len(args) || args[i] != w {
t.Fatalf("args[%d]: got %v want prefix %v", i, args, want)
}
}
joined := strings.Join(args, "|")
if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") {
t.Errorf("generation_time missing or malformed: %v", args)
}
if strings.Contains(joined, "client=") {
t.Errorf("empty client should not be passed: %v", args)
}
}
func TestSingleflight_Collapses(t *testing.T) {
var g singleflightGroup
const N = 50
var wg sync.WaitGroup
var hits int32
var mu sync.Mutex
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
_, _ = g.Do("k", func() (any, error) {
mu.Lock()
hits++
mu.Unlock()
time.Sleep(20 * time.Millisecond)
return "v", nil
})
}()
}
wg.Wait()
if hits != 1 {
t.Errorf("singleflight collapse: got %d invocations, want 1", hits)
}
}
// contains reports whether haystack has needle as any of its elements.
func contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}