First step of the model-agnostic image-generation framework. Lands the
plumbing other components (skill, ComfyUI/Replicate adapters, agents)
will plug into:
- internal/backend: Backend interface (Request/Result), thread-safe
Registry with init-time Register, plus a Mock reference adapter that
emits a deterministic gradient PNG for smoke tests.
- internal/config: YAML loader for ~/.config/imagen.yaml. Framework owns
default_backend + output settings + a per-backend block; each adapter
owns the schema below its own block via BackendSpec.Raw.
- internal/output: filename templating ({date}/{time}/{slug}/{seed}/
{backend}/{ext}), JSON metadata sidecar, --output override path.
- internal/prompt: embedded styles.yaml, style-preset suffix application.
- internal/server: 501 stub — HTTP surface lands in a follow-up issue.
- cmd/imagen: generate / backends / config (init|validate|path) / serve
/ version subcommands. Stdlib-only flag parsing with a small helper to
honour positional prompt args ahead of flags (matches the issue spec).
- Tests for output (slug, naming template, sidecar), backend (mock PNG
validity + determinism, registry build + duplicate panic), config
(round-trip + validation), prompt (style apply + unknown-style error).
- CLAUDE.md, README.md, docs/architecture.md, docs/usage.md, Makefile.
Acceptance criteria from #211:
1. go build ./... — clean
2. imagen backends — lists registered backends, exits 0
3. imagen generate "test prompt" --backend mock --output /tmp/x.png —
writes a 1024x1024 PNG plus an x.png.json sidecar
4. imagen config init | imagen config validate — round-trips cleanly
5. CLAUDE.md "Adding a new adapter" — six-step recipe
128 lines
3.4 KiB
Go
128 lines
3.4 KiB
Go
package output
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSlug(t *testing.T) {
|
|
cases := map[string]string{
|
|
"": "image",
|
|
" ": "image",
|
|
"A Cat in a Fishbowl": "a-cat-in-a-fishbowl",
|
|
"!!! weird---input": "weird-input",
|
|
"über-cool prompt": "ber-cool-prompt", // ASCII-only by design
|
|
strings.Repeat("a", 80): strings.Repeat("a", 40),
|
|
}
|
|
for in, want := range cases {
|
|
if got := Slug(in); got != want {
|
|
t.Errorf("Slug(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderTemplateAndWrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
w := &Writer{
|
|
Directory: dir,
|
|
NameTemplate: "{date}-{slug}-{seed}.{ext}",
|
|
WriteSidecar: true,
|
|
Now: func() time.Time {
|
|
return time.Date(2026, 5, 8, 14, 30, 15, 0, time.UTC)
|
|
},
|
|
}
|
|
body := []byte("PNGbytes")
|
|
out, err := w.Write(bytes.NewReader(body), Inputs{
|
|
Prompt: "A cat in a fishbowl",
|
|
Backend: "mock",
|
|
Seed: 42,
|
|
Ext: "png",
|
|
Metadata: map[string]any{"foo": "bar"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
want := filepath.Join(dir, "2026-05-08-a-cat-in-a-fishbowl-42.png")
|
|
if out.ImagePath != want {
|
|
t.Errorf("image path = %q, want %q", out.ImagePath, want)
|
|
}
|
|
gotBody, err := os.ReadFile(out.ImagePath)
|
|
if err != nil {
|
|
t.Fatalf("read image: %v", err)
|
|
}
|
|
if !bytes.Equal(gotBody, body) {
|
|
t.Errorf("image body mismatch")
|
|
}
|
|
if out.SidecarPath == "" {
|
|
t.Fatal("sidecar path empty")
|
|
}
|
|
sc, err := os.ReadFile(out.SidecarPath)
|
|
if err != nil {
|
|
t.Fatalf("read sidecar: %v", err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(sc, &parsed); err != nil {
|
|
t.Fatalf("sidecar json: %v\n%s", err, sc)
|
|
}
|
|
if parsed["prompt"] != "A cat in a fishbowl" {
|
|
t.Errorf("sidecar prompt = %v", parsed["prompt"])
|
|
}
|
|
if parsed["backend"] != "mock" {
|
|
t.Errorf("sidecar backend = %v", parsed["backend"])
|
|
}
|
|
if parsed["timestamp"] != "2026-05-08T14:30:15Z" {
|
|
t.Errorf("sidecar timestamp = %v", parsed["timestamp"])
|
|
}
|
|
meta, ok := parsed["metadata"].(map[string]any)
|
|
if !ok || meta["foo"] != "bar" {
|
|
t.Errorf("sidecar metadata = %v", parsed["metadata"])
|
|
}
|
|
}
|
|
|
|
func TestWriteSkipSidecarWhenDisabled(t *testing.T) {
|
|
dir := t.TempDir()
|
|
w := &Writer{Directory: dir, WriteSidecar: false}
|
|
out, err := w.Write(bytes.NewReader([]byte("x")), Inputs{Prompt: "p", Backend: "b", Seed: 1, Ext: "png"})
|
|
if err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
if out.SidecarPath != "" {
|
|
t.Errorf("sidecar path = %q, want empty", out.SidecarPath)
|
|
}
|
|
}
|
|
|
|
func TestUnknownPlaceholderPassesThrough(t *testing.T) {
|
|
got := renderTemplate("{date}-{nonsense}-{seed}", map[string]string{
|
|
"date": "2026-05-08", "seed": "1",
|
|
})
|
|
if got != "2026-05-08-{nonsense}-1" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteToPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "explicit.png")
|
|
w := &Writer{WriteSidecar: true}
|
|
out, err := w.WriteToPath(bytes.NewReader([]byte("z")), target, Inputs{
|
|
Prompt: "p", Backend: "b", Seed: 7, Ext: "png",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WriteToPath: %v", err)
|
|
}
|
|
if out.ImagePath != target {
|
|
t.Errorf("image path = %q, want %q", out.ImagePath, target)
|
|
}
|
|
if _, err := os.Stat(target); err != nil {
|
|
t.Errorf("expected %s to exist: %v", target, err)
|
|
}
|
|
if _, err := os.Stat(target + ".json"); err != nil {
|
|
t.Errorf("expected sidecar to exist: %v", err)
|
|
}
|
|
}
|