mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
53 Commits
fix/upload
...
feat-svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d5533c9a | ||
|
|
27e7ef8d5b | ||
|
|
4364609e37 | ||
|
|
a6b35d1e77 | ||
|
|
8d0197630f | ||
|
|
7ef44f7c27 | ||
|
|
33596111c7 | ||
|
|
fe8620425d | ||
|
|
76214a6176 | ||
|
|
a843ef0ac2 | ||
|
|
2a3e6ef2ef | ||
|
|
16f075b04a | ||
|
|
e5e17c17cf | ||
|
|
46014e9b77 | ||
|
|
57cc929ad1 | ||
|
|
bd63a20342 | ||
|
|
d82d4e3333 | ||
|
|
66ea925c3a | ||
|
|
0672f6de28 | ||
|
|
4dc182b8dd | ||
|
|
306307b3b3 | ||
|
|
589200c8c2 | ||
|
|
a215a33c8b | ||
|
|
1666c4db43 | ||
|
|
f3a40e4cda | ||
|
|
00222052ef | ||
|
|
f8950cdc8a | ||
|
|
74e7c5abee | ||
|
|
50754e53b1 | ||
|
|
ca8efe5d92 | ||
|
|
5ae2594a5f | ||
|
|
fd96f6e895 | ||
|
|
81c36bcf85 | ||
|
|
283462a36f | ||
|
|
d4e074a494 | ||
|
|
15e7ab8b66 | ||
|
|
f043ee61d8 | ||
|
|
5b264cf7b2 | ||
|
|
ead6362ab6 | ||
|
|
9c0c5ae26a | ||
|
|
8a450b6437 | ||
|
|
e196f68ef6 | ||
|
|
dff21a86ec | ||
|
|
38bf5402d9 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 |
@@ -22,6 +22,11 @@ import (
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdAuthWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdAuthWithContext creates the auth command with subcommands.
|
||||
func NewCmdAuthWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
@@ -38,7 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLoginWithContext(ctx, f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
|
||||
@@ -42,6 +42,11 @@ var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
return NewCmdAuthLoginWithContext(context.Background(), f, runF)
|
||||
}
|
||||
|
||||
// NewCmdAuthLoginWithContext creates the auth login subcommand.
|
||||
func NewCmdAuthLoginWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -73,7 +78,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
|
||||
18
cmd/build.go
18
cmd/build.go
@@ -90,8 +90,9 @@ func WithoutPlugins() BuildOption {
|
||||
}
|
||||
|
||||
// WithoutStrictMode builds the complete repository-owned command tree without
|
||||
// applying user/profile strict-mode pruning. It is intended for offline
|
||||
// inspection tools, not production execution.
|
||||
// applying user/profile strict-mode pruning or credential-backed bootstrap
|
||||
// probes. It is intended for offline inspection tools and pure local commands
|
||||
// that must not require account configuration.
|
||||
func WithoutStrictMode() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipStrictMode = true
|
||||
@@ -146,6 +147,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
o(cfg)
|
||||
}
|
||||
}
|
||||
if cfg.skipStrictMode {
|
||||
ctx = cmdutil.ContextWithCredentialBootstrapDisabled(ctx)
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
@@ -192,10 +196,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuthWithContext(ctx, f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoamiWithContext(ctx, f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -218,8 +222,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
if !cfg.skipStrictMode {
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
|
||||
34
cmd/root.go
34
cmd/root.go
@@ -103,10 +103,16 @@ func Execute() int {
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
ctx := context.Background()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
buildOpts := []BuildOption{
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
}
|
||||
if isLocalSVGlideInvocation(rawInvocationArgs) {
|
||||
buildOpts = append(buildOpts, WithoutStrictMode())
|
||||
}
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
buildOpts...,
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
@@ -130,6 +136,30 @@ func Execute() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isLocalSVGlideInvocation(args []string) bool {
|
||||
positionals := make([]string, 0, 2)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--profile":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
continue
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
continue
|
||||
default:
|
||||
positionals = append(positionals, arg)
|
||||
if len(positionals) == 2 {
|
||||
return positionals[0] == "slides" && positionals[1] == "+create-svglide"
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
|
||||
@@ -5,9 +5,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -26,6 +29,27 @@ import (
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
type countingKeychain struct {
|
||||
gets int
|
||||
sets int
|
||||
removes int
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Get(service, account string) (string, error) {
|
||||
k.gets++
|
||||
return "", fmt.Errorf("unexpected keychain Get for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Set(service, account, value string) error {
|
||||
k.sets++
|
||||
return fmt.Errorf("unexpected keychain Set for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Remove(service, account string) error {
|
||||
k.removes++
|
||||
return fmt.Errorf("unexpected keychain Remove for %s/%s", service, account)
|
||||
}
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
// auth, config, and schema commands have auth check disabled,
|
||||
// while api does not.
|
||||
@@ -75,6 +99,63 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalSVGlideInvocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "local svglide", args: []string{"slides", "+create-svglide", "--action", "init"}, want: true},
|
||||
{name: "with profile", args: []string{"--profile", "demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "with profile equals", args: []string{"--profile=demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "other slides shortcut", args: []string{"slides", "+create"}, want: false},
|
||||
{name: "root help", args: []string{"--help"}, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLocalSVGlideInvocation(tt.args); got != tt.want {
|
||||
t.Fatalf("isLocalSVGlideInvocation(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSVGlideRootCommandDoesNotTouchKeychain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
kc := &countingKeychain{}
|
||||
_, rootCmd, _ := buildInternal(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(&in, &out, &errOut),
|
||||
WithKeychain(kc),
|
||||
WithoutStrictMode(),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
rootCmd.SetArgs([]string{
|
||||
"slides",
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout=%s\nstderr=%s", err, out.String(), errOut.String())
|
||||
}
|
||||
if kc.gets != 0 || kc.sets != 0 || kc.removes != 0 {
|
||||
t.Fatalf("keychain touched: gets=%d sets=%d removes=%d", kc.gets, kc.sets, kc.removes)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
|
||||
@@ -54,6 +54,12 @@ type Options struct {
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdWhoamiWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdWhoamiWithContext creates the whoami command using the build context
|
||||
// for registration-time strict-mode presentation.
|
||||
func NewCmdWhoamiWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
@@ -63,7 +69,7 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &opts.As)
|
||||
// Output is always JSON. Accept (and ignore) --json so existing
|
||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||
// mode exists.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# `slides +create-svglide` Codex Runtime Design
|
||||
|
||||
Date: 2026-07-02
|
||||
Branch: `feat-svglide-07`
|
||||
Scope: first local-only version of `lark-cli slides +create-svglide`
|
||||
|
||||
## Result
|
||||
|
||||
Build `slides +create-svglide` as a staged local runtime for AnyGen SVG Slides. The command creates and manages a run directory that Codex can fill with generated content, assets, and SVG slides. The CLI owns state, prompts, schemas, validation, preview, receipts, and recovery. Codex owns LLM reasoning, web research, image/search execution, chart design, and SVG authoring.
|
||||
|
||||
The first version does not publish to Feishu Slides. It must produce a local, inspectable SVG deck workbench.
|
||||
|
||||
## Context
|
||||
|
||||
`feat-svglide-07` currently starts from the latest `origin/main` and has only the existing Slides XML shortcut surface. There is no current `+create-svglide` implementation on this branch.
|
||||
|
||||
The AnyGen SVG Slides prompt should be reused as contracts and workflow rules, not pasted as one large prompt. Its value is split across request interpretation, research, design brief, outline, `slide_content.md`, asset planning, SVG authoring, protocol validation, preview, and repair.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a staged `slides +create-svglide` command group.
|
||||
- Create a local run directory under a user-specified `--out` path, usually `.lark-slides/svglide-runs/<run-id>`.
|
||||
- Generate prompt task files that tell Codex exactly what to produce for each stage.
|
||||
- Generate JSON schemas for stage outputs.
|
||||
- Track stage state in `run.json`.
|
||||
- Validate JSON outputs, SVG protocol basics, asset href existence, slide count, placeholder slides, and preview generation.
|
||||
- Generate `preview.html` for local inspection.
|
||||
- Write receipts and `repair_queue.md` so failed runs can resume from the current stage.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No online Feishu Slides creation.
|
||||
- No `slide_engine` or `slide` server changes.
|
||||
- No SVG-to-SXSD conversion.
|
||||
- No built-in model API provider.
|
||||
- No built-in web search, image generation, or image search client.
|
||||
- No complete 12-agent process runner.
|
||||
- No PPTX import/edit workflow.
|
||||
|
||||
## Command Surface
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide init --title "Demo" --input ./source.md --audience "..." --delivery-mode self_read --pages 8 --out ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide next <run-dir>
|
||||
lark-cli slides +create-svglide status <run-dir>
|
||||
lark-cli slides +create-svglide validate <run-dir>
|
||||
lark-cli slides +create-svglide preview <run-dir>
|
||||
```
|
||||
|
||||
`init` creates the run directory, writes the initial request files, schemas, stage prompts, and `run.json`.
|
||||
|
||||
`next` reads `run.json`, finds the next stage, verifies required inputs, renders or refreshes that stage's Codex task prompt, and reports the exact files Codex must create. It must not pretend LLM work is complete.
|
||||
|
||||
`status` checks declared outputs and receipts for each stage, then prints the current stage, missing files, and next useful command.
|
||||
|
||||
`validate` runs deterministic checks and writes validation receipts.
|
||||
|
||||
`preview` writes `preview.html` from `outline/deck.json` and `slides/*.svg`.
|
||||
|
||||
## Run Directory Contract
|
||||
|
||||
```text
|
||||
<run-dir>/
|
||||
run.json
|
||||
README.md
|
||||
request/request.json
|
||||
request/source_manifest.json
|
||||
research/research_notes.md
|
||||
research/sources.json
|
||||
brief/design_brief.json
|
||||
brief/visual_system.json
|
||||
outline/deck.json
|
||||
content/slide_content.md
|
||||
content/slide_content.json
|
||||
assets/assets_plan.json
|
||||
assets/images/
|
||||
assets/charts/
|
||||
slides/*.svg
|
||||
prompts/*.task.md
|
||||
schemas/*.schema.json
|
||||
receipts/*.json
|
||||
receipts/generation_summary.md
|
||||
repair_queue.md
|
||||
preview.html
|
||||
```
|
||||
|
||||
The run directory is local agent state. It should not be committed by default.
|
||||
|
||||
## State Model
|
||||
|
||||
`run.json` stores:
|
||||
|
||||
- version
|
||||
- runtime, always `codex` in v1
|
||||
- command name
|
||||
- title
|
||||
- created and updated timestamps
|
||||
- current stage
|
||||
- stage list with status, inputs, outputs, and receipt path
|
||||
- important artifact paths
|
||||
- policy flags: `publish_enabled=false`, `network_by_codex=true`, `image_generation_by_codex=true`, `overwrite=false`
|
||||
|
||||
Stage statuses:
|
||||
|
||||
```text
|
||||
pending
|
||||
ready
|
||||
in_progress
|
||||
done
|
||||
failed
|
||||
blocked
|
||||
needs_repair
|
||||
```
|
||||
|
||||
## Stage Design
|
||||
|
||||
### 1. request
|
||||
|
||||
Role: Request Interpreter
|
||||
|
||||
Input: CLI flags and local source path.
|
||||
|
||||
Output: `request/request.json`, `request/source_manifest.json`.
|
||||
|
||||
Validation: title, audience, delivery mode, page count, and source references must be explicit or marked missing.
|
||||
|
||||
### 2. research
|
||||
|
||||
Role: Researcher
|
||||
|
||||
Input: request files and source files.
|
||||
|
||||
Output: `research/research_notes.md`, `research/sources.json`.
|
||||
|
||||
Validation: key facts need source references. Codex may perform web research, but the CLI only validates resulting files.
|
||||
|
||||
### 3. design_brief
|
||||
|
||||
Role: Design Brief Resolver and Visual System Planner
|
||||
|
||||
Input: request and research outputs.
|
||||
|
||||
Output: `brief/design_brief.json`, `brief/visual_system.json`.
|
||||
|
||||
Validation: narrative spine, depth, tone, and visual system dimensions must be present.
|
||||
|
||||
### 4. outline
|
||||
|
||||
Role: Outline Planner
|
||||
|
||||
Input: design brief.
|
||||
|
||||
Output: `outline/deck.json`.
|
||||
|
||||
Validation: page count matches request; each slide has id, title, summary, role, and key message.
|
||||
|
||||
### 5. slide_content
|
||||
|
||||
Role: Content Builder
|
||||
|
||||
Input: deck outline and research notes.
|
||||
|
||||
Output: `content/slide_content.md`, `content/slide_content.json`.
|
||||
|
||||
Validation: every slide has key material, content blocks, and source notes. This is content planning, not final layout.
|
||||
|
||||
### 6. assets
|
||||
|
||||
Role: Asset Planner and Chart Generator
|
||||
|
||||
Input: slide content and visual system.
|
||||
|
||||
Output: `assets/assets_plan.json`, optional `assets/images/*`, optional `assets/charts/*.svg`.
|
||||
|
||||
Validation: every planned asset has purpose plus either a local path or a fallback. Chart takeaway must be written before chart type.
|
||||
|
||||
### 7. svg_author
|
||||
|
||||
Role: SVG Author
|
||||
|
||||
Input: deck, slide content, visual system, and assets.
|
||||
|
||||
Output: `slides/*.svg`.
|
||||
|
||||
Validation: each slide must contain more than a background. Each slide needs a background, title, visible content or visual element, semantic id, and valid SVG root.
|
||||
|
||||
### 8. validate_preview_repair
|
||||
|
||||
Role: Protocol Validator, Preview Agent, and Repair Agent
|
||||
|
||||
Input: generated slides.
|
||||
|
||||
Output: `receipts/lint.json`, `receipts/preview.json`, `repair_queue.md`, `preview.html`.
|
||||
|
||||
Validation: SVG protocol lint, local href checks, slide count match, preview write success, and unresolved issues recorded in the repair queue.
|
||||
|
||||
## Code Layout
|
||||
|
||||
```text
|
||||
shortcuts/slides/
|
||||
slides_create_svglide.go
|
||||
slides_create_svglide_test.go
|
||||
|
||||
internal/svglide/
|
||||
run.go
|
||||
init.go
|
||||
stage.go
|
||||
prompt.go
|
||||
schema.go
|
||||
validate.go
|
||||
preview.go
|
||||
receipt.go
|
||||
```
|
||||
|
||||
The shortcut package should stay thin. State, prompt rendering, validation, and preview logic belong in `internal/svglide` so they can be tested without a Cobra/runtime-heavy command harness.
|
||||
|
||||
## Skill Documentation
|
||||
|
||||
Update `skills/lark-slides/SKILL.md` and add a focused reference file for the local SVG runtime. The skill should explain that `+create-svglide` is local-only in v1, requires Codex to fill stage outputs, and must not be described as an online publish path.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing required inputs block the stage and write a receipt.
|
||||
- Invalid JSON or schema mismatch marks the stage failed.
|
||||
- Invalid SVG marks `needs_repair` and writes `repair_queue.md`.
|
||||
- Existing output paths are not overwritten unless an explicit overwrite policy is enabled.
|
||||
- Partially completed stages remain inspectable; reruns resume from the current stage.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `init` creates the expected directory tree and `run.json`.
|
||||
- `init` refuses to overwrite an existing run directory by default.
|
||||
- `status` identifies missing outputs.
|
||||
- `next` renders the correct stage prompt and does not mark Codex-only stages done.
|
||||
- `validate` catches invalid SVG, missing hrefs, placeholder slides, and slide count mismatch.
|
||||
- `preview` writes HTML that references generated SVG files.
|
||||
|
||||
Fixtures:
|
||||
|
||||
- `testdata/svglide_run_valid/`
|
||||
- `testdata/svglide_run_invalid/`
|
||||
|
||||
No live end-to-end test is required for v1 because this version does not call Feishu APIs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A user can initialize a run directory from local input.
|
||||
- Codex can follow generated task prompts stage by stage.
|
||||
- The CLI can report status and missing artifacts.
|
||||
- The CLI can validate a completed local SVG deck.
|
||||
- The CLI can generate local preview HTML.
|
||||
- Failed validation produces actionable repair output.
|
||||
- No online presentation is created.
|
||||
|
||||
## Further Judgment
|
||||
|
||||
This design deliberately optimizes for artifact contracts rather than agent-count symmetry. Once the local runtime is stable, individual stages can be split into fuller agents without changing the run directory contract.
|
||||
2426
docs/vendor/anygen-svg/source.full.md
vendored
Normal file
2426
docs/vendor/anygen-svg/source.full.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8
docs/vendor/anygen-svg/source.meta.json
vendored
Normal file
8
docs/vendor/anygen-svg/source.meta.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"doc_url": "https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd",
|
||||
"local_full_snapshot": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.md",
|
||||
"local_handoff": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md",
|
||||
"fetched_by": "local export",
|
||||
"fetched_for": "slides +create-svglide AnyGen SVG prompt runtime experiment",
|
||||
"experiment_mode": "experiment_unrestricted_assets"
|
||||
}
|
||||
20
docs/vendor/anygen-svg/source.outline.md
vendored
Normal file
20
docs/vendor/anygen-svg/source.outline.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# AnyGen SVG Slides Local Outline
|
||||
|
||||
Source full snapshot: `docs/vendor/anygen-svg/source.full.md`
|
||||
Source handoff: `/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md`
|
||||
Remote doc: `https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd`
|
||||
|
||||
Required sections to split:
|
||||
|
||||
- System prompt(编排 / mode_system_prompt_svg)
|
||||
- SVG reference(协议 schema + 设计规范 / svg_reference)
|
||||
- resolve_design_brief
|
||||
- slide_outline
|
||||
- activate_slides_edit
|
||||
- slides_edit
|
||||
- finish_slides_edit
|
||||
- slide_organize
|
||||
- compute_custom_shape_bbox
|
||||
- generate_svg_chart
|
||||
- slides_convert
|
||||
- slides_parse_template
|
||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||
WithMissingScopes("mail:user_mailbox.message:send").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||
WithMissingScopes("calendar:event:create").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"identity": "user",
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||
"missing_scopes": []any{"calendar:event:create"},
|
||||
}
|
||||
for k, want := range wantFields {
|
||||
|
||||
@@ -48,6 +48,22 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
type skipCredentialBootstrapKey struct{}
|
||||
|
||||
// ContextWithCredentialBootstrapDisabled marks a command-tree build as
|
||||
// credential-free. Use it only for purely local command surfaces that must be
|
||||
// constructed without probing strict-mode, profile, or keychain state.
|
||||
func ContextWithCredentialBootstrapDisabled(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, skipCredentialBootstrapKey{}, true)
|
||||
}
|
||||
|
||||
// IsCredentialBootstrapDisabled reports whether credential-backed bootstrap
|
||||
// probes must be skipped for this context.
|
||||
func IsCredentialBootstrapDisabled(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(skipCredentialBootstrapKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
@@ -109,6 +125,9 @@ func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
}
|
||||
|
||||
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return nil
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -148,6 +167,9 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
// ResolveStrictMode returns the effective strict mode by reading
|
||||
// Account.SupportedIdentities from the credential provider chain.
|
||||
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||
// coupling this contract to core's brand enum.
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
// QueryEscape both values — clientID and scopes both sit in the query
|
||||
// string, and untrusted content must not be able to inject extra query
|
||||
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||
// open-platform base URL stays a single source of truth.
|
||||
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
|
||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
wantInURL: []string{"scopes=scope%23fragment"},
|
||||
denyInURL: []string{"scopes=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||
denyInURL: []string{"clientID=good?q=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
wantInURL: []string{"clientID=good%23fragment"},
|
||||
denyInURL: []string{"clientID=good#fragment"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
name: "slash in appID does not open a new path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
||||
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||
if !strings.Contains(got, "developer console") {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -78,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
if isPlaceholderCredentialURL(file, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
if !warnForPrivateIPv4(file) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
@@ -130,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
@@ -284,6 +291,9 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
@@ -313,11 +323,109 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
value = strings.TrimPrefix(value, "$")
|
||||
value = strings.Trim(value, "%")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func highEntropyCredentialValue(value string) bool {
|
||||
if len(value) < 32 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-' || r == '.' || r == '=':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
|
||||
}
|
||||
|
||||
func shannonEntropy(value string) float64 {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
counts := map[rune]int{}
|
||||
for _, r := range value {
|
||||
counts[r]++
|
||||
}
|
||||
var entropy float64
|
||||
length := float64(len([]rune(value)))
|
||||
for _, count := range counts {
|
||||
p := float64(count) / length
|
||||
entropy -= p * log2(p)
|
||||
}
|
||||
return entropy
|
||||
}
|
||||
|
||||
func log2(value float64) float64 {
|
||||
return math.Log(value) / math.Ln2
|
||||
}
|
||||
|
||||
func authCredentialTokenKey(key string) bool {
|
||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||
case "access_token",
|
||||
"api_token",
|
||||
"bot_token",
|
||||
"refresh_token",
|
||||
"secret_token",
|
||||
"session_token",
|
||||
"service_token",
|
||||
"bearer_token",
|
||||
"auth_token",
|
||||
"authorization_token",
|
||||
@@ -844,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -853,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
return credentialURLPasswordPlaceholder(password) ||
|
||||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
@@ -867,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
"pw",
|
||||
"s3cret",
|
||||
"secret",
|
||||
"t":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceOrTestFixtureFile(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
return sourceCodeFile(normalized) ||
|
||||
strings.HasPrefix(normalized, "testdata/") ||
|
||||
strings.HasPrefix(normalized, "fixtures/") ||
|
||||
strings.Contains(normalized, "/testdata/") ||
|
||||
strings.Contains(normalized, "/fixtures/")
|
||||
}
|
||||
|
||||
func warnForPrivateIPv4(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
if sourceOrTestFixtureFile(normalized) {
|
||||
return false
|
||||
}
|
||||
switch filepath.Ext(normalized) {
|
||||
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(normalized, "docs/") ||
|
||||
strings.HasPrefix(normalized, "skills/")
|
||||
}
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
|
||||
@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
|
||||
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@10.0.0.1:3128"`,
|
||||
`target := "socks5://admin:secret@172.16.0.1:1080"`,
|
||||
`host := "192.168.0.10"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
||||
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||
if len(benign) != 0 {
|
||||
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
|
||||
`proxy: http://user:pass@proxy:8080`,
|
||||
`repo: https://u:t@h/r.git`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
|
||||
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
|
||||
`endpoint: http://10.0.0.1:8080`,
|
||||
`redis: 192.168.1.10:6379`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||
for _, item := range got {
|
||||
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||
"URL=https://<user>:real-secret@example.invalid/path",
|
||||
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
|
||||
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
|
||||
`{"token":"img_abc123"}`,
|
||||
`{"token":"img_live_secret"}`,
|
||||
`{"token":"img_prod_key"}`,
|
||||
`token=ab********cd`,
|
||||
`{"image_token":"img_live_secret"}`,
|
||||
`{"data_mail_token":"mail_abc123"}`,
|
||||
`{"whiteboard_token":"board_v3_example"}`,
|
||||
`{"want_token":"token from callback"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
stripeToken := "sk_" + "live_1234567890abcdef"
|
||||
randomToken := strings.Join([]string{
|
||||
"a1b2c3d4",
|
||||
"e5f6g7h8",
|
||||
"i9j0k1l2",
|
||||
"m3n4p5q6",
|
||||
}, "")
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"token":"` + githubToken + `"}`,
|
||||
`token=` + stripeToken,
|
||||
`{"image_token":"` + githubToken + `"}`,
|
||||
`{"token":"` + randomToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
@@ -1052,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
||||
if appID == "" || scope == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
core.ResolveOpenBaseURL(brand),
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
|
||||
348
internal/svglide/agent_runtime_e2e_test.go
Normal file
348
internal/svglide/agent_runtime_e2e_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFakeAgentHappyPathProducesSVGDeck(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
opts := InitOptions{Title: "电影介绍", Pages: 1}
|
||||
setStringInitOptionField(t, &opts, "Topic", "介绍一部电影")
|
||||
setStringInitOptionField(t, &opts, "Language", "zh")
|
||||
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
|
||||
setStringInitOptionField(t, &opts, "AgentID", "fake-agent-e2e")
|
||||
|
||||
if err := InitRun("demo", opts); err != nil {
|
||||
t.Fatalf("topic-only fake-agent init should succeed: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("demo", "receipts", "prompt_context"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, stage := range []string{
|
||||
StageRequest,
|
||||
StageRequestResolution,
|
||||
StageResearch,
|
||||
StageDesignBrief,
|
||||
StageOutline,
|
||||
StageSlideContent,
|
||||
StageAssets,
|
||||
StageSVGAuthor,
|
||||
} {
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != stage {
|
||||
t.Fatalf("current stage = %q, want %q", run.CurrentStage, stage)
|
||||
}
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", stage, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, stage)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
writeFakeAgentStageArtifacts(t, stage)
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("complete %s: %v", stage, err)
|
||||
}
|
||||
}
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", StageValidatePreviewRepair, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, StageValidatePreviewRepair)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
|
||||
repair, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("repair: %v", err)
|
||||
}
|
||||
if repair.Status != "passed" {
|
||||
t.Fatalf("repair status = %q, want passed: %+v", repair.Status, repair)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/contact-sheet.png", "png")
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("complete %s: %v", StageValidatePreviewRepair, err)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"receipts/image_usage.json",
|
||||
"receipts/chart_quality.json",
|
||||
"quality_report.json",
|
||||
"anygen_semantic_report.json",
|
||||
"receipts/delivery.json",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("missing final artifact %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivery DeliveryReceipt
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.SemanticMetrics.VisibleLeakCount != 0 || delivery.SemanticMetrics.MissingFontTokenCount != 0 {
|
||||
t.Fatalf("delivery semantic metrics = %+v, want no visible leaks and all font tokens", delivery.SemanticMetrics)
|
||||
}
|
||||
if delivery.Status != StatusReady {
|
||||
t.Fatalf("delivery status = %q, want ready with full-chain screenshot evidence: %+v", delivery.Status, delivery.FullChainEvidence)
|
||||
}
|
||||
if len(delivery.FullChainEvidence.ScreenshotEvidence) == 0 {
|
||||
t.Fatalf("delivery screenshot evidence is empty: %+v", delivery.FullChainEvidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeAgentChartChainRendersAndValidatesVegaLite(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
opts := InitOptions{Title: "Chart Deck", Pages: 1}
|
||||
setStringInitOptionField(t, &opts, "Topic", "chart-only revenue comparison")
|
||||
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
|
||||
setStringInitOptionField(t, &opts, "AgentID", "fake-agent-chart-e2e")
|
||||
|
||||
if err := InitRun("demo", opts); err != nil {
|
||||
t.Fatalf("chart fake-agent init should succeed: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("demo", "receipts", "prompt_context"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"input_text":"chart-only revenue comparison","resolved_entity":{"name":"chart-only revenue comparison","type":"topic","confidence_bp":9000,"confidence_band":"high","reason":"chart-only E2E fixture"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"visual_quality_contract":{"profile":"data_report","requires_real_images":false,"required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1,"reason":"chart-only E2E"},"clarification_question":""}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"web1","path":"https://example.com/filing","title":"Company filing","excerpt":"Segment revenue data","usage":"chart data","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#76B900"},"typography":{"title":32,"body":16},"layout_language":"financial chart page"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"prompt_contract":`+promptContractJSON(StageOutline)+`,"title":"Chart Deck","slides":[{"id":"s1","title":"Data center leads","summary":"Revenue mix comparison","role":"content","key_message":"Data center revenue leads the mix","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"Data center leads","body":"Data center revenue leads the mix","labels":["Revenue $B","Source: web1"]},"production_instruction":{"layout":"Embed the rendered chart asset with rect role","asset_ids":["revenue_mix"]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Data center revenue leads the mix","source_refs":["web1"],"visuals":[{"id":"revenue_mix","type":"chart","instruction":"Compare segment revenue"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"no_image_reason":"chart-only E2E fixture; no raster image required.","candidates":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[],"no_image_reason":"chart-only E2E fixture; no raster image required."}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[],"no_image_reason":"chart-only E2E fixture; no raster image required."}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_briefs.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[{"id":"revenue_mix","slide_id":"s1","purpose":"comparison","takeaway":"Data center revenue leads the mix","renderer":"vega-lite","data_source_ids":["web1"],"unit":"$B","min_width":600,"min_height":320}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/specs/revenue_mix.vl.json", `{"$schema":"https://vega.github.io/schema/vega-lite/v5.json","width":640,"height":360,"title":{"text":"Segment revenue comparison ($B)","subtitle":"Source: web1 company filing"},"data":{"values":[{"segment":"Data Center","revenue":22.1},{"segment":"Gaming","revenue":2.9},{"segment":"Professional Visualization","revenue":0.5}]},"mark":{"type":"bar","tooltip":true},"encoding":{"x":{"field":"segment","type":"nominal","sort":"-y","axis":{"title":"Segment"}},"y":{"field":"revenue","type":"quantitative","axis":{"title":"Revenue $B"}},"color":{"field":"segment","type":"nominal","legend":null}}}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"revenue_mix","slide_id":"s1","renderer":"vega-lite","brief_id":"revenue_mix","spec_path":"assets/charts/specs/revenue_mix.vl.json","svg_path":"assets/charts/revenue_mix.svg","source_id":"web1","unit":"$B","takeaway":"Data center revenue leads the mix","render_receipt":"receipts/chart_render.json"}]}`)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", StageAssets, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, StageAssets)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("complete %s: %v", StageAssets, err)
|
||||
}
|
||||
if status.CurrentStage != StageSVGAuthor {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageSVGAuthor)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "assets", "charts", "revenue_mix.svg")); err != nil {
|
||||
t.Fatalf("missing rendered chart SVG: %v", err)
|
||||
}
|
||||
var renderReport ChartRenderReport
|
||||
raw, err := readRunRegularArtifact("demo", chartRenderReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &renderReport); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if renderReport.Status != "passed" || len(renderReport.Charts) != 1 {
|
||||
t.Fatalf("chart render report = %+v, want one passed chart", renderReport)
|
||||
}
|
||||
|
||||
next, err = NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask(%s): %v", StageSVGAuthor, err)
|
||||
}
|
||||
assertNextTaskHasRuntimeProtocolFields(t, next, StageSVGAuthor)
|
||||
writeFakeAgentReceiptsFromNext(t, next)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<slide:note>Source: web1</slide:note><rect width="960" height="540" fill="#fff"/><text x="48" y="72">Data center leads</text><rect slide:role="chart" href="../assets/charts/revenue_mix.svg" x="80" y="120" width="720" height="360"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"evidence","layout_family":"data_report","layout_archetype":"chart_forward","layout_signature":"hero_chart_with_title","thumbnail_job":"chart","visual_center":"Vega-Lite rendered revenue chart","topic_fit_claim":"uses chart evidence for revenue comparison","information_density_plan":"one chart plus one title","page_difference_from_previous":"single-slide chart fixture","primary_asset":"assets/charts/revenue_mix.svg","asset_role":"chart evidence","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"chart-forward financial evidence","data_visual_rationale":"Revenue comparison needs a standard chart","source_evidence":["web1 supports revenue data"],"container_fit_plan":"chart has open canvas and title outside chart bounds","container_decision":"no text card needed","text_carrier":"axis_annotation","typography_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"shape_language":"chart_forward","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"revenue_mix","renderer":"vega-lite","unit":"$B","source":"web1","why_chart_is_needed":"compare segment revenue"},"fusion_spec":{"enabled":false},"qa_expectations":["chart is rendered asset, not hand drawn"]}]}`)
|
||||
|
||||
status, err = CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("complete %s: %v", StageSVGAuthor, err)
|
||||
}
|
||||
if status.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
quality, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality.Status != "passed" {
|
||||
t.Fatalf("quality = %+v, want passed", quality)
|
||||
}
|
||||
var usage ChartUsageReport
|
||||
raw, err = readRunRegularArtifact("demo", chartUsageReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &usage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usage.Status != "passed" || len(usage.Charts) != 1 {
|
||||
t.Fatalf("chart usage = %+v, want one passed chart", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNextTaskHasRuntimeProtocolFields(t *testing.T, next NextTaskReport, stage string) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["protocol"] != "anygen-svg-slides" {
|
||||
t.Fatalf("%s next.protocol = %v, want anygen-svg-slides", stage, payload["protocol"])
|
||||
}
|
||||
agentTask, ok := payload["agent_task"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s next.agent_task missing: %+v", stage, payload)
|
||||
}
|
||||
if agentTask["stage"] != stage {
|
||||
t.Fatalf("%s agent_task.stage = %v, want %s", stage, agentTask["stage"], stage)
|
||||
}
|
||||
if payload["prompt_context"] == nil || payload["tool_invocation_contract"] == nil {
|
||||
t.Fatalf("%s next missing prompt_context/tool_invocation_contract: %+v", stage, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeAgentReceiptsFromNext(t *testing.T, next NextTaskReport) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolContract, _ := payload["tool_invocation_contract"].(map[string]any)
|
||||
for _, call := range callsFromContract(toolContract, "required_calls", "conditional_calls") {
|
||||
id, _ := call["id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
writeToolCallReceiptFromContractForE2E(t, next, call)
|
||||
}
|
||||
}
|
||||
|
||||
func callsFromContract(contract map[string]any, keys ...string) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, key := range keys {
|
||||
values, _ := contract[key].([]any)
|
||||
for _, value := range values {
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
out = append(out, object)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeToolCallReceiptFromContractForE2E(t *testing.T, next NextTaskReport, call map[string]any) {
|
||||
t.Helper()
|
||||
id, _ := call["id"].(string)
|
||||
promptID, _ := call["prompt_id"].(string)
|
||||
if promptID == "" {
|
||||
promptID = id
|
||||
}
|
||||
consumed := stringsFromJSONValue(call["consumes"])
|
||||
if len(consumed) == 0 {
|
||||
consumed = next.Inputs
|
||||
}
|
||||
produced := stringsFromJSONValue(call["produces"])
|
||||
if len(produced) == 0 {
|
||||
produced = next.Outputs
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"protocol": "anygen-svg-slides",
|
||||
"stage": next.Stage,
|
||||
"call_id": id,
|
||||
"prompt_id": promptID,
|
||||
"invocation": stringFromJSONValue(call["invocation"], "required"),
|
||||
"condition": stringFromJSONValue(call["condition"], "always"),
|
||||
"condition_matched": true,
|
||||
"order": intFromJSONValue(call["order"]),
|
||||
"cardinality": stringFromJSONValue(call["cardinality"], "once"),
|
||||
"consumed": consumed,
|
||||
"produced": produced,
|
||||
"status": "done",
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "tool_calls", next.Stage, id+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func stringFromJSONValue(value any, fallback string) string {
|
||||
if text, ok := value.(string); ok && text != "" {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func intFromJSONValue(value any) int {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int(typed)
|
||||
case int:
|
||||
return typed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func stringsFromJSONValue(value any) []string {
|
||||
values, _ := value.([]any)
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
if text, ok := item.(string); ok {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFakeAgentStageArtifacts(t *testing.T, stage string) {
|
||||
t.Helper()
|
||||
switch stage {
|
||||
case StageRequest:
|
||||
return
|
||||
case StageRequestResolution:
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"prompt_contract":`+promptContractJSON(StageRequestResolution)+`,"input_text":"介绍一部电影","resolved_entity":{"name":"介绍一部电影","type":"topic","confidence_bp":5000,"confidence_band":"medium","reason":"用户请求是开放主题,需要先研究确定内容方向"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"clarification_question":""}`)
|
||||
case StageResearch:
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# 电影资料\n\n用户提供主题。")
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"user1","path":"topic://介绍一部电影","title":"用户主题","excerpt":"介绍一部电影","usage":"primary brief","retrieval":"user_provided"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"介绍一部电影","type":"topic"},"queries":[{"query":"介绍一部电影","purpose":"context"}],"sources":[{"id":"user1","title":"用户主题","url":"topic://介绍一部电影","retrieved_at":"2026-07-04T00:00:00Z","usage":"context","status":"retrieved"}],"coverage":{"identity_confirmed":false,"has_reliable_source":true,"minimum_source_count_met":true,"source_count":1,"topic_only_rationale":"开放主题需要用研究材料确定内容边界。"}}`)
|
||||
case StageDesignBrief:
|
||||
writeValidDesignBriefOutputs(t)
|
||||
case StageOutline:
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"prompt_contract":`+promptContractJSON(StageOutline)+`,"main_title":"电影介绍","style_instruction":{"aesthetic_direction":"Editorial cinematic deck","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"一部电影","summary":"用一个清晰观点介绍电影","role":"cover","key_message":"电影的核心吸引力","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"image_claim","story_function":"hook","primary_asset_role":"cinematic topic anchor","fusion_candidate":false,"path":"slides/01.svg"}]}`)
|
||||
case StageSlideContent:
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# 一部电影\n\n电影的核心吸引力。")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"一部电影","body":"电影的核心吸引力","labels":["电影"]},"production_instruction":{"layout":"Use local hero image, no visible source note","asset_ids":["hero"]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"电影的核心吸引力","source_refs":["user1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a cinematic hero image"}]}]}`)
|
||||
case StageAssets:
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[{"id":"cand-hero","query":"movie hero image","source_url":"https://example.com/movie-hero.png","source_class":"user_provided","format":"png","width":1200,"height":800,"has_alpha":false,"asset_role":"hero_photo","fit_role":"split_panel","local_path":"assets/images/movie-hero.png","score_bp":9000,"selected":true,"selection_reason":"user-provided cinematic hero image","format_exception_reason":"","rejection_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/movie-hero.png","usage":"Cinematic hero image","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","kind":"image","source_url":"https://example.com/movie-hero.png","local_path":"assets/images/movie-hero.png","usage":"Cinematic hero image","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/movie-hero.png","source_url":"https://example.com/movie-hero.png","width":1200,"height":800,"semantic_type":"hero","large_ok":true,"full_bleed_ok":false,"recommended_use":"cover split image","avoid_reason":"","format":"png","has_alpha":false,"asset_role":"hero_photo","fit_role":"split_panel","candidate_id":"cand-hero","selection_reason":"user-provided cinematic hero image","format_exception_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/movie-hero.png", "png")
|
||||
case StageSVGAuthor:
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<slide:note>Source: user1</slide:note><rect width="960" height="540" fill="#fff"/><image slide:role="image" href="../assets/images/movie-hero.png" x="520" y="80" width="320" height="240"/><text x="48" y="88">电影介绍</text><text x="48" y="150">电影的核心吸引力</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"image_claim","thumbnail_job":"电影介绍","visual_center":"movie hero image and title","topic_fit_claim":"introduces the requested movie topic","information_density_plan":"one claim plus one visual anchor","page_difference_from_previous":"opening page","primary_asset":"assets/images/movie-hero.png","asset_role":"cinematic topic anchor","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"image-led cinematic introduction","data_visual_rationale":"","source_evidence":["user1 supports the topic"],"container_fit_plan":"text sits in image-safe open area with no default card","container_decision":"image-led open composition","text_carrier":"image_dark_zone","typography_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"shape_language":"image_annotation","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["no visible process text"]}]}`)
|
||||
default:
|
||||
t.Fatalf("unexpected fake-agent stage %q", stage)
|
||||
}
|
||||
}
|
||||
1016
internal/svglide/anygen_semantics.go
Normal file
1016
internal/svglide/anygen_semantics.go
Normal file
File diff suppressed because it is too large
Load Diff
34
internal/svglide/asset_path.go
Normal file
34
internal/svglide/asset_path.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validatePreparedImageAssetPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("image asset path must not be empty")
|
||||
}
|
||||
if strings.Contains(path, `\`) {
|
||||
return "", fmt.Errorf("image asset path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(path, "%") {
|
||||
return "", fmt.Errorf("image asset path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(path, ":") || strings.Contains(path, "//") || isAbsoluteRunPath(path) {
|
||||
return "", fmt.Errorf("image asset path %q must be a local assets/images/<file> path", raw)
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 3 || parts[0] != "assets" || parts[1] != "images" {
|
||||
return "", fmt.Errorf("image asset path %q must match assets/images/<file>", raw)
|
||||
}
|
||||
fileName := parts[2]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("image asset path %q must include a file name", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("image asset file name %q must not contain dot segments", fileName)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
46
internal/svglide/asset_path_test.go
Normal file
46
internal/svglide/asset_path_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePreparedImageAssetPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", path: "assets/images/hero.png", want: "assets/images/hero.png"},
|
||||
{name: "trim", path: " assets/images/hero.png ", want: "assets/images/hero.png"},
|
||||
{name: "empty", path: "", wantErr: true},
|
||||
{name: "remote", path: "https://example.com/hero.png", wantErr: true},
|
||||
{name: "parent directory", path: "../hero.png", wantErr: true},
|
||||
{name: "absolute", path: "/Users/example/hero.png", wantErr: true},
|
||||
{name: "file url", path: "file:///tmp/hero.png", wantErr: true},
|
||||
{name: "protocol relative", path: "//example.com/hero.png", wantErr: true},
|
||||
{name: "data url", path: "data:image/png;base64,AAAA", wantErr: true},
|
||||
{name: "percent", path: "assets/images/hero%2epng", wantErr: true},
|
||||
{name: "nested", path: "assets/images/nested/hero.png", wantErr: true},
|
||||
{name: "wrong directory", path: "assets/other/hero.png", wantErr: true},
|
||||
{name: "leading dot", path: "assets/images/.hero.png", wantErr: true},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png", wantErr: true},
|
||||
{name: "backslash", path: `assets\images\hero.png`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validatePreparedImageAssetPath(tt.path)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("path = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
169
internal/svglide/assets.go
Normal file
169
internal/svglide/assets.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
assetsPlanPath = "assets/assets_plan.json"
|
||||
assetsManifestPath = "assets/assets_manifest.json"
|
||||
assetInventoryPath = "assets/asset_inventory.json"
|
||||
)
|
||||
|
||||
type deckAssetsFile struct {
|
||||
Assets []deckAsset `json:"assets"`
|
||||
NoImageReason string `json:"no_image_reason"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type deckAsset struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
VisualID string `json:"visual_id"`
|
||||
Type string `json:"type"`
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
LocalPath string `json:"local_path"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Status string `json:"status"`
|
||||
Usage string `json:"usage"`
|
||||
MissingReason string `json:"missing_reason"`
|
||||
}
|
||||
|
||||
type assetInventoryFile struct {
|
||||
Items []assetInventoryItem `json:"items"`
|
||||
}
|
||||
|
||||
type assetInventoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
SemanticType string `json:"semantic_type"`
|
||||
LargeOK bool `json:"large_ok"`
|
||||
FullBleedOK bool `json:"full_bleed_ok"`
|
||||
RecommendedUse string `json:"recommended_use"`
|
||||
AvoidReason string `json:"avoid_reason"`
|
||||
Format string `json:"format"`
|
||||
HasAlpha bool `json:"has_alpha"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FitRole string `json:"fit_role"`
|
||||
CandidateID string `json:"candidate_id"`
|
||||
SelectionReason string `json:"selection_reason"`
|
||||
FormatExceptionReason string `json:"format_exception_reason"`
|
||||
}
|
||||
|
||||
func readDeckAssetsArtifact(safeRoot string, path string) (deckAssetsFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return deckAssetsFile{}, err
|
||||
}
|
||||
var file deckAssetsFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return deckAssetsFile{}, fmt.Errorf("read assets artifact %q: %w", path, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func readAssetInventory(safeRoot string) (assetInventoryFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, assetInventoryPath)
|
||||
if err != nil {
|
||||
return assetInventoryFile{}, fmt.Errorf("read asset inventory %q: %w", assetInventoryPath, err)
|
||||
}
|
||||
var inventory assetInventoryFile
|
||||
if err := json.Unmarshal(raw, &inventory); err != nil {
|
||||
return assetInventoryFile{}, fmt.Errorf("%s: invalid JSON: %w", assetInventoryPath, err)
|
||||
}
|
||||
return inventory, nil
|
||||
}
|
||||
|
||||
func readAssetsManifest(safeRoot string) (deckAssetsFile, error) {
|
||||
file, err := readDeckAssetsArtifact(safeRoot, assetsManifestPath)
|
||||
if err != nil {
|
||||
return deckAssetsFile{}, fmt.Errorf("read assets manifest %q: %w", assetsManifestPath, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func assetType(asset deckAsset) string {
|
||||
if value := strings.TrimSpace(asset.Kind); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(asset.Type)
|
||||
}
|
||||
|
||||
func assetPath(asset deckAsset) string {
|
||||
if value := strings.TrimSpace(asset.LocalPath); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(asset.Path)
|
||||
}
|
||||
|
||||
func assetStatus(asset deckAsset) string {
|
||||
return strings.TrimSpace(asset.Status)
|
||||
}
|
||||
|
||||
func assetSlideID(asset deckAsset) string {
|
||||
return strings.TrimSpace(asset.SlideID)
|
||||
}
|
||||
|
||||
func assetID(asset deckAsset) string {
|
||||
return strings.TrimSpace(asset.ID)
|
||||
}
|
||||
|
||||
func assetExt(asset deckAsset) string {
|
||||
raw := assetPath(asset)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(asset.SourceURL)
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" {
|
||||
raw = parsed.Path
|
||||
}
|
||||
if i := strings.IndexAny(raw, "?#"); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(raw))
|
||||
}
|
||||
|
||||
func isRasterImageAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" || assetType(asset) != "image" {
|
||||
return false
|
||||
}
|
||||
switch assetExt(asset) {
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".avif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isGeneratedSVGAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" {
|
||||
return false
|
||||
}
|
||||
if assetType(asset) == "generated_svg" {
|
||||
return true
|
||||
}
|
||||
return assetExt(asset) == ".svg" && assetType(asset) != "chart"
|
||||
}
|
||||
|
||||
func isChartSVGAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" {
|
||||
return false
|
||||
}
|
||||
return assetType(asset) == "chart" && assetExt(asset) == ".svg"
|
||||
}
|
||||
|
||||
func isPreviewWrapperImageAsset(asset deckAsset) bool {
|
||||
if assetStatus(asset) != "ready" || assetType(asset) != "image" {
|
||||
return false
|
||||
}
|
||||
normalized := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(assetPath(asset))), "./")
|
||||
return strings.HasPrefix(normalized, "slides/") && assetExt(asset) == ".svg"
|
||||
}
|
||||
655
internal/svglide/author.go
Normal file
655
internal/svglide/author.go
Normal file
@@ -0,0 +1,655 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSlideWidth = 960
|
||||
defaultSlideHeight = 540
|
||||
defaultAuthorBgColor = "#FFFFFF"
|
||||
defaultAuthorInkColor = "#111827"
|
||||
defaultAuthorMuteColor = "#6B7280"
|
||||
defaultAuthorAccent = "#2563EB"
|
||||
svgAuthorReceipt = "receipts/svg_author.json"
|
||||
)
|
||||
|
||||
type AuthorReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []string `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []authorDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
VisualRole string `json:"visual_role"`
|
||||
VisualIntent string `json:"visual_intent"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
LayoutFamily string `json:"layout_family"`
|
||||
LayoutArchetype string `json:"layout_archetype"`
|
||||
LayoutSignature string `json:"layout_signature"`
|
||||
StoryFunction string `json:"story_function"`
|
||||
PrimaryAssetRole string `json:"primary_asset_role"`
|
||||
FusionCandidate bool `json:"fusion_candidate"`
|
||||
}
|
||||
|
||||
type authorSlideContentFile struct {
|
||||
Slides []authorSlideContent `json:"slides"`
|
||||
}
|
||||
|
||||
type authorSlideContent struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Notes string `json:"notes"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
Visuals []authorSlideVisual `json:"visuals"`
|
||||
}
|
||||
|
||||
type authorSlideVisual struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
|
||||
type authorAsset = deckAsset
|
||||
|
||||
type authorVisualSystem struct {
|
||||
ColorSystem struct {
|
||||
Background string `json:"background"`
|
||||
Ink string `json:"ink"`
|
||||
Muted string `json:"muted"`
|
||||
Accent string `json:"accent"`
|
||||
} `json:"color_system"`
|
||||
Typography struct {
|
||||
Title int `json:"title"`
|
||||
Body int `json:"body"`
|
||||
} `json:"typography"`
|
||||
LayoutLanguage string `json:"layout_language"`
|
||||
}
|
||||
|
||||
type authorTheme struct {
|
||||
Background string
|
||||
Ink string
|
||||
Muted string
|
||||
Accent string
|
||||
TitleSize int
|
||||
BodySize int
|
||||
}
|
||||
|
||||
type authorSlideTarget struct {
|
||||
Slide authorDeckSlide
|
||||
Content authorSlideContent
|
||||
Assets []authorAsset
|
||||
Path string
|
||||
Target string
|
||||
Page int
|
||||
}
|
||||
|
||||
func AuthorSlides(root string) (AuthorReport, error) {
|
||||
return authorSlides(root, nil)
|
||||
}
|
||||
|
||||
func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
deck, err := readAuthorDeck(safeRoot, strings.TrimSpace(run.Artifacts.Deck))
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
contentByID, err := readAuthorContent(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
theme, err := readAuthorTheme(safeRoot, "brief/visual_system.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
assetsBySlideID, err := readAuthorAssets(safeRoot, assetsManifestPath)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := validateAuthorDeckContent(deck, contentByID); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
targets := make([]authorSlideTarget, 0, len(deck.Slides))
|
||||
report := AuthorReport{
|
||||
Status: StatusDone,
|
||||
Slides: make([]string, 0, len(deck.Slides)),
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if selectedPaths != nil && !selectedPaths[slidePath] {
|
||||
continue
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
targets = append(targets, authorSlideTarget{
|
||||
Slide: slide,
|
||||
Content: contentByID[strings.TrimSpace(slide.ID)],
|
||||
Assets: selectAuthorRenderableImageAssets(safeRoot, contentByID[strings.TrimSpace(slide.ID)], assetsBySlideID[strings.TrimSpace(slide.ID)]),
|
||||
Path: slidePath,
|
||||
Target: target,
|
||||
Page: i + 1,
|
||||
})
|
||||
report.Slides = append(report.Slides, slidePath)
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, svgAuthorReceipt)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, target.Assets, theme, target.Page, len(deck.Slides))
|
||||
if err := writeText(target.Target, svg); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
}
|
||||
if err := writeAuthorVisualReceipts(safeRoot, deck, contentByID, assetsBySlideID, selectedPaths); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := writeJSON(receiptTarget, StageReceipt{
|
||||
Stage: StageSVGAuthor,
|
||||
Status: StatusDone,
|
||||
Artifacts: report.Slides,
|
||||
}); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeAuthorVisualReceipts(safeRoot string, deck authorDeck, contentByID map[string]authorSlideContent, assetsBySlideID map[string][]authorAsset, selectedPaths map[string]bool) error {
|
||||
existingByID := map[string]visualReceipt{}
|
||||
if existing, err := readVisualReceipts(safeRoot); err == nil {
|
||||
for _, receipt := range existing.Slides {
|
||||
id := strings.TrimSpace(receipt.SlideID)
|
||||
if id != "" {
|
||||
existingByID[id] = receipt
|
||||
}
|
||||
}
|
||||
}
|
||||
out := visualReceiptsFile{Slides: make([]visualReceipt, 0, len(deck.Slides))}
|
||||
for i, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
receipt, ok := existingByID[id]
|
||||
slidePath, pathErr := previewSlideObjectPath(slide.Path)
|
||||
shouldRefresh := !ok || selectedPaths == nil || (pathErr == nil && selectedPaths[slidePath])
|
||||
if shouldRefresh {
|
||||
receipt = authorVisualReceiptForSlide(slide, contentByID[id], assetsBySlideID[id], i)
|
||||
}
|
||||
out.Slides = append(out.Slides, receipt)
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, visualReceiptsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, out)
|
||||
}
|
||||
|
||||
func authorVisualReceiptForSlide(slide authorDeckSlide, content authorSlideContent, assets []authorAsset, index int) visualReceipt {
|
||||
layoutFamily := firstNonEmpty(slide.LayoutFamily, inferAuthorLayoutFamily(slide, content, assets))
|
||||
layoutSignature := firstNonEmpty(slide.LayoutSignature, inferAuthorLayoutSignature(layoutFamily, assets, index))
|
||||
layoutArchetype := firstNonEmpty(slide.LayoutArchetype, inferAuthorLayoutArchetype(layoutFamily, layoutSignature))
|
||||
primaryAsset, assetRole := authorPrimaryAssetEvidence(slide, assets)
|
||||
pageDifference := "opening page"
|
||||
if index > 0 {
|
||||
pageDifference = "different content block and slide order from previous page"
|
||||
}
|
||||
dataRationale := ""
|
||||
chartReceipt := visualChartReceipt{Renderer: "none"}
|
||||
for _, visual := range content.Visuals {
|
||||
if strings.TrimSpace(visual.Type) == "chart" {
|
||||
dataRationale = firstNonEmpty(visual.Instruction, "chart visual requested by slide content")
|
||||
chartReceipt = visualChartReceipt{
|
||||
ChartID: strings.TrimSpace(visual.ID),
|
||||
Renderer: "none",
|
||||
WhyChartIsNeeded: dataRationale,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
fontRoles := map[string]string{
|
||||
"display": "Noto Serif CJK SC",
|
||||
"body": "Noto Sans CJK SC",
|
||||
"number": "Roboto Mono",
|
||||
"label": "PingFang SC",
|
||||
}
|
||||
return visualReceipt{
|
||||
SlideID: strings.TrimSpace(slide.ID),
|
||||
StoryJob: firstNonEmpty(slide.StoryFunction, slide.Role, "proof"),
|
||||
LayoutFamily: layoutFamily,
|
||||
LayoutArchetype: layoutArchetype,
|
||||
LayoutSignature: layoutSignature,
|
||||
ThumbnailJob: firstNonEmpty(slide.Title, slide.KeyMessage),
|
||||
VisualCenter: firstNonEmpty(primaryAsset, slide.Title, slide.KeyMessage),
|
||||
TopicFitClaim: firstNonEmpty(slide.KeyMessage, slide.Summary, content.Content),
|
||||
InformationDensityPlan: "one clear claim with supporting visual or concise text",
|
||||
PageDifferenceFromPrevious: pageDifference,
|
||||
PrimaryAsset: primaryAsset,
|
||||
AssetRole: assetRole,
|
||||
FontRoleUsage: fontRoles,
|
||||
TypographyRoleUsage: fontRoles,
|
||||
CompositionIntent: "local author fallback layout with topic-specific text and available assets",
|
||||
DataVisualRationale: dataRationale,
|
||||
SourceEvidence: append([]string{}, content.SourceRefs...),
|
||||
ContainerFitPlan: "use open grid by default; use cards only for explicit grouping or complex image backgrounds",
|
||||
ContainerDecision: "content decides carrier; no default card wrapper",
|
||||
TextCarrier: "open_grid",
|
||||
ShapeLanguage: "minimal",
|
||||
CardBudget: visualCardBudget{
|
||||
CardCount: 0,
|
||||
WhyCardsAreNeeded: "none: default text carrier is open layout",
|
||||
},
|
||||
ChartReceipt: chartReceipt,
|
||||
FusionSpec: visualFusionReceipt{Enabled: false},
|
||||
QAExpectations: []string{"no process text", "font roles present", "layout is readable"},
|
||||
}
|
||||
}
|
||||
|
||||
func inferAuthorLayoutFamily(slide authorDeckSlide, content authorSlideContent, assets []authorAsset) string {
|
||||
if len(assets) > 0 && isCoverSlide(slide) {
|
||||
return "character_product_focus"
|
||||
}
|
||||
for _, visual := range content.Visuals {
|
||||
switch strings.TrimSpace(visual.Type) {
|
||||
case "chart", "table":
|
||||
return "data_scoreboard"
|
||||
}
|
||||
}
|
||||
if len(assets) > 1 {
|
||||
return "evidence_board"
|
||||
}
|
||||
return "quiet_synthesis"
|
||||
}
|
||||
|
||||
func inferAuthorLayoutSignature(layoutFamily string, assets []authorAsset, index int) string {
|
||||
switch layoutFamily {
|
||||
case "character_product_focus":
|
||||
return "image_claim"
|
||||
case "data_scoreboard":
|
||||
return "data_panel"
|
||||
case "evidence_board":
|
||||
return "evidence_collage"
|
||||
default:
|
||||
if index%2 == 1 {
|
||||
return "text_evidence_panel"
|
||||
}
|
||||
return "single_claim_poster"
|
||||
}
|
||||
}
|
||||
|
||||
func inferAuthorLayoutArchetype(layoutFamily string, layoutSignature string) string {
|
||||
signature := strings.ToLower(strings.TrimSpace(layoutSignature))
|
||||
switch {
|
||||
case strings.Contains(signature, "waterfall") || strings.Contains(signature, "bridge"):
|
||||
return "waterfall_bridge"
|
||||
case strings.Contains(signature, "bubble") || strings.Contains(signature, "peer"):
|
||||
return "peer_bubble_field"
|
||||
case strings.Contains(signature, "ledger") || strings.Contains(signature, "statement"):
|
||||
return "statement_ledger"
|
||||
case strings.Contains(signature, "timeline") || strings.Contains(signature, "route"):
|
||||
return "timeline_path"
|
||||
case strings.Contains(signature, "risk") || strings.Contains(signature, "radar"):
|
||||
return "risk_radar"
|
||||
case strings.Contains(signature, "split") || strings.Contains(signature, "left_text_right_chart"):
|
||||
return "image_argument_split"
|
||||
case strings.Contains(signature, "evidence") || strings.Contains(signature, "collage"):
|
||||
return "evidence_collage"
|
||||
case strings.Contains(signature, "poster"):
|
||||
return "poster_stat_lockup"
|
||||
}
|
||||
switch strings.TrimSpace(layoutFamily) {
|
||||
case "full_bleed_hero":
|
||||
return "full_bleed_photo_title"
|
||||
case "data_scoreboard":
|
||||
return "data_scoreboard"
|
||||
case "evidence_board":
|
||||
return "evidence_collage"
|
||||
case "timeline_route":
|
||||
return "timeline_path"
|
||||
case "character_product_focus":
|
||||
return "annotated_image"
|
||||
case "image_text_fusion_split":
|
||||
return "image_argument_split"
|
||||
default:
|
||||
return "poster_stat_lockup"
|
||||
}
|
||||
}
|
||||
|
||||
func authorPrimaryAssetEvidence(slide authorDeckSlide, assets []authorAsset) (string, string) {
|
||||
for _, asset := range assets {
|
||||
if path := assetPath(asset); path != "" {
|
||||
return path, firstNonEmpty(slide.PrimaryAssetRole, asset.Usage, "topic anchor")
|
||||
}
|
||||
}
|
||||
return "", firstNonEmpty(slide.PrimaryAssetRole, "none")
|
||||
}
|
||||
|
||||
func readAuthorDeck(safeRoot string, deckPath string) (authorDeck, error) {
|
||||
if deckPath == "" {
|
||||
return authorDeck{}, fmt.Errorf("deck artifact path is empty")
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
var deck authorDeck
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return authorDeck{}, fmt.Errorf("deck %q contains no slides", deckPath)
|
||||
}
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
func readAuthorContent(safeRoot string, path string) (map[string]authorSlideContent, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
var file authorSlideContentFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
byID := make(map[string]authorSlideContent, len(file.Slides))
|
||||
for _, slide := range file.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("slide content id must not be empty")
|
||||
}
|
||||
if _, exists := byID[id]; exists {
|
||||
return nil, fmt.Errorf("slide content id %q is duplicated", id)
|
||||
}
|
||||
byID[id] = slide
|
||||
}
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
func readAuthorAssets(safeRoot string, path string) (map[string][]authorAsset, error) {
|
||||
file, err := readDeckAssetsArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read assets manifest %q: %w", path, err)
|
||||
}
|
||||
bySlideID := make(map[string][]authorAsset, len(file.Assets))
|
||||
for _, asset := range file.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
slideID := assetSlideID(asset)
|
||||
bySlideID[slideID] = append(bySlideID[slideID], asset)
|
||||
}
|
||||
return bySlideID, nil
|
||||
}
|
||||
|
||||
func selectAuthorRenderableImageAssets(safeRoot string, content authorSlideContent, assets []authorAsset) []authorAsset {
|
||||
if len(content.Visuals) == 0 || len(assets) == 0 {
|
||||
return nil
|
||||
}
|
||||
assetByID := make(map[string]authorAsset, len(assets))
|
||||
for _, asset := range assets {
|
||||
if assetType(asset) != "image" {
|
||||
continue
|
||||
}
|
||||
id := assetID(asset)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
assetByID[id] = asset
|
||||
}
|
||||
for _, visual := range content.Visuals {
|
||||
if strings.TrimSpace(visual.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(visual.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
asset, ok := assetByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !authorImageAssetUsable(safeRoot, asset) {
|
||||
continue
|
||||
}
|
||||
return []authorAsset{asset}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorImageAssetUsable(_ string, asset authorAsset) bool {
|
||||
if assetType(asset) != "image" {
|
||||
return false
|
||||
}
|
||||
path := assetPath(asset)
|
||||
return path != ""
|
||||
}
|
||||
|
||||
func validateAuthorDeckContent(deck authorDeck, contentByID map[string]authorSlideContent) error {
|
||||
deckIDs := make(map[string]bool, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("deck slide id must not be empty")
|
||||
}
|
||||
if deckIDs[id] {
|
||||
return fmt.Errorf("deck slide id %q is duplicated", id)
|
||||
}
|
||||
deckIDs[id] = true
|
||||
if _, ok := contentByID[id]; !ok {
|
||||
return fmt.Errorf("deck slide id %q is missing from slide content", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAuthorTheme(safeRoot string, path string) (authorTheme, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
var visual authorVisualSystem
|
||||
if err := json.Unmarshal(raw, &visual); err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
theme := authorTheme{
|
||||
Background: normalizeAuthorColor(visual.ColorSystem.Background, defaultAuthorBgColor),
|
||||
Ink: normalizeAuthorColor(visual.ColorSystem.Ink, defaultAuthorInkColor),
|
||||
Muted: normalizeAuthorColor(visual.ColorSystem.Muted, defaultAuthorMuteColor),
|
||||
Accent: normalizeAuthorColor(visual.ColorSystem.Accent, defaultAuthorAccent),
|
||||
TitleSize: visual.Typography.Title,
|
||||
BodySize: visual.Typography.Body,
|
||||
}
|
||||
if theme.TitleSize <= 0 {
|
||||
theme.TitleSize = 32
|
||||
}
|
||||
if theme.BodySize <= 0 {
|
||||
theme.BodySize = 16
|
||||
}
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
func normalizeAuthorColor(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if isAllowedAuthorHexColor(value) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isAllowedAuthorHexColor(value string) bool {
|
||||
if len(value) != 4 && len(value) != 7 && len(value) != 9 {
|
||||
return false
|
||||
}
|
||||
if value[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[1:] {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, assets []authorAsset, theme authorTheme, page int, total int) string {
|
||||
title := firstNonEmpty(slide.Title, "Untitled slide")
|
||||
keyMessage := firstNonEmpty(slide.KeyMessage, slide.Summary)
|
||||
bodyLines := authorBodyLines(content.Content)
|
||||
footer := strings.TrimSpace(deckTitle)
|
||||
if footer == "" {
|
||||
footer = "SVGlide"
|
||||
}
|
||||
footnote := authorSourceFootnote(content.SourceRefs)
|
||||
heroAsset := firstReadyAuthorImageAsset(assets)
|
||||
contentWidth := 848
|
||||
contentHeight := 404
|
||||
if heroAsset != nil {
|
||||
contentWidth = 500
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="%s" xmlns:slide="%s" width="%d" height="%d" viewBox="0 0 960 540" slide:role="slide">`+"\n", svgNamespace, slideNamespace, defaultSlideWidth, defaultSlideHeight)
|
||||
fmt.Fprintf(&b, " <style>:root{--font-display:\"Noto Serif CJK SC\",\"Songti SC\",serif;--font-body:\"Noto Sans CJK SC\",\"PingFang SC\",sans-serif;--font-number:\"Roboto Mono\",\"SFMono-Regular\",monospace;--font-label:\"PingFang SC\",\"Noto Sans CJK SC\",sans-serif;}</style>\n")
|
||||
if notes := strings.TrimSpace(content.Notes); notes != "" {
|
||||
fmt.Fprintf(&b, " <slide:note>%s</slide:note>\n", escapeText(notes))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="540" fill="%s" data-role="background"/>`+"\n", escapeAttr(theme.Background))
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="8" fill="%s"/>`+"\n", escapeAttr(theme.Accent))
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="48" width="%d" height="%d" slide:role="shape" slide:shape-type="text">`+"\n", contentWidth, contentHeight)
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-body);color:%s;">`+"\n", escapeAttr(theme.Ink))
|
||||
fmt.Fprintf(&b, ` <div style="font-family:var(--font-display);font-size:%dpx;font-weight:700;line-height:1.16;margin-bottom:16px;">%s</div>`+"\n", theme.TitleSize, escapeText(title))
|
||||
if keyMessage != "" {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.35;color:%s;margin-bottom:22px;">%s</div>`+"\n", maxInt(theme.BodySize+4, 18), escapeAttr(theme.Accent), escapeText(keyMessage))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <div style="border:1px solid #E5E7EB;border-radius:6px;padding:20px 24px;min-height:190px;background:#F9FAFB;">`+"\n")
|
||||
for _, line := range bodyLines {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.55;margin-bottom:8px;">- %s</div>`+"\n", theme.BodySize, escapeText(line))
|
||||
}
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
if footnote != "" {
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="456" width="520" height="18" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-label);color:%s;font-size:12px;line-height:1.2;">%s</div>`+"\n", escapeAttr(theme.Muted), escapeText(footnote))
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
}
|
||||
if heroAsset != nil {
|
||||
fmt.Fprintf(&b, ` <image slide:role="image" slide:shape-type="image" href="%s" x="600" y="160" width="304" height="190"/>`+"\n", escapeAttr(svgHrefForRunAsset(slide.Path, assetPath(*heroAsset))))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="482" width="848" height="32" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:var(--font-label);color:%s;font-size:12px;display:flex;justify-content:space-between;">`+"\n", escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(&b, ` <span>%s</span><span style="font-family:var(--font-number)">%d / %d</span>`+"\n", escapeText(footer), page, total)
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
fmt.Fprintf(&b, "</svg>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func svgHrefForRunAsset(slidePath string, runAssetPath string) string {
|
||||
runAssetPath = strings.TrimSpace(runAssetPath)
|
||||
if runAssetPath == "" ||
|
||||
strings.HasPrefix(runAssetPath, "data:") ||
|
||||
strings.HasPrefix(runAssetPath, "http://") ||
|
||||
strings.HasPrefix(runAssetPath, "https://") {
|
||||
return runAssetPath
|
||||
}
|
||||
cleanSlidePath, err := previewSlideObjectPath(slidePath)
|
||||
if err != nil {
|
||||
return runAssetPath
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.ToSlash(filepath.Dir(cleanSlidePath)), filepath.ToSlash(runAssetPath))
|
||||
if err != nil {
|
||||
return runAssetPath
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func authorBodyLines(content string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{"No content provided."}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func authorSourceFootnote(sourceRefs []string) string {
|
||||
if len(sourceRefs) == 0 {
|
||||
return ""
|
||||
}
|
||||
refs := make([]string, 0, len(sourceRefs))
|
||||
for _, ref := range sourceRefs {
|
||||
if trimmed := strings.TrimSpace(ref); trimmed != "" {
|
||||
refs = append(refs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "来源:" + strings.Join(refs, " / ")
|
||||
}
|
||||
|
||||
func firstReadyAuthorImageAsset(assets []authorAsset) *authorAsset {
|
||||
for i := range assets {
|
||||
asset := &assets[i]
|
||||
if assetType(*asset) != "image" {
|
||||
continue
|
||||
}
|
||||
if assetPath(*asset) == "" {
|
||||
continue
|
||||
}
|
||||
return asset
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeText(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func escapeAttr(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
634
internal/svglide/author_test.go
Normal file
634
internal/svglide/author_test.go
Normal file
@@ -0,0 +1,634 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
report, err := AuthorSlides("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != StatusDone {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, StatusDone)
|
||||
}
|
||||
if len(report.Slides) != 2 {
|
||||
t.Fatalf("Slides len = %d, want 2: %+v", len(report.Slides), report.Slides)
|
||||
}
|
||||
receipt := readAuthorReceiptForTest(t)
|
||||
if receipt["stage"] != StageSVGAuthor {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageSVGAuthor)
|
||||
}
|
||||
if receipt["status"] != StatusDone {
|
||||
t.Fatalf("receipt status = %v, want %q", receipt["status"], StatusDone)
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
|
||||
for _, rel := range []string{"slides/01.svg", "slides/02.svg"} {
|
||||
raw, err := os.ReadFile(filepath.Join("demo", rel))
|
||||
if err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`slide:role="slide"`,
|
||||
`viewBox="0 0 960 540"`,
|
||||
`foreignObject`,
|
||||
`slide:role="shape"`,
|
||||
`slide:shape-type="text"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("%s missing %q:\n%s", rel, want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesFallsBackForUnsafeColorTokens(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"url(https://example.com/bg.svg)","ink":"red;background:url(https://example.com/x)","muted":"not-a-color","accent":"#abc"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, banned := range []string{"url(", "https://example.com", "red;background", "not-a-color"} {
|
||||
if strings.Contains(svg, banned) {
|
||||
t.Fatalf("SVG contains unsafe color token %q:\n%s", banned, svg)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`fill="#FFFFFF"`, `color:#111827`, `color:#6B7280`, `fill="#abc"`, `color:#abc`} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("SVG missing normalized/default color %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesPreflightsSlidePathsBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/../02.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected invalid second slide path to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "01.svg")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("first slide output exists after preflight failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsMissingContentBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected missing slide content to fail")
|
||||
}
|
||||
for _, rel := range []string{"slides/01.svg", "receipts/svg_author.json"} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s exists after content preflight failure, stat err = %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s1","content":"Duplicate body line","source_refs":[],"visuals":[{"id":"none-s1b","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected duplicate slide content id to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("svg_author receipt exists after duplicate content id failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForNoneVisualDespiteReadyAsset(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("visual type none should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForMismatchedVisualID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"other","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("mismatched visual id should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExperimentRemoteImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the remote hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`href="https://example.com/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("experiment remote image missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesSkipsUnsupportedReadyImageAssets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
asset string
|
||||
}{
|
||||
{
|
||||
name: "diagram",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero diagram","status":"ready"}]}`,
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"missing"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", tt.asset)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("unsupported asset should not render image:\n%s", string(raw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExistingAbsoluteImageAssetInExperiment(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), outside) || !strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("absolute asset should render image in experiment mode:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initAuthorDemoRun(t *testing.T, visualSystem string, deck string) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", visualSystem)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", deck)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersSourceFootnotes(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`来源`,
|
||||
`web1`,
|
||||
`slide:role="shape"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("source footnote missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersPreparedImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`slide:shape-type="image"`,
|
||||
`href="../assets/images/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("prepared image asset missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesReadsAssetsManifestAndSerializesNotes(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the manifest hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<slide:note>Speaker note</slide:note>`,
|
||||
`<image slide:role="image"`,
|
||||
`href="../assets/images/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("SVG missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersImageFootnoteAndMultilineBodyWithValidation(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthorInputsWithAnyGenContracts(t *testing.T, assets string) {
|
||||
t.Helper()
|
||||
if strings.Contains(assets, `"assets":[]`) && !strings.Contains(assets, `"no_image_reason"`) {
|
||||
assets = strings.TrimSuffix(strings.TrimSpace(assets), "}") + `,"no_image_reason":"Text-only deck; no image assets required"}`
|
||||
}
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"First claim","body":"First body line\nSecond body line","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}},{"id":"s2","audience_copy":{"title":"Second claim","body":"Point A\nPoint B\nPoint C","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s2","content":"Point A\nPoint B\nPoint C","source_refs":["web1"],"visuals":[{"id":"none-s2","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", assets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", assets)
|
||||
}
|
||||
|
||||
func readAuthorReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "svg_author.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func mustWriteTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasSuffix(filepath.ToSlash(path), assetsPlanPath) {
|
||||
manifestPath := filepath.Join(filepath.Dir(path), filepath.Base(assetsManifestPath))
|
||||
if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidatesPath := filepath.Join(filepath.Dir(path), filepath.Base(imageCandidatesPath))
|
||||
if _, err := os.Stat(candidatesPath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(candidatesPath, []byte(testImageCandidatesJSON(content)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventoryPath := filepath.Join(filepath.Dir(path), filepath.Base(assetInventoryPath))
|
||||
if err := os.WriteFile(inventoryPath, []byte(testAssetInventoryJSON(content)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chartManifestPath := filepath.Join(filepath.Dir(path), "charts", "chart_manifest.json")
|
||||
if err := os.MkdirAll(filepath.Dir(chartManifestPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chartBriefsPath := filepath.Join(filepath.Dir(path), "charts", "chart_briefs.json")
|
||||
if err := os.WriteFile(chartBriefsPath, []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(chartManifestPath, []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chartRenderPath := filepath.Join(filepath.Dir(filepath.Dir(path)), chartRenderReceiptPath)
|
||||
if err := os.MkdirAll(filepath.Dir(chartRenderPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(chartRenderPath, []byte(`{"status":"passed","renderer":"node-vega-lite","charts":[],"issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAssetInventoryJSON(assets string) string {
|
||||
var file deckAssetsFile
|
||||
if err := json.Unmarshal([]byte(assets), &file); err != nil {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"items":[]}`
|
||||
}
|
||||
items := make([]map[string]any, 0, len(file.Assets))
|
||||
for _, asset := range file.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
format := strings.TrimPrefix(assetExt(asset), ".")
|
||||
if format == "" {
|
||||
format = "unknown"
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": assetID(asset),
|
||||
"path": assetPath(asset),
|
||||
"source_url": strings.TrimSpace(asset.SourceURL),
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"semantic_type": assetType(asset),
|
||||
"large_ok": true,
|
||||
"full_bleed_ok": true,
|
||||
"recommended_use": strings.TrimSpace(asset.Usage),
|
||||
"avoid_reason": "",
|
||||
"format": format,
|
||||
"has_alpha": assetExt(asset) == ".png",
|
||||
"asset_role": "hero_photo",
|
||||
"fit_role": "split_panel",
|
||||
"candidate_id": "cand-" + assetID(asset),
|
||||
"selection_reason": "test fixture selected image",
|
||||
})
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"prompt_contract": json.RawMessage(promptContractJSON(StageAssets)),
|
||||
"items": items,
|
||||
})
|
||||
if err != nil {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"items":[]}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func testImageCandidatesJSON(assets string) string {
|
||||
var file deckAssetsFile
|
||||
if err := json.Unmarshal([]byte(assets), &file); err != nil {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"requires_real_images":false,"no_image_reason":"invalid test asset fixture; no image candidates","candidates":[]}`
|
||||
}
|
||||
candidates := make([]map[string]any, 0, len(file.Assets))
|
||||
for _, asset := range file.Assets {
|
||||
if !isRasterImageAsset(asset) {
|
||||
continue
|
||||
}
|
||||
path := assetPath(asset)
|
||||
sourceURL := strings.TrimSpace(asset.SourceURL)
|
||||
if sourceURL == "" {
|
||||
sourceURL = strings.TrimSpace(asset.Path)
|
||||
}
|
||||
format := strings.TrimPrefix(assetExt(asset), ".")
|
||||
if format == "" {
|
||||
format = "unknown"
|
||||
}
|
||||
candidates = append(candidates, map[string]any{
|
||||
"id": "cand-" + assetID(asset),
|
||||
"query": strings.TrimSpace(asset.Usage),
|
||||
"source_url": sourceURL,
|
||||
"source_class": "user_provided",
|
||||
"format": format,
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"has_alpha": assetExt(asset) == ".png",
|
||||
"asset_role": "hero_photo",
|
||||
"fit_role": "split_panel",
|
||||
"local_path": path,
|
||||
"score_bp": 9000,
|
||||
"selected": true,
|
||||
"selection_reason": "test fixture selected image",
|
||||
"format_exception_reason": "",
|
||||
"rejection_reason": "",
|
||||
})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"prompt_contract": json.RawMessage(promptContractJSON(StageAssets)),
|
||||
"requires_real_images": len(candidates) > 0,
|
||||
"candidates": candidates,
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
payload["no_image_reason"] = "test fixture has no real raster image assets"
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageAssets) + `,"requires_real_images":false,"no_image_reason":"test fixture has no real raster image assets","candidates":[]}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
141
internal/svglide/chart_brief.go
Normal file
141
internal/svglide/chart_brief.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartBriefsPath = "assets/charts/chart_briefs.json"
|
||||
|
||||
type chartBriefFile struct {
|
||||
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
|
||||
Charts []chartBriefEntry `json:"charts"`
|
||||
}
|
||||
|
||||
type chartBriefEntry struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
Takeaway string `json:"takeaway"`
|
||||
Renderer string `json:"renderer"`
|
||||
SourceIDs []string `json:"data_source_ids"`
|
||||
Unit string `json:"unit"`
|
||||
MinWidth int `json:"min_width,omitempty"`
|
||||
MinHeight int `json:"min_height,omitempty"`
|
||||
FallbackPolicy string `json:"fallback_policy,omitempty"`
|
||||
}
|
||||
|
||||
func readChartBriefs(safeRoot string) (chartBriefFile, bool, error) {
|
||||
exists, err := runRegularFileExists(safeRoot, chartBriefsPath)
|
||||
if err != nil {
|
||||
return chartBriefFile{}, false, err
|
||||
}
|
||||
if !exists {
|
||||
return chartBriefFile{}, false, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, chartBriefsPath)
|
||||
if err != nil {
|
||||
return chartBriefFile{}, true, err
|
||||
}
|
||||
var file chartBriefFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return chartBriefFile{}, true, fmt.Errorf("%s: invalid JSON: %w", chartBriefsPath, err)
|
||||
}
|
||||
return file, true, nil
|
||||
}
|
||||
|
||||
func ensureEmptyChartBriefsForNoChartDeck(safeRoot string) error {
|
||||
if exists, err := runRegularFileExists(safeRoot, chartBriefsPath); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return nil
|
||||
}
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if slideContentHasChartVisual(content) {
|
||||
return nil
|
||||
}
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contract, err := RequiredPromptContractForStage(StageAssets, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawContract, err := json.Marshal(contract)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartBriefsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, chartBriefFile{
|
||||
PromptContract: rawContract,
|
||||
Charts: []chartBriefEntry{},
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateChartBriefsGate(safeRoot string) error {
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
briefs, present, err := readChartBriefs(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasChartVisual := slideContentHasChartVisual(content)
|
||||
if !present {
|
||||
if hasChartVisual {
|
||||
return fmt.Errorf("chart_briefs_gate: chart visual exists but %s is missing", chartBriefsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hasChartVisual && len(briefs.Charts) == 0 {
|
||||
return fmt.Errorf("chart_briefs_gate: chart visual exists but %s has no chart briefs", chartBriefsPath)
|
||||
}
|
||||
for _, entry := range briefs.Charts {
|
||||
id := strings.TrimSpace(entry.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief id must not be empty")
|
||||
}
|
||||
if renderer := strings.TrimSpace(entry.Renderer); renderer != requiredChartRendererVegaLite {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q renderer = %q, want %q", id, renderer, requiredChartRendererVegaLite)
|
||||
}
|
||||
if strings.TrimSpace(entry.SlideID) == "" {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q slide_id must not be empty", id)
|
||||
}
|
||||
if strings.TrimSpace(entry.Takeaway) == "" {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q takeaway must not be empty", id)
|
||||
}
|
||||
if len(entry.SourceIDs) == 0 {
|
||||
return fmt.Errorf("chart_briefs_gate: chart brief %q data_source_ids must not be empty", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slideContentHasChartVisual(content qualityContentFile) bool {
|
||||
for _, slide := range content.Slides {
|
||||
for _, visual := range slide.Visuals {
|
||||
if strings.TrimSpace(visual.Type) == "chart" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartSpecPathForBrief(id string) string {
|
||||
return filepath.ToSlash(filepath.Join("assets", "charts", "specs", strings.TrimSpace(id)+".vl.json"))
|
||||
}
|
||||
|
||||
func chartSVGPathForBrief(id string) string {
|
||||
return filepath.ToSlash(filepath.Join("assets", "charts", strings.TrimSpace(id)+".svg"))
|
||||
}
|
||||
50
internal/svglide/chart_brief_test.go
Normal file
50
internal/svglide/chart_brief_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureEmptyChartBriefsForNoChartDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"none","type":"none","instruction":"Text only"}]}]}`)
|
||||
|
||||
if err := ensureEmptyChartBriefsForNoChartDeck("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", chartBriefsPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"charts": []`) {
|
||||
t.Fatalf("chart_briefs = %s, want empty charts array", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureEmptyChartBriefsDoesNotHideMissingChartBriefForChartDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Revenue","source_refs":["web1"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
|
||||
if err := ensureEmptyChartBriefsForNoChartDeck("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", chartBriefsPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("chart_briefs should not be auto-created for chart deck, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBriefRejectsNativeSVGRenderer(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Revenue","source_refs":["web1"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_briefs.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[{"id":"revenue","slide_id":"s1","purpose":"comparison","takeaway":"Revenue increased","renderer":"native-svg","data_source_ids":["web1"],"unit":"$"}]}`)
|
||||
|
||||
err := ValidateChartBriefsGate("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected native-svg chart brief renderer to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "renderer") || !strings.Contains(err.Error(), "vega-lite") {
|
||||
t.Fatalf("error = %v, want renderer vega-lite rejection", err)
|
||||
}
|
||||
}
|
||||
74
internal/svglide/chart_manifest.go
Normal file
74
internal/svglide/chart_manifest.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartManifestPath = "assets/charts/chart_manifest.json"
|
||||
|
||||
type chartManifestFile struct {
|
||||
Renderer string `json:"renderer"`
|
||||
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
|
||||
Charts []chartManifestEntry `json:"charts"`
|
||||
}
|
||||
|
||||
type chartManifestEntry struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Renderer string `json:"renderer"`
|
||||
BriefID string `json:"brief_id,omitempty"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
SourceID string `json:"source_id"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Takeaway string `json:"takeaway,omitempty"`
|
||||
RenderReceipt string `json:"render_receipt,omitempty"`
|
||||
}
|
||||
|
||||
func readChartManifest(safeRoot string) (chartManifestFile, bool, error) {
|
||||
exists, err := runRegularFileExists(safeRoot, chartManifestPath)
|
||||
if err != nil {
|
||||
return chartManifestFile{}, false, err
|
||||
}
|
||||
if !exists {
|
||||
return chartManifestFile{}, false, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, chartManifestPath)
|
||||
if err != nil {
|
||||
return chartManifestFile{}, false, err
|
||||
}
|
||||
var file chartManifestFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return chartManifestFile{}, true, fmt.Errorf("%s: invalid JSON: %w", chartManifestPath, err)
|
||||
}
|
||||
return file, true, nil
|
||||
}
|
||||
|
||||
func chartEntryRenderer(file chartManifestFile, entry chartManifestEntry) string {
|
||||
if value := strings.TrimSpace(entry.Renderer); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(file.Renderer)
|
||||
}
|
||||
|
||||
func countVegaLiteSpecEntries(file chartManifestFile) int {
|
||||
count := 0
|
||||
for _, entry := range file.Charts {
|
||||
if chartEntryRenderer(file, entry) == requiredChartRendererVegaLite && strings.TrimSpace(entry.SpecPath) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countChartSVGEntries(file chartManifestFile) int {
|
||||
count := 0
|
||||
for _, entry := range file.Charts {
|
||||
if strings.TrimSpace(entry.SVGPath) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
343
internal/svglide/chart_quality.go
Normal file
343
internal/svglide/chart_quality.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartQualityReportPath = "receipts/chart_quality.json"
|
||||
|
||||
type ChartQualityReport struct {
|
||||
Status string `json:"status"`
|
||||
Metrics ChartQualityMetrics `json:"metrics"`
|
||||
Issues []ChartQualityIssue `json:"issues"`
|
||||
Charts []ChartQualityChart `json:"charts"`
|
||||
}
|
||||
|
||||
type ChartQualityMetrics struct {
|
||||
Charts int `json:"charts"`
|
||||
VegaLiteCharts int `json:"vega_lite_charts"`
|
||||
MissingAxisCount int `json:"missing_axis_count"`
|
||||
MissingUnitCount int `json:"missing_unit_count"`
|
||||
MissingSourceCount int `json:"missing_source_count"`
|
||||
MissingDirectLabelCount int `json:"missing_direct_label_count"`
|
||||
DecorativeChartCount int `json:"decorative_chart_count"`
|
||||
}
|
||||
|
||||
type ChartQualityIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type ChartQualityChart struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Renderer string `json:"renderer"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
SpecPath string `json:"spec_path,omitempty"`
|
||||
}
|
||||
|
||||
func CheckChartQuality(root string) (ChartQualityReport, error) {
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return ChartQualityReport{}, err
|
||||
}
|
||||
manifest, present, err := readChartManifest(safeRoot)
|
||||
if err != nil {
|
||||
return ChartQualityReport{}, err
|
||||
}
|
||||
report := ChartQualityReport{
|
||||
Status: "passed",
|
||||
Issues: []ChartQualityIssue{},
|
||||
Charts: []ChartQualityChart{},
|
||||
}
|
||||
if !present {
|
||||
if err := writeChartQualityReport(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
sourceIDs, sourceErr := readKnownSourceIDs(safeRoot)
|
||||
if sourceErr != nil {
|
||||
addChartQualityIssue(&report, "research/sources.json", "svglide.chart_quality.sources_unreadable", sourceErr.Error())
|
||||
sourceIDs = map[string]bool{}
|
||||
}
|
||||
renderReport, renderErr := readChartRenderReport(safeRoot)
|
||||
if renderErr != nil {
|
||||
addChartQualityIssue(&report, chartRenderReceiptPath, "svglide.chart_quality.missing_render_receipt", renderErr.Error())
|
||||
}
|
||||
renderByID := chartRenderEntriesByID(renderReport)
|
||||
for _, chart := range manifest.Charts {
|
||||
renderer := normalizedRequiredChartRenderer(chartEntryRenderer(manifest, chart))
|
||||
svgPath := strings.TrimSpace(chart.SVGPath)
|
||||
item := ChartQualityChart{
|
||||
ID: strings.TrimSpace(chart.ID),
|
||||
SlideID: strings.TrimSpace(chart.SlideID),
|
||||
Renderer: renderer,
|
||||
SVGPath: svgPath,
|
||||
SpecPath: strings.TrimSpace(chart.SpecPath),
|
||||
}
|
||||
report.Charts = append(report.Charts, item)
|
||||
report.Metrics.Charts++
|
||||
if renderer == requiredChartRendererVegaLite {
|
||||
report.Metrics.VegaLiteCharts++
|
||||
validateVegaLiteChartQuality(&report, safeRoot, chart, sourceIDs, renderByID, renderErr == nil)
|
||||
}
|
||||
if svgPath == "" {
|
||||
addChartQualityIssue(&report, chartManifestPath, "svglide.chart_quality.missing_svg", fmt.Sprintf("chart %q has no svg_path", chart.ID))
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, svgPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(&report, svgPath, "svglide.chart_quality.missing_svg", fmt.Sprintf("chart %q SVG cannot be read: %v", chart.ID, err))
|
||||
continue
|
||||
}
|
||||
checkChartSVGQuality(&report, svgPath, string(raw))
|
||||
}
|
||||
if len(report.Issues) > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
if err := writeChartQualityReport(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func validateVegaLiteChartQuality(report *ChartQualityReport, safeRoot string, chart chartManifestEntry, sourceIDs map[string]bool, renderByID map[string]ChartRenderEntry, hasRenderReceipt bool) {
|
||||
id := strings.TrimSpace(chart.ID)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(chart.SVGPath)
|
||||
}
|
||||
for _, required := range []struct {
|
||||
value string
|
||||
code string
|
||||
name string
|
||||
}{
|
||||
{strings.TrimSpace(chart.BriefID), "svglide.chart_quality.missing_brief_id", "brief_id"},
|
||||
{strings.TrimSpace(chart.SpecPath), "svglide.chart_quality.missing_spec_path", "spec_path"},
|
||||
{strings.TrimSpace(chart.SVGPath), "svglide.chart_quality.missing_svg", "svg_path"},
|
||||
{strings.TrimSpace(chart.SourceID), "svglide.chart_quality.missing_source", "source_id"},
|
||||
{strings.TrimSpace(chart.Unit), "svglide.chart_quality.missing_unit", "unit"},
|
||||
{strings.TrimSpace(chart.Takeaway), "svglide.chart_quality.missing_takeaway", "takeaway"},
|
||||
{strings.TrimSpace(chart.RenderReceipt), "svglide.chart_quality.missing_render_receipt", "render_receipt"},
|
||||
} {
|
||||
if required.value == "" {
|
||||
addChartQualityIssue(report, chartManifestPath, required.code, fmt.Sprintf("chart %q is missing %s", id, required.name))
|
||||
}
|
||||
}
|
||||
if chart.RenderReceipt != "" && chart.RenderReceipt != chartRenderReceiptPath {
|
||||
addChartQualityIssue(report, chartManifestPath, "svglide.chart_quality.missing_render_receipt", fmt.Sprintf("chart %q render_receipt = %q, want %q", id, chart.RenderReceipt, chartRenderReceiptPath))
|
||||
}
|
||||
if sourceID := strings.TrimSpace(chart.SourceID); sourceID != "" && !sourceIDs[sourceID] {
|
||||
addChartQualityIssue(report, chartManifestPath, "svglide.chart_quality.unknown_source_id", fmt.Sprintf("chart %q references unknown source_id %q", id, sourceID))
|
||||
}
|
||||
validateVegaLiteSpec(report, safeRoot, chart)
|
||||
if !hasRenderReceipt {
|
||||
return
|
||||
}
|
||||
renderEntry, ok := renderByID[id]
|
||||
if !ok {
|
||||
addChartQualityIssue(report, chartRenderReceiptPath, "svglide.chart_quality.render_receipt_missing_chart", fmt.Sprintf("render receipt has no entry for chart %q", id))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(chart.SpecPath) != "" {
|
||||
raw, err := readRunRegularArtifact(safeRoot, chart.SpecPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(report, chart.SpecPath, "svglide.chart_quality.missing_spec_path", err.Error())
|
||||
} else if got := sha256Hex(raw); got != renderEntry.SpecSHA256 {
|
||||
addChartQualityIssue(report, chart.SpecPath, "svglide.chart_quality.spec_hash_mismatch", fmt.Sprintf("chart %q spec hash %s, want %s", id, got, renderEntry.SpecSHA256))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(chart.SVGPath) != "" {
|
||||
raw, err := readRunRegularArtifact(safeRoot, chart.SVGPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(report, chart.SVGPath, "svglide.chart_quality.missing_svg", err.Error())
|
||||
} else if got := sha256Hex(raw); got != renderEntry.SVGSHA256 {
|
||||
addChartQualityIssue(report, chart.SVGPath, "svglide.chart_quality.svg_hash_mismatch", fmt.Sprintf("chart %q SVG hash %s, want %s", id, got, renderEntry.SVGSHA256))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateVegaLiteSpec(report *ChartQualityReport, safeRoot string, chart chartManifestEntry) {
|
||||
specPath := strings.TrimSpace(chart.SpecPath)
|
||||
if specPath == "" {
|
||||
return
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, specPath)
|
||||
if err != nil {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.missing_spec_path", err.Error())
|
||||
return
|
||||
}
|
||||
var spec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.invalid_spec_json", err.Error())
|
||||
return
|
||||
}
|
||||
if len(spec["$schema"]) == 0 {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_schema", "Vega-Lite spec must include $schema")
|
||||
}
|
||||
if len(spec["mark"]) == 0 {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_mark", "Vega-Lite spec must include mark")
|
||||
}
|
||||
if len(spec["encoding"]) == 0 {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_encoding", "Vega-Lite spec must include encoding")
|
||||
}
|
||||
if !vegaLiteSpecHasData(spec) {
|
||||
addChartQualityIssue(report, specPath, "svglide.chart_quality.spec_missing_data", "Vega-Lite spec must include data.values or a local data reference")
|
||||
}
|
||||
}
|
||||
|
||||
func vegaLiteSpecHasData(spec map[string]json.RawMessage) bool {
|
||||
raw := spec["data"]
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var data map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return false
|
||||
}
|
||||
if len(data["values"]) > 0 {
|
||||
return true
|
||||
}
|
||||
var urlValue string
|
||||
if err := json.Unmarshal(data["url"], &urlValue); err == nil && strings.HasPrefix(urlValue, "assets/charts/data/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readChartRenderReport(safeRoot string) (ChartRenderReport, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, chartRenderReceiptPath)
|
||||
if err != nil {
|
||||
return ChartRenderReport{}, err
|
||||
}
|
||||
var report ChartRenderReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return ChartRenderReport{}, fmt.Errorf("%s: invalid JSON: %w", chartRenderReceiptPath, err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func chartRenderEntriesByID(report ChartRenderReport) map[string]ChartRenderEntry {
|
||||
out := map[string]ChartRenderEntry{}
|
||||
for _, entry := range report.Charts {
|
||||
if id := strings.TrimSpace(entry.ID); id != "" {
|
||||
out[id] = entry
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func checkChartSVGQuality(report *ChartQualityReport, path, svg string) {
|
||||
visible := strings.ToLower(visibleSemanticText(svg))
|
||||
raw := strings.ToLower(svg)
|
||||
if !chartHasUnit(visible) {
|
||||
report.Metrics.MissingUnitCount++
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.missing_unit", "chart must include a visible unit such as $, %, bps, billion, million, points, goals, or score")
|
||||
}
|
||||
if !chartHasSource(visible) {
|
||||
report.Metrics.MissingSourceCount++
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.missing_source", "chart must include a visible source note or source label")
|
||||
}
|
||||
hasAxis := chartHasAxis(raw, visible)
|
||||
hasDirectLabel := chartHasDirectLabel(raw, visible)
|
||||
if !hasAxis {
|
||||
report.Metrics.MissingAxisCount++
|
||||
}
|
||||
if !hasDirectLabel {
|
||||
report.Metrics.MissingDirectLabelCount++
|
||||
}
|
||||
if !hasAxis || !hasDirectLabel {
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.missing_labeling", "chart must include readable axes or direct labels")
|
||||
}
|
||||
if chartLooksDecorative(raw, visible) {
|
||||
report.Metrics.DecorativeChartCount++
|
||||
addChartQualityIssue(report, path, "svglide.chart_quality.decorative_chart", "chart looks decorative: it lacks enough labels, axes, units, or source context")
|
||||
}
|
||||
}
|
||||
|
||||
func writeChartQualityReport(safeRoot string, report ChartQualityReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartQualityReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func addChartQualityIssue(report *ChartQualityReport, path, code, message string) {
|
||||
report.Issues = append(report.Issues, ChartQualityIssue{
|
||||
Path: filepath.ToSlash(path),
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
})
|
||||
}
|
||||
|
||||
func chartHasUnit(visible string) bool {
|
||||
for _, token := range []string{"$", "%", "bps", "bp", "points", "point", "score", "goals", "goal", "usd", "rmb", "billion", "million", "bn", "分", "美元", "亿元", "亿", "倍"} {
|
||||
if strings.Contains(visible, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartHasSource(visible string) bool {
|
||||
normalized := strings.NewReplacer(":", ":", "﹕", ":", ":", ":", "\n", " ").Replace(visible)
|
||||
for _, token := range []string{
|
||||
"source:",
|
||||
"sources:",
|
||||
"data source:",
|
||||
"source note:",
|
||||
"来源:",
|
||||
"数据源:",
|
||||
"资料来源:",
|
||||
"数据来源:",
|
||||
"sec 10-k",
|
||||
"sec 10-q",
|
||||
"company filings",
|
||||
"company filing",
|
||||
"annual report",
|
||||
"quarterly report",
|
||||
"official statistics",
|
||||
"official data",
|
||||
"fifa official",
|
||||
"olympics official",
|
||||
"年报",
|
||||
"财报",
|
||||
} {
|
||||
if strings.Contains(normalized, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartHasAxis(raw, visible string) bool {
|
||||
if strings.Contains(raw, "role=\"axis\"") || strings.Contains(raw, "aria-label=\"axis") || strings.Contains(raw, "class=\"axis") {
|
||||
return true
|
||||
}
|
||||
for _, token := range []string{"x-axis", "y-axis", "axis", "year", "quarter", "fy", "q1", "q2", "q3", "q4", "年度", "季度"} {
|
||||
if strings.Contains(raw, token) || strings.Contains(visible, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func chartHasDirectLabel(raw, visible string) bool {
|
||||
if strings.Contains(raw, "direct-label") || strings.Contains(raw, "data-label") || strings.Contains(raw, "mark-text") {
|
||||
return true
|
||||
}
|
||||
return strings.Count(raw, "<text") >= 2 && containsDigit(visible)
|
||||
}
|
||||
|
||||
func chartLooksDecorative(raw, visible string) bool {
|
||||
barCount := strings.Count(raw, "<rect") + strings.Count(raw, "<path")
|
||||
textCount := strings.Count(raw, "<text")
|
||||
return barCount >= 2 && (textCount == 0 || !containsDigit(visible) || !chartHasUnit(visible) || !chartHasSource(visible))
|
||||
}
|
||||
113
internal/svglide/chart_quality_test.go
Normal file
113
internal/svglide/chart_quality_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChartQualityRequiresUnitsSourcesAndLabels(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
copyChartQualityTestData(t, "weak_financial_chart", "demo")
|
||||
|
||||
report, err := CheckChartQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if report.Metrics.Charts != 1 || report.Metrics.VegaLiteCharts != 1 {
|
||||
t.Fatalf("metrics = %+v, want one Vega-Lite chart", report.Metrics)
|
||||
}
|
||||
if report.Metrics.MissingUnitCount != 1 {
|
||||
t.Fatalf("missing unit count = %d, want 1", report.Metrics.MissingUnitCount)
|
||||
}
|
||||
if report.Metrics.MissingSourceCount != 1 {
|
||||
t.Fatalf("missing source count = %d, want 1", report.Metrics.MissingSourceCount)
|
||||
}
|
||||
if report.Metrics.MissingAxisCount != 1 || report.Metrics.MissingDirectLabelCount != 1 {
|
||||
t.Fatalf("label metrics = %+v, want missing axis and direct label", report.Metrics)
|
||||
}
|
||||
if report.Metrics.DecorativeChartCount != 1 {
|
||||
t.Fatalf("decorative chart count = %d, want 1", report.Metrics.DecorativeChartCount)
|
||||
}
|
||||
for _, code := range []string{
|
||||
"svglide.chart_quality.missing_unit",
|
||||
"svglide.chart_quality.missing_source",
|
||||
"svglide.chart_quality.missing_labeling",
|
||||
"svglide.chart_quality.decorative_chart",
|
||||
} {
|
||||
if !chartQualityIssueCodesContain(report.Issues, code) {
|
||||
t.Fatalf("issues = %+v, want %s", report.Issues, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartQualityDoesNotTreatCompanyComparisonAsSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"peer","slide_id":"s1","renderer":"vega-lite","spec_path":"assets/charts/specs/peer.vl.json","svg_path":"assets/charts/peer.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/peer.svg", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 240"><g role="axis"><text>FY2024</text></g><text>Company comparison</text><text>$22.1B</text><rect width="120" height="160"/></svg>`)
|
||||
|
||||
report, err := CheckChartQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for missing source", report.Status)
|
||||
}
|
||||
if report.Metrics.MissingSourceCount != 1 {
|
||||
t.Fatalf("missing source count = %d, want 1", report.Metrics.MissingSourceCount)
|
||||
}
|
||||
if !chartQualityIssueCodesContain(report.Issues, "svglide.chart_quality.missing_source") {
|
||||
t.Fatalf("issues = %+v, want missing source", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func copyChartQualityTestData(t *testing.T, name string, root string) {
|
||||
t.Helper()
|
||||
srcRoot := chartQualityTestDataRoot(t, name)
|
||||
err := filepath.WalkDir(srcRoot, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(srcRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, raw, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chartQualityTestDataRoot(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "testdata", "chart_quality", name)
|
||||
}
|
||||
|
||||
func chartQualityIssueCodesContain(issues []ChartQualityIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
211
internal/svglide/chart_render.go
Normal file
211
internal/svglide/chart_render.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartRenderReceiptPath = "receipts/chart_render.json"
|
||||
|
||||
type ChartRenderReport struct {
|
||||
Status string `json:"status"`
|
||||
Renderer string `json:"renderer"`
|
||||
Charts []ChartRenderEntry `json:"charts"`
|
||||
Issues []ChartRenderIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ChartRenderEntry struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
SpecSHA256 string `json:"spec_sha256"`
|
||||
SVGSHA256 string `json:"svg_sha256"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type ChartRenderIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func RenderVegaLiteCharts(root string) (ChartRenderReport, error) {
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return ChartRenderReport{}, err
|
||||
}
|
||||
report := ChartRenderReport{
|
||||
Status: "passed",
|
||||
Renderer: "node-vega-lite",
|
||||
Charts: []ChartRenderEntry{},
|
||||
Issues: []ChartRenderIssue{},
|
||||
}
|
||||
manifest, present, err := readChartManifest(safeRoot)
|
||||
if err != nil {
|
||||
return ChartRenderReport{}, err
|
||||
}
|
||||
if !present || len(manifest.Charts) == 0 {
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
nodePath, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_node",
|
||||
Message: "node executable is not available in PATH",
|
||||
})
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
rendererScript, err := findNodeChartRendererScript()
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_node_dependencies",
|
||||
Path: "internal/svglide/chart_renderer",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
if err := validateNodeChartRendererDependencies(rendererScript); err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_node_dependencies",
|
||||
Path: "internal/svglide/chart_renderer",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
for _, chart := range manifest.Charts {
|
||||
if chartEntryRenderer(manifest, chart) != requiredChartRendererVegaLite {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.unsupported_renderer",
|
||||
Path: chartManifestPath,
|
||||
Message: fmt.Sprintf("chart %q renderer must be vega-lite for local SVG deck", chart.ID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
specPath := strings.TrimSpace(chart.SpecPath)
|
||||
svgPath := strings.TrimSpace(chart.SVGPath)
|
||||
if specPath == "" || svgPath == "" {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.missing_path",
|
||||
Path: chartManifestPath,
|
||||
Message: fmt.Sprintf("chart %q must include spec_path and svg_path", chart.ID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
specAbs, err := safeRunPath(safeRoot, specPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.invalid_spec_path", Path: specPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
svgAbs, err := safeRunPath(safeRoot, svgPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.invalid_svg_path", Path: svgPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(svgAbs), 0o755); err != nil {
|
||||
return report, err
|
||||
}
|
||||
cmd := exec.Command(nodePath, rendererScript, "--input", specAbs, "--output", svgAbs)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{
|
||||
Code: "svglide.chart_render.node_renderer_failed",
|
||||
Path: specPath,
|
||||
Message: strings.TrimSpace(string(output)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
specRaw, err := readRunRegularArtifact(safeRoot, specPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.read_spec", Path: specPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
svgRaw, err := readRunRegularArtifact(safeRoot, svgPath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartRenderIssue{Code: "svglide.chart_render.read_svg", Path: svgPath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
report.Charts = append(report.Charts, ChartRenderEntry{
|
||||
ID: strings.TrimSpace(chart.ID),
|
||||
SlideID: strings.TrimSpace(chart.SlideID),
|
||||
SpecPath: specPath,
|
||||
SVGPath: svgPath,
|
||||
SpecSHA256: sha256Hex(specRaw),
|
||||
SVGSHA256: sha256Hex(svgRaw),
|
||||
Command: "node internal/svglide/chart_renderer/render-vegalite.mjs --input " + specPath + " --output " + svgPath,
|
||||
})
|
||||
}
|
||||
return report, writeChartRenderReport(safeRoot, report)
|
||||
}
|
||||
|
||||
func writeChartRenderReport(safeRoot string, report ChartRenderReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartRenderReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func sha256Hex(raw []byte) string {
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func findNodeChartRendererScript() (string, error) {
|
||||
if _, file, _, ok := runtime.Caller(0); ok {
|
||||
candidate := filepath.Join(filepath.Dir(file), "chart_renderer", "render-vegalite.mjs")
|
||||
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for dir := cwd; ; dir = filepath.Dir(dir) {
|
||||
for _, rel := range []string{
|
||||
filepath.Join("internal", "svglide", "chart_renderer", "render-vegalite.mjs"),
|
||||
filepath.Join("chart_renderer", "render-vegalite.mjs"),
|
||||
} {
|
||||
candidate := filepath.Join(dir, rel)
|
||||
if info, statErr := os.Stat(candidate); statErr == nil && info.Mode().IsRegular() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot locate internal/svglide/chart_renderer/render-vegalite.mjs from %s", cwd)
|
||||
}
|
||||
|
||||
func validateNodeChartRendererDependencies(scriptPath string) error {
|
||||
root := filepath.Dir(scriptPath)
|
||||
for _, rel := range []string{
|
||||
filepath.Join("node_modules", "vega", "package.json"),
|
||||
filepath.Join("node_modules", "vega-lite", "package.json"),
|
||||
} {
|
||||
path := filepath.Join(root, rel)
|
||||
if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("missing %s; run npm --prefix internal/svglide/chart_renderer install", filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
66
internal/svglide/chart_render_test.go
Normal file
66
internal/svglide/chart_render_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChartRenderRendersVegaLiteSpecWithNodeRenderer(t *testing.T) {
|
||||
if script, err := findNodeChartRendererScript(); err != nil {
|
||||
t.Skip(err)
|
||||
} else if err := validateNodeChartRendererDependencies(script); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"vega-lite","charts":[{"id":"revenue","slide_id":"s1","renderer":"vega-lite","brief_id":"revenue","spec_path":"assets/charts/specs/revenue.vl.json","svg_path":"assets/charts/revenue.svg","source_id":"web1","unit":"$","takeaway":"Revenue increased","render_receipt":"receipts/chart_render.json"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/specs/revenue.vl.json", minimalVegaLiteSpecForTest())
|
||||
|
||||
report, err := RenderVegaLiteCharts("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
if report.Renderer != "node-vega-lite" {
|
||||
t.Fatalf("renderer = %q, want node-vega-lite", report.Renderer)
|
||||
}
|
||||
if len(report.Charts) != 1 || report.Charts[0].SpecSHA256 == "" || report.Charts[0].SVGSHA256 == "" {
|
||||
t.Fatalf("render report = %+v, want one hashed chart", report)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join("demo", "assets", "charts", "revenue.svg")); err != nil || info.Size() == 0 {
|
||||
t.Fatalf("rendered SVG missing or empty, info=%+v err=%v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartRenderWritesEmptyReceiptForNoChartManifest(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
report, err := RenderVegaLiteCharts("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || len(report.Charts) != 0 {
|
||||
t.Fatalf("report = %+v, want passed empty report", report)
|
||||
}
|
||||
}
|
||||
|
||||
func minimalVegaLiteSpecForTest() string {
|
||||
return `{
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"width": 640,
|
||||
"height": 320,
|
||||
"data": {
|
||||
"values": [
|
||||
{"quarter": "Q1", "revenue": 2},
|
||||
{"quarter": "Q2", "revenue": 5}
|
||||
]
|
||||
},
|
||||
"mark": "bar",
|
||||
"encoding": {
|
||||
"x": {"field": "quarter", "type": "nominal", "title": "Quarter"},
|
||||
"y": {"field": "revenue", "type": "quantitative", "title": "Revenue ($B)"}
|
||||
}
|
||||
}`
|
||||
}
|
||||
1
internal/svglide/chart_renderer/.gitignore
vendored
Normal file
1
internal/svglide/chart_renderer/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
917
internal/svglide/chart_renderer/package-lock.json
generated
Normal file
917
internal/svglide/chart_renderer/package-lock.json
generated
Normal file
@@ -0,0 +1,917 @@
|
||||
{
|
||||
"name": "@svglide/chart-renderer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@svglide/chart-renderer",
|
||||
"dependencies": {
|
||||
"vega": "^6.2.0",
|
||||
"vega-lite": "^6.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://bnpm.byted.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://bnpm.byted.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://bnpm.byted.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://bnpm.byted.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://bnpm.byted.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://bnpm.byted.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://bnpm.byted.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://bnpm.byted.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://bnpm.byted.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo-projection": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
|
||||
"integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"d3-array": "1 - 3",
|
||||
"d3-geo": "1.12.0 - 3"
|
||||
},
|
||||
"bin": {
|
||||
"geo2svg": "bin/geo2svg.js",
|
||||
"geograticule": "bin/geograticule.js",
|
||||
"geoproject": "bin/geoproject.js",
|
||||
"geoquantize": "bin/geoquantize.js",
|
||||
"geostitch": "bin/geostitch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://bnpm.byted.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://bnpm.byted.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://bnpm.byted.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://bnpm.byted.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://bnpm.byted.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://bnpm.byted.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://bnpm.byted.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://bnpm.byted.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://bnpm.byted.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://bnpm.byted.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://bnpm.byted.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://bnpm.byted.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://bnpm.byted.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://bnpm.byted.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://bnpm.byted.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://bnpm.byted.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://bnpm.byted.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://bnpm.byted.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/vega": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://bnpm.byted.org/vega/-/vega-6.2.0.tgz",
|
||||
"integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-crossfilter": "~5.1.0",
|
||||
"vega-dataflow": "~6.1.0",
|
||||
"vega-encode": "~5.1.0",
|
||||
"vega-event-selector": "~4.0.0",
|
||||
"vega-expression": "~6.1.0",
|
||||
"vega-force": "~5.1.0",
|
||||
"vega-format": "~2.1.0",
|
||||
"vega-functions": "~6.1.0",
|
||||
"vega-geo": "~5.1.0",
|
||||
"vega-hierarchy": "~5.1.0",
|
||||
"vega-label": "~2.1.0",
|
||||
"vega-loader": "~5.1.0",
|
||||
"vega-parser": "~7.1.0",
|
||||
"vega-projection": "~2.1.0",
|
||||
"vega-regression": "~2.1.0",
|
||||
"vega-runtime": "~7.1.0",
|
||||
"vega-scale": "~8.1.0",
|
||||
"vega-scenegraph": "~5.1.0",
|
||||
"vega-statistics": "~2.0.0",
|
||||
"vega-time": "~3.1.0",
|
||||
"vega-transforms": "~5.1.0",
|
||||
"vega-typings": "~2.1.0",
|
||||
"vega-util": "~2.1.0",
|
||||
"vega-view": "~6.1.0",
|
||||
"vega-view-transforms": "~5.1.0",
|
||||
"vega-voronoi": "~5.1.0",
|
||||
"vega-wordcloud": "~5.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-canvas": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-canvas/-/vega-canvas-2.0.0.tgz",
|
||||
"integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-crossfilter": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz",
|
||||
"integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-dataflow": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz",
|
||||
"integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-loader": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-encode": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-encode/-/vega-encode-5.1.0.tgz",
|
||||
"integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-event-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-expression": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-expression/-/vega-expression-6.1.0.tgz",
|
||||
"integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-force": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-force/-/vega-force-5.1.0.tgz",
|
||||
"integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-format": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-format/-/vega-format-2.1.0.tgz",
|
||||
"integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-format": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-functions": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://bnpm.byted.org/vega-functions/-/vega-functions-6.1.1.tgz",
|
||||
"integrity": "sha512-Due6jP0y0FfsGMTrHnzUGnEwXPu7VwE+9relfo+LjL/tRPYnnKqwWvzt7n9JkeBuZqjkgYjMzm/WucNn6Hkw5A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-selections": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-geo": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-geo/-/vega-geo-5.1.0.tgz",
|
||||
"integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-projection": "^2.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-hierarchy": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz",
|
||||
"integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-label": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-label/-/vega-label-2.1.0.tgz",
|
||||
"integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-lite": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://bnpm.byted.org/vega-lite/-/vega-lite-6.4.3.tgz",
|
||||
"integrity": "sha512-d/7hPjfz560UERaQuTmGgIVfXAe3g2hJWeC+igDeaGohUdEoNrHLXgR/yTOBT8vV/lIuuKnw+0/xWWblkDwkMQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"json-stringify-pretty-compact": "~4.0.0",
|
||||
"tslib": "~2.8.1",
|
||||
"vega-event-selector": "~4.0.0",
|
||||
"vega-expression": "~6.1.0",
|
||||
"vega-util": "~2.1.0",
|
||||
"yargs": "~18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"vl2pdf": "bin/vl2pdf",
|
||||
"vl2png": "bin/vl2png",
|
||||
"vl2svg": "bin/vl2svg",
|
||||
"vl2vg": "bin/vl2vg"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vega": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-loader": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-loader/-/vega-loader-5.1.0.tgz",
|
||||
"integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dsv": "^3.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-parser": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-parser/-/vega-parser-7.1.0.tgz",
|
||||
"integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-event-selector": "^4.0.0",
|
||||
"vega-functions": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-projection": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-projection/-/vega-projection-2.1.0.tgz",
|
||||
"integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-geo-projection": "^4.0.0",
|
||||
"vega-scale": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-regression": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-regression/-/vega-regression-2.1.0.tgz",
|
||||
"integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-runtime": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-runtime/-/vega-runtime-7.1.0.tgz",
|
||||
"integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-scale": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-scale/-/vega-scale-8.1.0.tgz",
|
||||
"integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale-chromatic": "^3.1.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-scenegraph": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz",
|
||||
"integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0",
|
||||
"d3-shape": "^3.2.0",
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-loader": "^5.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-selections": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://bnpm.byted.org/vega-selections/-/vega-selections-6.1.2.tgz",
|
||||
"integrity": "sha512-xJ+V4qdd46nk2RBdwIRrQm2iSTMHdlu/omhLz1pqRL3jZDrkqNBXimrisci2kIKpH2WBpA1YVagwuZEKBmF2Qw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "3.2.4",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-statistics": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-statistics/-/vega-statistics-2.0.0.tgz",
|
||||
"integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-time/-/vega-time-3.1.0.tgz",
|
||||
"integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-transforms": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-transforms/-/vega-transforms-5.1.0.tgz",
|
||||
"integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-time": "^3.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-typings": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-typings/-/vega-typings-2.1.0.tgz",
|
||||
"integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/geojson": "7946.0.16",
|
||||
"vega-event-selector": "^4.0.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-util": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://bnpm.byted.org/vega-util/-/vega-util-2.1.1.tgz",
|
||||
"integrity": "sha512-tpNmm8bGtUa8gKfFDSjXPffxqSyPr91vaWIEBnJS/rijhoLZMwM+mgYQG6XfwdcBSN1+jkZ57P0sYSEW/jophw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/vega-view": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-view/-/vega-view-6.1.0.tgz",
|
||||
"integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-timer": "^3.0.1",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-format": "^2.1.0",
|
||||
"vega-functions": "^6.1.0",
|
||||
"vega-runtime": "^7.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-view-transforms": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz",
|
||||
"integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scenegraph": "^5.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-voronoi": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz",
|
||||
"integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-delaunay": "^6.0.4",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vega-wordcloud": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://bnpm.byted.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz",
|
||||
"integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"vega-canvas": "^2.0.0",
|
||||
"vega-dataflow": "^6.1.0",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-statistics": "^2.0.0",
|
||||
"vega-util": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://bnpm.byted.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://bnpm.byted.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://bnpm.byted.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://bnpm.byted.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
internal/svglide/chart_renderer/package.json
Normal file
15
internal/svglide/chart_renderer/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@svglide/chart-renderer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"render": "node ./render-vegalite.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"vega": "^6.2.0",
|
||||
"vega-lite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
28
internal/svglide/chart_renderer/render-vegalite.mjs
Normal file
28
internal/svglide/chart_renderer/render-vegalite.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as vega from "vega";
|
||||
import * as vegaLite from "vega-lite";
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index < 0 || index + 1 >= process.argv.length) {
|
||||
throw new Error(`missing required argument ${name}`);
|
||||
}
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
const input = readArg("--input");
|
||||
const output = readArg("--output");
|
||||
const raw = fs.readFileSync(input, "utf8");
|
||||
const vlSpec = JSON.parse(raw);
|
||||
const vgSpec = vegaLite.compile(vlSpec).spec;
|
||||
const view = new vega.View(vega.parse(vgSpec), {
|
||||
renderer: "svg",
|
||||
logLevel: vega.Warn
|
||||
});
|
||||
|
||||
await view.runAsync();
|
||||
const svg = await view.toSVG();
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, svg);
|
||||
view.finalize();
|
||||
195
internal/svglide/chart_usage.go
Normal file
195
internal/svglide/chart_usage.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chartUsageReceiptPath = "receipts/chart_usage.json"
|
||||
|
||||
type ChartUsageReport struct {
|
||||
Status string `json:"status"`
|
||||
Charts []ChartUsageChart `json:"charts"`
|
||||
Issues []ChartUsageIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ChartUsageChart struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
SVGPath string `json:"svg_path"`
|
||||
ReferenceCount int `json:"reference_count"`
|
||||
}
|
||||
|
||||
type ChartUsageIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type chartUsageReference struct {
|
||||
SlideID string
|
||||
Path string
|
||||
Href string
|
||||
Width float64
|
||||
Height float64
|
||||
}
|
||||
|
||||
func EvaluateChartUsageRun(safeRoot string, deck authorDeck, manifest chartManifestFile, briefs chartBriefFile) ChartUsageReport {
|
||||
report := ChartUsageReport{Status: "passed", Charts: []ChartUsageChart{}, Issues: []ChartUsageIssue{}}
|
||||
refsByPath := map[string][]chartUsageReference{}
|
||||
rawBySlide := map[string]string{}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath := strings.TrimSpace(slide.Path)
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.read_slide", Path: slidePath, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
rawText := string(raw)
|
||||
rawBySlide[strings.TrimSpace(slide.ID)] = rawText
|
||||
refs, issues := extractChartUsageReferences(strings.TrimSpace(slide.ID), slidePath, rawText)
|
||||
if len(issues) > 0 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, issues...)
|
||||
}
|
||||
for _, ref := range refs {
|
||||
refsByPath[ref.Path] = append(refsByPath[ref.Path], ref)
|
||||
}
|
||||
}
|
||||
briefByID := chartBriefByID(briefs)
|
||||
expectedSlideIDs := map[string]bool{}
|
||||
for _, chart := range manifest.Charts {
|
||||
id := strings.TrimSpace(chart.ID)
|
||||
slideID := strings.TrimSpace(chart.SlideID)
|
||||
svgPath := strings.TrimSpace(chart.SVGPath)
|
||||
expectedSlideIDs[slideID] = true
|
||||
refs := refsByPath[svgPath]
|
||||
report.Charts = append(report.Charts, ChartUsageChart{ID: id, SlideID: slideID, SVGPath: svgPath, ReferenceCount: len(refs)})
|
||||
if len(refs) == 0 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.not_referenced", Path: svgPath, Message: fmt.Sprintf("chart %q is not referenced by a <rect slide:role=\"chart\">", id)})
|
||||
continue
|
||||
}
|
||||
if len(refs) > 1 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.duplicate_reference", Path: svgPath, Message: fmt.Sprintf("chart %q has %d references; expected exactly one", id, len(refs))})
|
||||
}
|
||||
minWidth, minHeight := chartUsageMinSize(briefByID[strings.TrimSpace(chart.BriefID)])
|
||||
for _, ref := range refs {
|
||||
if ref.SlideID != slideID {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.wrong_slide", Path: svgPath, Message: fmt.Sprintf("chart %q referenced on slide %q, want %q", id, ref.SlideID, slideID)})
|
||||
}
|
||||
if ref.Width < float64(minWidth) || ref.Height < float64(minHeight) {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.too_small", Path: svgPath, Message: fmt.Sprintf("chart %q rendered at %.0fx%.0f, minimum is %dx%d", id, ref.Width, ref.Height, minWidth, minHeight)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for slideID := range expectedSlideIDs {
|
||||
hasValidRef := false
|
||||
for _, refs := range refsByPath {
|
||||
for _, ref := range refs {
|
||||
if ref.SlideID == slideID {
|
||||
hasValidRef = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasValidRef && chartSlideLooksHandDrawn(rawBySlide[slideID]) {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ChartUsageIssue{Code: "svglide.chart_usage.hand_drawn_chart", Path: slideID, Message: "slide appears to hand-draw chart primitives instead of embedding a rendered chart asset"})
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func writeChartUsageReport(safeRoot string, report ChartUsageReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, chartUsageReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func extractChartUsageReferences(slideID, slidePath, svg string) ([]chartUsageReference, []ChartUsageIssue) {
|
||||
refs := []chartUsageReference{}
|
||||
issues := []ChartUsageIssue{}
|
||||
decoder := xml.NewDecoder(strings.NewReader(svg))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attrs := parseSVGAttrs(start.Attr)
|
||||
if strings.TrimSpace(attrs["role"]) != "chart" {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != "rect" {
|
||||
issues = append(issues, ChartUsageIssue{Code: "svglide.chart_usage.invalid_chart_element", Path: slidePath, Message: fmt.Sprintf("<%s slide:role=\"chart\"> is invalid; use <rect slide:role=\"chart\">", start.Name.Local)})
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
refs = append(refs, chartUsageReference{
|
||||
SlideID: slideID,
|
||||
Path: normalizeChartHref(slidePath, href),
|
||||
Href: href,
|
||||
Width: parseChartUsageFloatAttr(attrs["width"]),
|
||||
Height: parseChartUsageFloatAttr(attrs["height"]),
|
||||
})
|
||||
}
|
||||
return refs, issues
|
||||
}
|
||||
|
||||
func normalizeChartHref(slidePath, href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if strings.HasPrefix(href, "assets/charts/") {
|
||||
return href
|
||||
}
|
||||
return normalizeSlideAssetHref(slidePath, href)
|
||||
}
|
||||
|
||||
func parseChartUsageFloatAttr(raw string) float64 {
|
||||
raw = strings.TrimSpace(strings.TrimSuffix(raw, "px"))
|
||||
value, _ := strconv.ParseFloat(raw, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func chartBriefByID(briefs chartBriefFile) map[string]chartBriefEntry {
|
||||
out := map[string]chartBriefEntry{}
|
||||
for _, brief := range briefs.Charts {
|
||||
if id := strings.TrimSpace(brief.ID); id != "" {
|
||||
out[id] = brief
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func chartUsageMinSize(brief chartBriefEntry) (int, int) {
|
||||
minWidth := 480
|
||||
minHeight := 260
|
||||
if brief.MinWidth > minWidth {
|
||||
minWidth = brief.MinWidth
|
||||
}
|
||||
if brief.MinHeight > minHeight {
|
||||
minHeight = brief.MinHeight
|
||||
}
|
||||
return minWidth, minHeight
|
||||
}
|
||||
|
||||
func chartSlideLooksHandDrawn(svg string) bool {
|
||||
raw := strings.ToLower(svg)
|
||||
rects := strings.Count(raw, "<rect") - strings.Count(raw, `slide:role="chart"`)
|
||||
circles := strings.Count(raw, "<circle")
|
||||
lines := strings.Count(raw, "<line")
|
||||
paths := strings.Count(raw, "<path")
|
||||
texts := strings.Count(raw, "<text")
|
||||
return rects >= 4 || circles >= 6 || (lines >= 2 && (rects+paths+circles) >= 4) || (texts >= 5 && (rects+paths+circles+lines) >= 4)
|
||||
}
|
||||
121
internal/svglide/chart_usage_test.go
Normal file
121
internal/svglide/chart_usage_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChartUsageAcceptsRectChartReference(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsImageRoleChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<image slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.invalid_chart_element") {
|
||||
t.Fatalf("issues = %+v, want invalid_chart_element", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsGroupRoleChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<g slide:role="chart" href="../assets/charts/revenue.svg"></g>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.invalid_chart_element") {
|
||||
t.Fatalf("issues = %+v, want invalid_chart_element", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsWrongSlide(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="640" height="320"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("other-slide"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.wrong_slide") {
|
||||
t.Fatalf("issues = %+v, want wrong_slide", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsTinyChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<rect slide:role="chart" href="../assets/charts/revenue.svg" x="120" y="120" width="240" height="120"/>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.too_small") {
|
||||
t.Fatalf("issues = %+v, want too_small", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartUsageRejectsHandDrawnChart(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
deck := writeChartUsageDeckForTest(t, `<line x1="100" y1="420" x2="700" y2="420"/><line x1="100" y1="100" x2="100" y2="420"/><rect x="150" y="320" width="60" height="100"/><rect x="250" y="260" width="60" height="160"/><rect x="350" y="210" width="60" height="210"/><rect x="450" y="180" width="60" height="240"/><text x="150" y="450">$2B</text><text x="250" y="450">$5B</text><text x="350" y="450">$8B</text><text x="450" y="450">$9B</text>`)
|
||||
report := EvaluateChartUsageRun("demo", deck, chartUsageManifestForTest("s1"), chartUsageBriefsForTest())
|
||||
|
||||
if !chartUsageIssuesContain(report.Issues, "svglide.chart_usage.hand_drawn_chart") {
|
||||
t.Fatalf("issues = %+v, want hand_drawn_chart", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityWritesChartUsageReceipt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[],"no_image_reason":"Chart usage receipt smoke does not exercise raster image selection."}`)
|
||||
|
||||
if _, err := CheckQuality("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readRunRegularArtifact("demo", chartUsageReceiptPath); err != nil {
|
||||
t.Fatalf("missing chart usage receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeChartUsageDeckForTest(t *testing.T, body string) authorDeck {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">`+body+`</svg>`)
|
||||
return authorDeck{Slides: []authorDeckSlide{{ID: "s1", Path: "slides/01.svg"}}}
|
||||
}
|
||||
|
||||
func chartUsageManifestForTest(slideID string) chartManifestFile {
|
||||
return chartManifestFile{
|
||||
Renderer: "vega-lite",
|
||||
Charts: []chartManifestEntry{{
|
||||
ID: "revenue",
|
||||
SlideID: slideID,
|
||||
Renderer: "vega-lite",
|
||||
BriefID: "revenue",
|
||||
SpecPath: "assets/charts/specs/revenue.vl.json",
|
||||
SVGPath: "assets/charts/revenue.svg",
|
||||
SourceID: "web1",
|
||||
Unit: "$",
|
||||
Takeaway: "Revenue increased",
|
||||
RenderReceipt: chartRenderReceiptPath,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func chartUsageBriefsForTest() chartBriefFile {
|
||||
return chartBriefFile{Charts: []chartBriefEntry{{
|
||||
ID: "revenue",
|
||||
SlideID: "s1",
|
||||
Purpose: "trend",
|
||||
Takeaway: "Revenue increased",
|
||||
Renderer: "vega-lite",
|
||||
SourceIDs: []string{"web1"},
|
||||
Unit: "$",
|
||||
}}}
|
||||
}
|
||||
|
||||
func chartUsageIssuesContain(issues []ChartUsageIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
884
internal/svglide/creative_quality.go
Normal file
884
internal/svglide/creative_quality.go
Normal file
@@ -0,0 +1,884 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
visualReceiptsPath = "visual_receipts.json"
|
||||
creativeQualityReportPath = "creative_quality_report.json"
|
||||
)
|
||||
|
||||
type CreativeQualityReport struct {
|
||||
Status string `json:"status"`
|
||||
Issues []CreativeQualityIssue `json:"issues"`
|
||||
Metrics CreativeQualityMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
type CreativeQualityIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type CreativeQualityMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
VisualReceipts int `json:"visual_receipts"`
|
||||
MissingVisualReceipts int `json:"missing_visual_receipts"`
|
||||
ProcessLeakCount int `json:"process_leak_count"`
|
||||
GenericFontSlideCount int `json:"generic_font_slide_count"`
|
||||
TopicTypographyMismatchCount int `json:"topic_typography_mismatch_count"`
|
||||
TypographyRoleCollapseCount int `json:"typography_role_collapse_count"`
|
||||
DistinctLayoutFamilyCount int `json:"distinct_layout_family_count"`
|
||||
DistinctLayoutArchetypeCount int `json:"distinct_layout_archetype_count"`
|
||||
LayoutArchetypeMaxRatioBP int `json:"layout_archetype_max_ratio_bp"`
|
||||
AdjacentLayoutArchetypeCount int `json:"adjacent_layout_archetype_count"`
|
||||
LeftRightChartArchetypeCount int `json:"left_right_chart_archetype_count"`
|
||||
LayoutSignatureMaxRatioBP int `json:"layout_signature_max_ratio_bp"`
|
||||
AdjacentLayoutRepetitionCount int `json:"adjacent_layout_repetition_count"`
|
||||
CardDominantSlideCount int `json:"card_dominant_slide_count"`
|
||||
DarkCardTemplateSlideCount int `json:"dark_card_template_slide_count"`
|
||||
ShapeLanguageMaxRatioBP int `json:"shape_language_max_ratio_bp"`
|
||||
DecorativeImageOnlyCount int `json:"decorative_image_only_count"`
|
||||
WeakCoverVisualImpactCount int `json:"weak_cover_visual_impact_count"`
|
||||
DefaultCardTextContainerCount int `json:"default_card_text_container_count"`
|
||||
OpenTextCarrierSlideCount int `json:"open_text_carrier_slide_count"`
|
||||
FusionSlideCount int `json:"fusion_slide_count"`
|
||||
FusionAdjacentCount int `json:"fusion_adjacent_count"`
|
||||
WeakSlideCount int `json:"weak_slide_count"`
|
||||
ChartWithoutEvidenceCount int `json:"chart_without_evidence_count"`
|
||||
WarningCount int `json:"warning_count"`
|
||||
}
|
||||
|
||||
type visualReceiptsFile struct {
|
||||
Slides []visualReceipt `json:"slides"`
|
||||
}
|
||||
|
||||
type visualReceipt struct {
|
||||
SlideID string `json:"slide_id"`
|
||||
StoryJob string `json:"story_job"`
|
||||
LayoutFamily string `json:"layout_family"`
|
||||
LayoutArchetype string `json:"layout_archetype"`
|
||||
LayoutSignature string `json:"layout_signature"`
|
||||
ThumbnailJob string `json:"thumbnail_job"`
|
||||
VisualCenter string `json:"visual_center"`
|
||||
TopicFitClaim string `json:"topic_fit_claim"`
|
||||
InformationDensityPlan string `json:"information_density_plan"`
|
||||
PageDifferenceFromPrevious string `json:"page_difference_from_previous"`
|
||||
PrimaryAsset string `json:"primary_asset"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FontRoleUsage map[string]string `json:"font_role_usage"`
|
||||
TypographyRoleUsage map[string]string `json:"typography_role_usage"`
|
||||
CompositionIntent string `json:"composition_intent"`
|
||||
DataVisualRationale string `json:"data_visual_rationale"`
|
||||
SourceEvidence []string `json:"source_evidence"`
|
||||
ContainerFitPlan string `json:"container_fit_plan"`
|
||||
ContainerDecision string `json:"container_decision"`
|
||||
TextCarrier string `json:"text_carrier"`
|
||||
ShapeLanguage string `json:"shape_language"`
|
||||
CardBudget visualCardBudget `json:"card_budget"`
|
||||
ChartReceipt visualChartReceipt `json:"chart_receipt"`
|
||||
FusionSpec visualFusionReceipt `json:"fusion_spec"`
|
||||
QAExpectations []string `json:"qa_expectations"`
|
||||
}
|
||||
|
||||
type visualCardBudget struct {
|
||||
CardCount int `json:"card_count"`
|
||||
WhyCardsAreNeeded string `json:"why_cards_are_needed"`
|
||||
}
|
||||
|
||||
type visualChartReceipt struct {
|
||||
ChartID string `json:"chart_id"`
|
||||
Renderer string `json:"renderer"`
|
||||
Unit string `json:"unit"`
|
||||
Source string `json:"source"`
|
||||
WhyChartIsNeeded string `json:"why_chart_is_needed"`
|
||||
}
|
||||
|
||||
type visualFusionReceipt struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SeamSide string `json:"seam_side"`
|
||||
SampledColor string `json:"sampled_color"`
|
||||
PanelColor string `json:"panel_color"`
|
||||
FadeWidth int `json:"fade_width"`
|
||||
SubjectSafety string `json:"subject_safety"`
|
||||
}
|
||||
|
||||
func CheckCreativeQuality(root string) (CreativeQualityReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return CreativeQualityReport{}, err
|
||||
}
|
||||
deckPath := "outline/deck.json"
|
||||
deck, err := readAuthorDeck(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return CreativeQualityReport{}, err
|
||||
}
|
||||
mode := normalizedVisualQualityMode(run.VisualQualityMode)
|
||||
report := CreativeQualityReport{
|
||||
Status: "passed",
|
||||
Issues: []CreativeQualityIssue{},
|
||||
Metrics: CreativeQualityMetrics{Slides: len(deck.Slides)},
|
||||
}
|
||||
|
||||
receipts, receiptErr := readVisualReceipts(safeRoot)
|
||||
if receiptErr != nil {
|
||||
addCreativeIssue(&report, mode, visualReceiptsPath, "svglide.creative.missing_visual_receipts", receiptErr.Error(), "error")
|
||||
}
|
||||
report.Metrics.VisualReceipts = len(receipts.Slides)
|
||||
receiptBySlide := make(map[string]visualReceipt, len(receipts.Slides))
|
||||
for _, receipt := range receipts.Slides {
|
||||
id := strings.TrimSpace(receipt.SlideID)
|
||||
if id != "" {
|
||||
receiptBySlide[id] = receipt
|
||||
}
|
||||
}
|
||||
|
||||
if contract, present, contractErr := readTypographyContract(safeRoot); present {
|
||||
if contractErr != nil {
|
||||
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.invalid_contract", contractErr.Error(), "error")
|
||||
} else {
|
||||
deckType := strings.Join([]string{run.Title, run.Intent.Topic, deck.Title, contract.Profile}, " ")
|
||||
identity := evaluateTypographyIdentity(contract, deckType)
|
||||
if identity.GenericFallbackOnly {
|
||||
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.too_generic", "typography contract uses only generic/browser fallback font stacks", "error")
|
||||
}
|
||||
if identity.RepeatedDefaultStack {
|
||||
report.Metrics.TypographyRoleCollapseCount++
|
||||
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.role_collapse", "typography contract must keep display, body, and numeric/label roles distinct enough to carry visual identity", "error")
|
||||
}
|
||||
if identity.ProfileMismatch {
|
||||
report.Metrics.TopicTypographyMismatchCount++
|
||||
addCreativeIssue(&report, mode, typographyContractPath, "svglide.typography.identity.profile_mismatch", "typography contract does not match the deck topic/profile identity", "error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
familyCounts := make(map[string]int)
|
||||
archetypeCounts := make(map[string]int)
|
||||
layoutCounts := make(map[string]int)
|
||||
shapeLanguageCounts := make(map[string]int)
|
||||
var previousLayout string
|
||||
var previousArchetype string
|
||||
var previousFamily string
|
||||
var previousDarkCardTemplate bool
|
||||
for i, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
receipt, hasReceipt := receiptBySlide[id]
|
||||
if !hasReceipt {
|
||||
report.Metrics.MissingVisualReceipts++
|
||||
addCreativeIssue(&report, mode, visualReceiptsPath, "svglide.creative.missing_visual_receipt", fmt.Sprintf("slide %q has no visual receipt", id), "error")
|
||||
}
|
||||
layoutFamily := firstNonEmpty(slide.LayoutFamily, receipt.LayoutFamily)
|
||||
layoutArchetype := firstNonEmpty(slide.LayoutArchetype, receipt.LayoutArchetype, inferAuthorLayoutArchetype(layoutFamily, firstNonEmpty(slide.LayoutSignature, receipt.LayoutSignature)))
|
||||
layoutSignature := firstNonEmpty(slide.LayoutSignature, receipt.LayoutSignature)
|
||||
if strings.TrimSpace(layoutFamily) == "" || strings.TrimSpace(layoutSignature) == "" || strings.TrimSpace(layoutArchetype) == "" {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.missing_layout_fields", fmt.Sprintf("slide %q must declare layout_family, layout_archetype, and layout_signature", id), "error")
|
||||
}
|
||||
if layoutFamily != "" {
|
||||
familyCounts[layoutFamily]++
|
||||
}
|
||||
if layoutArchetype != "" {
|
||||
archetypeCounts[layoutArchetype]++
|
||||
if i > 0 && layoutArchetype == previousArchetype {
|
||||
report.Metrics.AdjacentLayoutArchetypeCount++
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_layout_archetype", fmt.Sprintf("slide %q repeats adjacent layout_archetype %q", id, layoutArchetype), "error")
|
||||
}
|
||||
if isLeftRightChartArchetype(layoutArchetype, layoutSignature, receipt) {
|
||||
report.Metrics.LeftRightChartArchetypeCount++
|
||||
}
|
||||
previousArchetype = layoutArchetype
|
||||
}
|
||||
if layoutSignature != "" {
|
||||
layoutCounts[layoutSignature]++
|
||||
if i > 0 && layoutSignature == previousLayout {
|
||||
report.Metrics.AdjacentLayoutRepetitionCount++
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_layout_repetition", fmt.Sprintf("slide %q repeats adjacent layout_signature %q", id, layoutSignature), "error")
|
||||
}
|
||||
previousLayout = layoutSignature
|
||||
}
|
||||
if layoutFamily == "image_text_fusion_split" {
|
||||
report.Metrics.FusionSlideCount++
|
||||
if i > 0 && previousFamily == "image_text_fusion_split" {
|
||||
report.Metrics.FusionAdjacentCount++
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.adjacent_fusion", fmt.Sprintf("slide %q repeats image_text_fusion_split after another fusion slide", id), "error")
|
||||
}
|
||||
if hasReceipt {
|
||||
checkFusionReceipt(&report, mode, id, receipt)
|
||||
}
|
||||
}
|
||||
previousFamily = layoutFamily
|
||||
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.invalid_slide_path", err.Error(), "error")
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.missing_slide_svg", err.Error(), "error")
|
||||
continue
|
||||
}
|
||||
svg := string(raw)
|
||||
shapeSummary := analyzeShapeLanguage(svg)
|
||||
shapeLanguage := firstNonEmpty(receipt.ShapeLanguage, shapeLanguageSignature(shapeSummary, receipt))
|
||||
if shapeLanguage != "" {
|
||||
shapeLanguageCounts[shapeLanguage]++
|
||||
}
|
||||
if isCardDominantSlide(shapeSummary) {
|
||||
report.Metrics.CardDominantSlideCount++
|
||||
}
|
||||
darkCardTemplate := isDarkCardTemplateSlide(shapeSummary)
|
||||
if darkCardTemplate {
|
||||
report.Metrics.DarkCardTemplateSlideCount++
|
||||
if previousDarkCardTemplate {
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.dark_card_template_repetition", fmt.Sprintf("slide %q repeats the adjacent dark rounded-card template", id), "error")
|
||||
}
|
||||
}
|
||||
previousDarkCardTemplate = darkCardTemplate
|
||||
textCarrier := classifyTextCarrier(svg, receipt)
|
||||
if isOpenTextCarrier(textCarrier) {
|
||||
report.Metrics.OpenTextCarrierSlideCount++
|
||||
}
|
||||
if hasReceipt && isDefaultCardTextContainer(shapeSummary, textCarrier, receipt) {
|
||||
report.Metrics.DefaultCardTextContainerCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.default_card_text_container", fmt.Sprintf("slide %q uses rounded cards as the default text container without a content reason", id), "error")
|
||||
}
|
||||
if hasReceipt && isDecorativeImageOnlySlide(svg, receipt) {
|
||||
report.Metrics.DecorativeImageOnlyCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.decorative_image_only", fmt.Sprintf("slide %q uses imagery as decoration rather than a topic visual", id), "error")
|
||||
}
|
||||
if hasReceipt && isCoverSlide(slide) && !hasStrongCoverVisualImpact(svg, receipt) {
|
||||
report.Metrics.WeakCoverVisualImpactCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.weak_cover_visual_impact", fmt.Sprintf("slide %q cover lacks a strong topic-specific visual", id), "error")
|
||||
}
|
||||
leaks := countCreativeProcessLeaks(svg)
|
||||
if leaks > 0 {
|
||||
report.Metrics.ProcessLeakCount += leaks
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.process_leak", fmt.Sprintf("slide %q exposes process/source/prompt language in visible text", id), "error")
|
||||
}
|
||||
if svgHasGenericFontProblem(svg) {
|
||||
report.Metrics.GenericFontSlideCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.generic_fonts", fmt.Sprintf("slide %q uses generic/browser font roles instead of concrete deck typography", id), "error")
|
||||
}
|
||||
if hasReceipt && isWeakCreativeSlide(svg, receipt, layoutFamily) {
|
||||
report.Metrics.WeakSlideCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.weak_slide", fmt.Sprintf("slide %q lacks enough visual center, topic fit, differentiation, or information density", id), "error")
|
||||
}
|
||||
if hasDataVisualIntent(svg, layoutFamily, receipt) && !hasNumericSourceEvidence(receipt) {
|
||||
report.Metrics.ChartWithoutEvidenceCount++
|
||||
addCreativeIssue(&report, mode, slidePath, "svglide.creative.chart_without_evidence", fmt.Sprintf("slide %q uses data/chart visual language without numeric source_evidence", id), "error")
|
||||
}
|
||||
}
|
||||
|
||||
report.Metrics.DistinctLayoutFamilyCount = len(familyCounts)
|
||||
report.Metrics.DistinctLayoutArchetypeCount = len(archetypeCounts)
|
||||
report.Metrics.LayoutArchetypeMaxRatioBP = maxLayoutSignatureRatioBP(archetypeCounts, len(deck.Slides))
|
||||
report.Metrics.LayoutSignatureMaxRatioBP = maxLayoutSignatureRatioBP(layoutCounts, len(deck.Slides))
|
||||
report.Metrics.ShapeLanguageMaxRatioBP = maxLayoutSignatureRatioBP(shapeLanguageCounts, len(deck.Slides))
|
||||
if len(deck.Slides) >= 8 && report.Metrics.DistinctLayoutArchetypeCount < minDistinctLayoutArchetypes(len(deck.Slides)) {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_archetype_diversity", fmt.Sprintf("deck has %d distinct layout_archetype values, want >= %d", report.Metrics.DistinctLayoutArchetypeCount, minDistinctLayoutArchetypes(len(deck.Slides))), "error")
|
||||
}
|
||||
if len(deck.Slides) >= 5 && report.Metrics.LayoutArchetypeMaxRatioBP > maxArchetypeRatioBP(len(deck.Slides)) {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_archetype_overuse", fmt.Sprintf("most common layout_archetype ratio is %d bp, want <= %d bp", report.Metrics.LayoutArchetypeMaxRatioBP, maxArchetypeRatioBP(len(deck.Slides))), "error")
|
||||
}
|
||||
if report.Metrics.LeftRightChartArchetypeCount > maxLeftRightChartArchetypes(len(deck.Slides)) {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.left_right_chart_overuse", fmt.Sprintf("left/right chart archetype count is %d, want <= %d", report.Metrics.LeftRightChartArchetypeCount, maxLeftRightChartArchetypes(len(deck.Slides))), "error")
|
||||
}
|
||||
if len(deck.Slides) >= 8 && report.Metrics.LayoutSignatureMaxRatioBP > 3000 {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.layout_overuse", fmt.Sprintf("most common layout_signature ratio is %d bp, want <= 3000 bp", report.Metrics.LayoutSignatureMaxRatioBP), "error")
|
||||
}
|
||||
if limit := maxFusionSlides(len(deck.Slides)); report.Metrics.FusionSlideCount > limit {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.fusion_overuse", fmt.Sprintf("image_text_fusion_split count is %d, want <= %d", report.Metrics.FusionSlideCount, limit), "error")
|
||||
}
|
||||
if len(deck.Slides) >= 8 && report.Metrics.CardDominantSlideCount*10000/len(deck.Slides) > 3500 {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.card_dominant_overuse", fmt.Sprintf("card-dominant slide ratio is %d bp, want <= 3500 bp", report.Metrics.CardDominantSlideCount*10000/len(deck.Slides)), "error")
|
||||
}
|
||||
if len(deck.Slides) >= 8 && report.Metrics.OpenTextCarrierSlideCount*10000/len(deck.Slides) < 4000 {
|
||||
addCreativeIssue(&report, mode, deckPath, "svglide.creative.open_text_carrier_underuse", fmt.Sprintf("open text carrier ratio is %d bp, want >= 4000 bp", report.Metrics.OpenTextCarrierSlideCount*10000/len(deck.Slides)), "error")
|
||||
}
|
||||
|
||||
for _, issue := range report.Issues {
|
||||
if issue.Severity == "warning" {
|
||||
report.Metrics.WarningCount++
|
||||
}
|
||||
if issue.Severity == "error" {
|
||||
report.Status = "failed"
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readVisualReceipts(safeRoot string) (visualReceiptsFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, visualReceiptsPath)
|
||||
if err != nil {
|
||||
return visualReceiptsFile{}, fmt.Errorf("%s: read artifact: %w", visualReceiptsPath, err)
|
||||
}
|
||||
var receipts visualReceiptsFile
|
||||
if err := json.Unmarshal(raw, &receipts); err != nil {
|
||||
return visualReceiptsFile{}, fmt.Errorf("%s: invalid JSON: %w", visualReceiptsPath, err)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
|
||||
func addCreativeIssue(report *CreativeQualityReport, mode, path, code, message, severity string) {
|
||||
severity = strings.TrimSpace(severity)
|
||||
if severity == "" {
|
||||
severity = "error"
|
||||
}
|
||||
if mode == VisualQualityModeWarn && severity == "error" {
|
||||
severity = "warning"
|
||||
}
|
||||
report.Issues = append(report.Issues, CreativeQualityIssue{
|
||||
Path: path,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: severity,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizedVisualQualityMode(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case VisualQualityModeWarn:
|
||||
return VisualQualityModeWarn
|
||||
default:
|
||||
return VisualQualityModeStrict
|
||||
}
|
||||
}
|
||||
|
||||
func checkFusionReceipt(report *CreativeQualityReport, mode, slideID string, receipt visualReceipt) {
|
||||
if !receipt.FusionSpec.Enabled {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_missing_spec", fmt.Sprintf("slide %q uses image_text_fusion_split but fusion_spec.enabled is false", slideID), "error")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(receipt.FusionSpec.SeamSide) == "" || strings.TrimSpace(receipt.FusionSpec.SampledColor) == "" || strings.TrimSpace(receipt.FusionSpec.PanelColor) == "" {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_missing_spec", fmt.Sprintf("slide %q fusion_spec must include seam_side, sampled_color, and panel_color", slideID), "error")
|
||||
}
|
||||
if receipt.FusionSpec.FadeWidth < 80 || receipt.FusionSpec.FadeWidth > 180 {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_bad_fade", fmt.Sprintf("slide %q fusion fade_width must be 80-180 px, got %d", slideID, receipt.FusionSpec.FadeWidth), "error")
|
||||
}
|
||||
if strings.TrimSpace(receipt.FusionSpec.SubjectSafety) == "" {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.subject_safety_warning", fmt.Sprintf("slide %q fusion_spec lacks subject safety judgment", slideID), "warning")
|
||||
}
|
||||
if ok, delta := CheckSeamDelta(receipt.FusionSpec.SampledColor, receipt.FusionSpec.PanelColor); !ok {
|
||||
addCreativeIssue(report, mode, visualReceiptsPath, "svglide.creative.fusion_seam_delta", fmt.Sprintf("slide %q seam color delta is %d, want <= 45", slideID, delta), "warning")
|
||||
}
|
||||
}
|
||||
|
||||
func maxFusionSlides(slides int) int {
|
||||
if slides <= 0 {
|
||||
return 0
|
||||
}
|
||||
limit := slides * 30 / 100
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 3 {
|
||||
limit = 3
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func isLeftRightChartArchetype(archetype string, signature string, receipt visualReceipt) bool {
|
||||
value := strings.ToLower(strings.TrimSpace(archetype + " " + signature + " " + receipt.CompositionIntent))
|
||||
if strings.Contains(value, "left_text_right_chart") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(value, "left text right chart") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "split") && strings.Contains(value, "chart")
|
||||
}
|
||||
|
||||
func minDistinctLayoutArchetypes(slides int) int {
|
||||
switch {
|
||||
case slides >= 8:
|
||||
return 5
|
||||
case slides >= 5:
|
||||
return 3
|
||||
case slides >= 3:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func maxArchetypeRatioBP(slides int) int {
|
||||
if slides >= 8 {
|
||||
return 2500
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
func maxLeftRightChartArchetypes(slides int) int {
|
||||
if slides >= 8 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func countCreativeProcessLeaks(svg string) int {
|
||||
visible := strings.ToLower(visibleSemanticText(svg))
|
||||
count := 0
|
||||
for _, marker := range []string{
|
||||
"sources:",
|
||||
"source note",
|
||||
"prompt",
|
||||
"slide:note",
|
||||
"production_instruction",
|
||||
"素材说明",
|
||||
"制作说明",
|
||||
"接缝",
|
||||
"取色",
|
||||
"渐变遮罩",
|
||||
"source ref",
|
||||
} {
|
||||
count += strings.Count(visible, strings.ToLower(marker))
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
var fontRolePattern = regexp.MustCompile(`--font-(display|body|number|label)\s*:\s*([^;"}]+)`)
|
||||
|
||||
func svgHasGenericFontProblem(svg string) bool {
|
||||
matches := fontRolePattern.FindAllStringSubmatch(svg, -1)
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
roles := make(map[string]bool)
|
||||
genericRoles := 0
|
||||
for _, match := range matches {
|
||||
if len(match) != 3 {
|
||||
continue
|
||||
}
|
||||
role := strings.TrimSpace(match[1])
|
||||
if roles[role] {
|
||||
continue
|
||||
}
|
||||
roles[role] = true
|
||||
if isGenericOrBrowserFontStack(match[2]) {
|
||||
genericRoles++
|
||||
}
|
||||
}
|
||||
if len(roles) >= 4 && genericRoles == len(roles) {
|
||||
return true
|
||||
}
|
||||
visible := visibleSemanticText(svg)
|
||||
if containsCJK(visible) && !hasConcreteCJKFont(svg) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGenericOrBrowserFontStack(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.Trim(value, `"'`)
|
||||
for _, token := range []string{",", " "} {
|
||||
value = strings.ReplaceAll(value, token, "")
|
||||
}
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, concrete := range []string{"inter", "roboto", "georgia", "avenir", "sfpro", "noto", "sourcehan", "pingfang", "hiragino", "microsoftyahei", "songtisc"} {
|
||||
if strings.Contains(value, concrete) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, generic := range []string{"arial", "helvetica", "sans-serif", "serif", "system-ui", "ui-sans-serif"} {
|
||||
value = strings.ReplaceAll(value, generic, "")
|
||||
}
|
||||
return value == ""
|
||||
}
|
||||
|
||||
func containsCJK(text string) bool {
|
||||
for _, r := range text {
|
||||
if (r >= 0x4e00 && r <= 0x9fff) || (r >= 0x3400 && r <= 0x4dbf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasConcreteCJKFont(svg string) bool {
|
||||
lower := strings.ToLower(svg)
|
||||
for _, token := range []string{
|
||||
"noto sans cjk", "noto serif cjk", "source han sans", "source han serif",
|
||||
"pingfang", "hiragino sans gb", "microsoft yahei", "songti sc",
|
||||
"思源黑体", "思源宋体", "微软雅黑", "黑体", "宋体",
|
||||
} {
|
||||
if strings.Contains(lower, strings.ToLower(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWeakCreativeSlide(svg string, receipt visualReceipt, layoutFamily string) bool {
|
||||
score := 0
|
||||
if strings.TrimSpace(receipt.VisualCenter) == "" {
|
||||
score++
|
||||
}
|
||||
if strings.TrimSpace(receipt.TopicFitClaim) == "" {
|
||||
score++
|
||||
}
|
||||
if weakReceiptText(receipt.InformationDensityPlan) {
|
||||
score++
|
||||
}
|
||||
if weakReceiptText(receipt.PageDifferenceFromPrevious) {
|
||||
score++
|
||||
}
|
||||
if countSVGImageElements(svg) == 0 && !hasDataVisualIntent(svg, layoutFamily, receipt) {
|
||||
score++
|
||||
}
|
||||
if countRoundedTextPanels(svg) >= 3 && countSVGImageElements(svg) == 0 {
|
||||
score++
|
||||
}
|
||||
return score >= 3
|
||||
}
|
||||
|
||||
func weakReceiptText(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, marker := range []string{"same", "similar", "tbd", "none", "无", "同上", "相同"} {
|
||||
if value == marker || strings.Contains(value, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var roundedRectPattern = regexp.MustCompile(`(?is)<rect\b[^>]*(\brx\s*=|\bry\s*=)`)
|
||||
var svgRectTagForCreativePattern = regexp.MustCompile(`(?is)<rect\b[^>]*>`)
|
||||
var svgTextBlockForCreativePattern = regexp.MustCompile(`(?is)<(?:text|foreignObject)\b`)
|
||||
var svgFillForCreativePattern = regexp.MustCompile(`(?i)\bfill\s*=\s*"([^"]+)"`)
|
||||
var svgStrokeForCreativePattern = regexp.MustCompile(`(?i)\bstroke\s*=\s*"([^"]+)"`)
|
||||
|
||||
type shapeLanguageSummary struct {
|
||||
RectCount int
|
||||
RoundedCardCount int
|
||||
LargePanelCount int
|
||||
DarkFillCount int
|
||||
StrokePanelCount int
|
||||
ImageCount int
|
||||
ImageAreaBP int
|
||||
LargestImageAreaBP int
|
||||
TextBlockCount int
|
||||
}
|
||||
|
||||
type textCarrierKind string
|
||||
|
||||
const (
|
||||
textCarrierOpenGrid textCarrierKind = "open_grid"
|
||||
textCarrierImageDarkZone textCarrierKind = "image_dark_zone"
|
||||
textCarrierLineAnnotation textCarrierKind = "line_annotation"
|
||||
textCarrierAxisAnnotation textCarrierKind = "axis_annotation"
|
||||
textCarrierCardGroup textCarrierKind = "card_group"
|
||||
textCarrierMetricPanel textCarrierKind = "metric_panel"
|
||||
)
|
||||
|
||||
func countRoundedTextPanels(svg string) int {
|
||||
return len(roundedRectPattern.FindAllStringIndex(svg, -1))
|
||||
}
|
||||
|
||||
func analyzeShapeLanguage(svg string) shapeLanguageSummary {
|
||||
width, height := svgViewBoxSize(svg)
|
||||
if width <= 0 {
|
||||
width = defaultSlideWidth
|
||||
}
|
||||
if height <= 0 {
|
||||
height = defaultSlideHeight
|
||||
}
|
||||
canvasArea := width * height
|
||||
if canvasArea <= 0 {
|
||||
canvasArea = defaultSlideWidth * defaultSlideHeight
|
||||
}
|
||||
summary := shapeLanguageSummary{
|
||||
ImageCount: countSVGImageElements(svg),
|
||||
TextBlockCount: len(svgTextBlockForCreativePattern.FindAllStringIndex(svg, -1)),
|
||||
}
|
||||
for _, tag := range svgRectTagForCreativePattern.FindAllString(svg, -1) {
|
||||
summary.RectCount++
|
||||
attrs := svgNumericAttrs(tag)
|
||||
areaBP := creativeAreaBP(attrs["width"], attrs["height"], canvasArea)
|
||||
isBackground := areaBP >= 9000 && attrs["x"] <= 1 && attrs["y"] <= 1
|
||||
if !isBackground && rectTagHasRoundedCorners(tag) && areaBP >= 300 {
|
||||
summary.RoundedCardCount++
|
||||
}
|
||||
if !isBackground && areaBP >= 1200 {
|
||||
summary.LargePanelCount++
|
||||
}
|
||||
if !isBackground && isDarkCreativeFill(rectCreativeAttr(tag, svgFillForCreativePattern)) {
|
||||
summary.DarkFillCount++
|
||||
}
|
||||
if !isBackground && rectCreativeAttr(tag, svgStrokeForCreativePattern) != "" {
|
||||
summary.StrokePanelCount++
|
||||
}
|
||||
}
|
||||
for _, tag := range svgImageTagPattern.FindAllString(svg, -1) {
|
||||
attrs := svgNumericAttrs(tag)
|
||||
areaBP := creativeAreaBP(attrs["width"], attrs["height"], canvasArea)
|
||||
summary.ImageAreaBP += areaBP
|
||||
if areaBP > summary.LargestImageAreaBP {
|
||||
summary.LargestImageAreaBP = areaBP
|
||||
}
|
||||
}
|
||||
if summary.ImageAreaBP > 10000 {
|
||||
summary.ImageAreaBP = 10000
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func isCardDominantSlide(summary shapeLanguageSummary) bool {
|
||||
if summary.TextBlockCount == 0 {
|
||||
return false
|
||||
}
|
||||
if summary.RoundedCardCount >= 3 && summary.ImageCount == 0 {
|
||||
return true
|
||||
}
|
||||
return summary.RoundedCardCount >= 2 && summary.LargePanelCount >= 2 && summary.ImageAreaBP < 2000
|
||||
}
|
||||
|
||||
func isDarkCardTemplateSlide(summary shapeLanguageSummary) bool {
|
||||
return summary.RoundedCardCount >= 2 && summary.DarkFillCount >= 2 && summary.TextBlockCount > 0
|
||||
}
|
||||
|
||||
func isDecorativeImageOnlySlide(svg string, receipt visualReceipt) bool {
|
||||
if countSVGImageElements(svg) == 0 {
|
||||
return false
|
||||
}
|
||||
summary := analyzeShapeLanguage(svg)
|
||||
if summary.LargestImageAreaBP >= 1000 {
|
||||
return false
|
||||
}
|
||||
return containsAny(strings.ToLower(strings.Join([]string{receipt.AssetRole, receipt.CompositionIntent}, " ")), []string{"decorative", "ornament", "texture", "background", "装饰", "纹理"})
|
||||
}
|
||||
|
||||
func hasStrongCoverVisualImpact(svg string, receipt visualReceipt) bool {
|
||||
if !receiptRequiresStrongCoverVisual(receipt) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(receipt.PrimaryAsset+receipt.AssetRole) == "" {
|
||||
return true
|
||||
}
|
||||
if hasFullBleedImage(svg) {
|
||||
return true
|
||||
}
|
||||
summary := analyzeShapeLanguage(svg)
|
||||
return summary.LargestImageAreaBP >= 4500
|
||||
}
|
||||
|
||||
func receiptRequiresStrongCoverVisual(receipt visualReceipt) bool {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
receipt.LayoutFamily,
|
||||
receipt.LayoutArchetype,
|
||||
receipt.LayoutSignature,
|
||||
receipt.ShapeLanguage,
|
||||
receipt.ContainerDecision,
|
||||
receipt.CompositionIntent,
|
||||
receipt.AssetRole,
|
||||
strings.Join(receipt.QAExpectations, " "),
|
||||
}, " "))
|
||||
return containsAny(haystack, []string{
|
||||
"full_bleed", "full-bleed", "hero_cover", "cover hero", "strong cover", "strong visual",
|
||||
"主视觉", "强视觉", "封面大图", "全屏图", "封面主视觉",
|
||||
})
|
||||
}
|
||||
|
||||
func classifyTextCarrier(svg string, receipt visualReceipt) textCarrierKind {
|
||||
if carrier := parseReceiptTextCarrier(receipt.TextCarrier); carrier != "" {
|
||||
return carrier
|
||||
}
|
||||
shape := analyzeShapeLanguage(svg)
|
||||
haystack := strings.ToLower(strings.Join([]string{receipt.ContainerDecision, receipt.CompositionIntent, receipt.LayoutFamily, receipt.LayoutArchetype, receipt.LayoutSignature}, " "))
|
||||
switch {
|
||||
case containsAny(haystack, []string{"axis", "annotation", "坐标"}):
|
||||
return textCarrierAxisAnnotation
|
||||
case containsAny(haystack, []string{"line annotation", "callout", "rule", "标注", "引线"}):
|
||||
return textCarrierLineAnnotation
|
||||
case containsAny(haystack, []string{"metric", "scoreboard", "kpi", "指标"}):
|
||||
return textCarrierMetricPanel
|
||||
case shape.RoundedCardCount > 0 && shape.TextBlockCount > 0:
|
||||
return textCarrierCardGroup
|
||||
case shape.ImageCount > 0 && shape.DarkFillCount > 0:
|
||||
return textCarrierImageDarkZone
|
||||
default:
|
||||
return textCarrierOpenGrid
|
||||
}
|
||||
}
|
||||
|
||||
func parseReceiptTextCarrier(value string) textCarrierKind {
|
||||
switch textCarrierKind(strings.TrimSpace(value)) {
|
||||
case textCarrierOpenGrid, textCarrierImageDarkZone, textCarrierLineAnnotation, textCarrierAxisAnnotation, textCarrierCardGroup, textCarrierMetricPanel:
|
||||
return textCarrierKind(strings.TrimSpace(value))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isOpenTextCarrier(kind textCarrierKind) bool {
|
||||
switch kind {
|
||||
case textCarrierOpenGrid, textCarrierImageDarkZone, textCarrierLineAnnotation, textCarrierAxisAnnotation:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDefaultCardTextContainer(summary shapeLanguageSummary, carrier textCarrierKind, receipt visualReceipt) bool {
|
||||
if carrier != textCarrierCardGroup || summary.RoundedCardCount == 0 || summary.TextBlockCount == 0 {
|
||||
return false
|
||||
}
|
||||
if receiptJustifiesCards(receipt) {
|
||||
return false
|
||||
}
|
||||
if summary.ImageAreaBP >= 2500 || hasDataVisualIntent("", receipt.LayoutFamily, receipt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func receiptJustifiesCards(receipt visualReceipt) bool {
|
||||
if receipt.CardBudget.CardCount > 0 && strings.TrimSpace(receipt.CardBudget.WhyCardsAreNeeded) != "" {
|
||||
return true
|
||||
}
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
receipt.ContainerDecision,
|
||||
receipt.CompositionIntent,
|
||||
receipt.InformationDensityPlan,
|
||||
receipt.DataVisualRationale,
|
||||
receipt.AssetRole,
|
||||
}, " "))
|
||||
return containsAny(haystack, []string{
|
||||
"comparison", "compare", "metric", "kpi", "scoreboard", "quote", "control", "panel", "table", "chart", "group",
|
||||
"比较", "对比", "指标", "引用", "面板", "表格", "图表", "分组", "复杂背景", "background complexity",
|
||||
})
|
||||
}
|
||||
|
||||
func shapeLanguageSignature(summary shapeLanguageSummary, receipt visualReceipt) string {
|
||||
switch {
|
||||
case strings.TrimSpace(receipt.ShapeLanguage) != "":
|
||||
return strings.TrimSpace(receipt.ShapeLanguage)
|
||||
case summary.ImageAreaBP >= 4500:
|
||||
return "image_forward"
|
||||
case summary.RoundedCardCount >= 3:
|
||||
return "card_grid"
|
||||
case summary.RoundedCardCount > 0:
|
||||
return "card_text_panel"
|
||||
case hasDataVisualIntent("", receipt.LayoutFamily, receipt):
|
||||
return "chart_forward"
|
||||
case summary.StrokePanelCount > 0:
|
||||
return "rule_annotation"
|
||||
default:
|
||||
return "open_text"
|
||||
}
|
||||
}
|
||||
|
||||
func rectTagHasRoundedCorners(tag string) bool {
|
||||
return roundedRectPattern.MatchString(tag)
|
||||
}
|
||||
|
||||
func rectCreativeAttr(tag string, pattern *regexp.Regexp) string {
|
||||
match := pattern.FindStringSubmatch(tag)
|
||||
if len(match) != 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func creativeAreaBP(width float64, height float64, canvasArea float64) int {
|
||||
if width <= 0 || height <= 0 || canvasArea <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(width * height * 10000 / canvasArea)
|
||||
}
|
||||
|
||||
func isDarkCreativeFill(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || value == "none" || strings.HasPrefix(value, "url(") {
|
||||
return false
|
||||
}
|
||||
switch value {
|
||||
case "black", "#000", "#000000", "#111", "#111111", "#101010", "#101319":
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(value, "#") {
|
||||
hex := strings.TrimPrefix(value, "#")
|
||||
if len(hex) == 3 {
|
||||
hex = strings.Repeat(hex[0:1], 2) + strings.Repeat(hex[1:2], 2) + strings.Repeat(hex[2:3], 2)
|
||||
}
|
||||
if len(hex) != 6 {
|
||||
return false
|
||||
}
|
||||
r := parseHexByte(hex[0:2])
|
||||
g := parseHexByte(hex[2:4])
|
||||
b := parseHexByte(hex[4:6])
|
||||
return r+g+b < 180
|
||||
}
|
||||
return strings.Contains(value, "rgb(0") || strings.Contains(value, "rgb(16") || strings.Contains(value, "rgb(17")
|
||||
}
|
||||
|
||||
func parseHexByte(value string) int {
|
||||
out := 0
|
||||
for _, r := range value {
|
||||
out *= 16
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
out += int(r - '0')
|
||||
case r >= 'a' && r <= 'f':
|
||||
out += int(r-'a') + 10
|
||||
case r >= 'A' && r <= 'F':
|
||||
out += int(r-'A') + 10
|
||||
default:
|
||||
return 255
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasDataVisualIntent(svg string, layoutFamily string, receipt visualReceipt) bool {
|
||||
if strings.TrimSpace(layoutFamily) == "data_scoreboard" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(receipt.DataVisualRationale) != "" {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(svg)
|
||||
for _, marker := range []string{"chart", "axis", "bar", "line-chart", "vega", "data-score", "scoreboard"} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasNumericSourceEvidence(receipt visualReceipt) bool {
|
||||
if containsDigit(receipt.DataVisualRationale) {
|
||||
return true
|
||||
}
|
||||
for _, value := range receipt.SourceEvidence {
|
||||
if containsDigit(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsDigit(value string) bool {
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeCreativeQualityReport(safeRoot string, report CreativeQualityReport) error {
|
||||
return writeJSON(filepath.Join(safeRoot, creativeQualityReportPath), report)
|
||||
}
|
||||
268
internal/svglide/creative_quality_test.go
Normal file
268
internal/svglide/creative_quality_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckCreativeQualityRejectsMissingVisualReceipts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
|
||||
}
|
||||
if !creativeIssueCodesContain(report.Issues, "svglide.creative.missing_visual_receipts") {
|
||||
t.Fatalf("Issues = %+v, want missing_visual_receipts", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityWarnModeDowngradesHardFailures(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setRunVisualQualityModeForTest(t, VisualQualityModeWarn)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "single_claim_poster", creativeQualityGoodSVG())
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed in warn mode; report=%+v", report.Status, report)
|
||||
}
|
||||
if len(report.Issues) == 0 || report.Issues[0].Severity != "warning" {
|
||||
t.Fatalf("Issues = %+v, want warning issues", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsProcessLeakAndWeakTextBoxStack(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "card_stack", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540"/><rect rx="12" x="40" y="40" width="220" height="100"/><rect rx="12" x="300" y="40" width="220" height="100"/><rect rx="12" x="560" y="40" width="220" height="100"/><text x="48" y="90">接缝取色说明</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"card_stack","thumbnail_job":"cards","visual_center":"","topic_fit_claim":"","information_density_plan":"same","page_difference_from_previous":"same","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"stacked cards","data_visual_rationale":"","source_evidence":["web1"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
|
||||
}
|
||||
for _, code := range []string{"svglide.creative.process_leak", "svglide.creative.weak_slide"} {
|
||||
if !creativeIssueCodesContain(report.Issues, code) {
|
||||
t.Fatalf("Issues = %+v, want %s", report.Issues, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsDataVisualWithoutNumericEvidence(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "data_scoreboard", "scoreboard", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540"/><g class="chart"><rect x="48" y="200" width="100" height="200"/></g><text x="48" y="80">Scoreboard</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"proof","layout_family":"data_scoreboard","layout_archetype":"data_scoreboard","layout_signature":"scoreboard","thumbnail_job":"score","visual_center":"score panel","topic_fit_claim":"shows data claim","information_density_plan":"one metric and one explanation","page_difference_from_previous":"first data page","primary_asset":"","asset_role":"data proof","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"data scoreboard","data_visual_rationale":"compare result shape","source_evidence":["match report"],"fusion_spec":{"enabled":false},"qa_expectations":["numeric evidence required"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || !creativeIssueCodesContain(report.Issues, "svglide.creative.chart_without_evidence") {
|
||||
t.Fatalf("report = %+v, want chart_without_evidence failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeQualityDetectsDefaultCardTextContainer(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "quiet_synthesis", "editorial_text", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#f7f6f1"/><rect x="64" y="70" width="360" height="320" rx="24" fill="#101319"/><text x="96" y="140" fill="#fff">Athlete story</text><text x="96" y="202" fill="#fff">Every major claim is simply placed inside a rounded card.</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"single_claim_poster","layout_signature":"editorial_text","thumbnail_job":"text card","visual_center":"main text block","topic_fit_claim":"introduces the sports topic","information_density_plan":"one main claim and supporting explanation","page_difference_from_previous":"opening page with a text-led composition","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"plain text card for a simple claim","data_visual_rationale":"","source_evidence":["official athlete bio"],"fusion_spec":{"enabled":false},"qa_expectations":["use open editorial text when no panel is needed"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || report.Metrics.DefaultCardTextContainerCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.creative.default_card_text_container") {
|
||||
t.Fatalf("report = %+v, want default_card_text_container failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeQualityDetectsTopicTypographyMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteCreativeQualityBaseDeck(t, "character_product_focus", "sports_profile", creativeQualityGoodSVG())
|
||||
mustWriteTestFile(t, "demo/brief/typography_contract.json", `{"profile":"sports_editorial","roles":{"display":{"family":"Noto Serif CJK SC","weight":"700","size":"42","usage":"cover title"},"body":{"family":"Noto Sans CJK SC","weight":"400","size":"18","usage":"body copy"},"number":{"family":"Roboto Mono","weight":"700","size":"34","usage":"scores"},"label":{"family":"PingFang SC","weight":"600","size":"13","usage":"labels"}},"rules":["sports deck typography should carry athletic score identity"]}`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"character_product_focus","layout_archetype":"annotated_image","layout_signature":"sports_profile","thumbnail_job":"sports profile","visual_center":"athlete profile and opening claim","topic_fit_claim":"matches the sports profile topic","information_density_plan":"one claim plus athlete context","page_difference_from_previous":"opening page","primary_asset":"assets/images/athlete.png","asset_role":"sports topic anchor","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"sports editorial profile","data_visual_rationale":"","source_evidence":["league profile"],"fusion_spec":{"enabled":false},"qa_expectations":["typography carries sports identity"]}]}`)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" || report.Metrics.TopicTypographyMismatchCount != 1 || !creativeIssueCodesContain(report.Issues, "svglide.typography.identity.profile_mismatch") {
|
||||
t.Fatalf("report = %+v, want typography profile mismatch failure", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCreativeQualityRejectsRepeatedLayoutArchetype(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteRepeatedArchetypeDeck(t)
|
||||
mustWriteRepeatedArchetypeReceipts(t)
|
||||
|
||||
report, err := CheckCreativeQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed; report=%+v", report.Status, report)
|
||||
}
|
||||
for _, code := range []string{
|
||||
"svglide.creative.layout_archetype_overuse",
|
||||
"svglide.creative.adjacent_layout_archetype",
|
||||
"svglide.creative.left_right_chart_overuse",
|
||||
} {
|
||||
if !creativeIssueCodesContain(report.Issues, code) {
|
||||
t.Fatalf("Issues = %+v, want %s", report.Issues, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreativeQualityVisualFixtures(t *testing.T) {
|
||||
t.Chdir(filepath.Join("..", ".."))
|
||||
base := filepath.Join("testdata", "svglide", "visual_quality")
|
||||
weak, err := CheckCreativeQuality(filepath.Join(base, "germany_2026_weak_visual_run"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if weak.Status != "failed" || !creativeIssueCodesContain(weak.Issues, "svglide.creative.weak_slide") || !creativeIssueCodesContain(weak.Issues, "svglide.creative.process_leak") {
|
||||
t.Fatalf("weak fixture report = %+v, want weak/process failure", weak)
|
||||
}
|
||||
good, err := CheckCreativeQuality(filepath.Join(base, "fusion_split_good_run"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if good.Status != "passed" {
|
||||
t.Fatalf("fusion fixture report = %+v, want passed", good)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteCreativeQualityBaseDeck(t *testing.T, family, signature, svg string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Creative Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Opening key","layout_family":"`+family+`","layout_archetype":"`+inferAuthorLayoutArchetype(family, signature)+`","layout_signature":"`+signature+`","story_function":"hook","primary_asset_role":"topic anchor","fusion_candidate":false,"path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", svg)
|
||||
}
|
||||
|
||||
func mustWriteRepeatedArchetypeDeck(t *testing.T) {
|
||||
t.Helper()
|
||||
deck := authorDeck{Title: "Financial Deck"}
|
||||
deck.Slides = append(deck.Slides,
|
||||
authorDeckSlide{ID: "s1", Title: "Cover", Summary: "Cover", Role: "cover", KeyMessage: "Cover", LayoutFamily: "full_bleed_hero", LayoutArchetype: "full_bleed_photo_title", LayoutSignature: "chip_cover", StoryFunction: "hook", PrimaryAssetRole: "hero image", Path: "slides/01.svg"},
|
||||
)
|
||||
for i, title := range []string{"Executive summary", "Income", "Segment", "Margin", "Cash flow"} {
|
||||
page := i + 2
|
||||
deck.Slides = append(deck.Slides, authorDeckSlide{
|
||||
ID: fmt.Sprintf("s%d", page),
|
||||
Title: title,
|
||||
Summary: title,
|
||||
Role: "content",
|
||||
KeyMessage: title,
|
||||
LayoutFamily: "data_scoreboard",
|
||||
LayoutArchetype: "image_argument_split",
|
||||
LayoutSignature: fmt.Sprintf("left_text_right_chart_%d", page),
|
||||
StoryFunction: "proof",
|
||||
PrimaryAssetRole: "chart",
|
||||
Path: fmt.Sprintf("slides/%02d.svg", page),
|
||||
})
|
||||
}
|
||||
deck.Slides = append(deck.Slides,
|
||||
authorDeckSlide{ID: "s7", Title: "Close", Summary: "Close", Role: "close", KeyMessage: "Close", LayoutFamily: "quiet_synthesis", LayoutArchetype: "closing_poster", LayoutSignature: "closing_poster", StoryFunction: "synthesis", PrimaryAssetRole: "closing", Path: "slides/07.svg"},
|
||||
)
|
||||
raw, err := json.Marshal(deck)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", string(raw))
|
||||
for i := 1; i <= 7; i++ {
|
||||
body := `<rect width="960" height="540"/><text x="48" y="80">NVIDIA financial report</text>`
|
||||
if i >= 2 && i <= 6 {
|
||||
body += `<g class="chart"><rect x="600" y="160" width="220" height="160"/></g>`
|
||||
}
|
||||
mustWriteTestFile(t, fmt.Sprintf("demo/slides/%02d.svg", i), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+body+`</svg>`)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteRepeatedArchetypeReceipts(t *testing.T) {
|
||||
t.Helper()
|
||||
receipts := visualReceiptsFile{}
|
||||
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt("s1", "full_bleed_hero", "full_bleed_photo_title", "chip_cover", "cover", "NVIDIA image"))
|
||||
for i, label := range []string{"revenue $22.1B", "net income $12.3B", "data center $18.4B", "gross margin 76.0%", "free cash flow $11.2B"} {
|
||||
page := i + 2
|
||||
receipt := repeatedArchetypeReceipt(
|
||||
fmt.Sprintf("s%d", page),
|
||||
"data_scoreboard",
|
||||
"image_argument_split",
|
||||
fmt.Sprintf("left_text_right_chart_%d", page),
|
||||
"left text right chart",
|
||||
label,
|
||||
)
|
||||
receipt.DataVisualRationale = label
|
||||
receipts.Slides = append(receipts.Slides, receipt)
|
||||
}
|
||||
receipts.Slides = append(receipts.Slides, repeatedArchetypeReceipt("s7", "quiet_synthesis", "closing_poster", "closing_poster", "closing", "NVIDIA report"))
|
||||
raw, err := json.Marshal(receipts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", string(raw))
|
||||
}
|
||||
|
||||
func repeatedArchetypeReceipt(slideID string, family string, archetype string, signature string, intent string, evidence string) visualReceipt {
|
||||
return visualReceipt{
|
||||
SlideID: slideID,
|
||||
StoryJob: "proof",
|
||||
LayoutFamily: family,
|
||||
LayoutArchetype: archetype,
|
||||
LayoutSignature: signature,
|
||||
ThumbnailJob: "thumbnail",
|
||||
VisualCenter: "visual center",
|
||||
TopicFitClaim: "topic fit",
|
||||
InformationDensityPlan: "one claim plus supporting visual",
|
||||
PageDifferenceFromPrevious: "different named page in sequence",
|
||||
PrimaryAsset: "chart.svg",
|
||||
AssetRole: "chart",
|
||||
FontRoleUsage: map[string]string{"display": "Inter", "body": "Aptos", "number": "Roboto Mono", "label": "Inter"},
|
||||
CompositionIntent: intent,
|
||||
SourceEvidence: []string{evidence},
|
||||
FusionSpec: visualFusionReceipt{Enabled: false},
|
||||
QAExpectations: []string{"vary layout"},
|
||||
}
|
||||
}
|
||||
|
||||
func creativeQualityGoodSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540"/><text x="48" y="80">Opening</text><text x="48" y="132">A focused claim</text></svg>`
|
||||
}
|
||||
|
||||
func setRunVisualQualityModeForTest(t *testing.T, mode string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run.VisualQualityMode = mode
|
||||
if err := writeJSON(filepath.Join("demo", "run.json"), run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func creativeIssueCodesContain(issues []CreativeQualityIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want || strings.Contains(issue.Code, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
212
internal/svglide/image_candidates.go
Normal file
212
internal/svglide/image_candidates.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const imageCandidatesPath = "assets/image_candidates.json"
|
||||
|
||||
type imageCandidatesFile struct {
|
||||
RequiresRealImages bool `json:"requires_real_images"`
|
||||
NoImageReason string `json:"no_image_reason"`
|
||||
Candidates []imageCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
type imageCandidate struct {
|
||||
ID string `json:"id"`
|
||||
Query string `json:"query"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SourceClass string `json:"source_class"`
|
||||
Format string `json:"format"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
HasAlpha bool `json:"has_alpha"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FitRole string `json:"fit_role"`
|
||||
LocalPath string `json:"local_path"`
|
||||
ScoreBP int `json:"score_bp"`
|
||||
Selected bool `json:"selected"`
|
||||
SelectionReason string `json:"selection_reason"`
|
||||
FormatExceptionReason string `json:"format_exception_reason"`
|
||||
RejectionReason string `json:"rejection_reason"`
|
||||
}
|
||||
|
||||
func readImageCandidates(safeRoot string) (imageCandidatesFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, imageCandidatesPath)
|
||||
if err != nil {
|
||||
return imageCandidatesFile{}, fmt.Errorf("read image candidates %q: %w", imageCandidatesPath, err)
|
||||
}
|
||||
var file imageCandidatesFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return imageCandidatesFile{}, fmt.Errorf("%s: invalid JSON: %w", imageCandidatesPath, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func ValidateImageCandidatesGate(safeRoot string) error {
|
||||
manifest, err := readAssetsManifest(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventory, err := readAssetInventory(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates, err := readImageCandidates(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasRasterAsset := false
|
||||
for _, asset := range manifest.Assets {
|
||||
if isRasterImageAsset(asset) {
|
||||
hasRasterAsset = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !candidates.RequiresRealImages && len(candidates.Candidates) == 0 {
|
||||
if strings.TrimSpace(candidates.NoImageReason) == "" {
|
||||
return fmt.Errorf("image_candidates_gate: no real image candidates; set no_image_reason when requires_real_images=false")
|
||||
}
|
||||
if hasRasterAsset {
|
||||
return fmt.Errorf("image_candidates_gate: ready raster image assets require selected candidates even when requires_real_images=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if candidates.RequiresRealImages {
|
||||
if err := validateImageCandidateSearchBreadth(candidates); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
selectedByID := selectedImageCandidatesByID(candidates)
|
||||
selectedByPath := selectedImageCandidatesByLocalPath(candidates)
|
||||
inventoryByPath := inventoryItemByPath(inventory)
|
||||
for _, asset := range manifest.Assets {
|
||||
if !isRasterImageAsset(asset) {
|
||||
continue
|
||||
}
|
||||
path := assetPath(asset)
|
||||
item, ok := inventoryByPath[path]
|
||||
if !ok {
|
||||
return fmt.Errorf("image_candidates_gate: ready image asset %q path %q has no asset_inventory entry", assetID(asset), path)
|
||||
}
|
||||
candidate, ok := selectedCandidateForInventoryItem(item, selectedByID, selectedByPath)
|
||||
if !ok {
|
||||
return fmt.Errorf("image_candidates_gate: ready image asset %q path %q has no selected candidate", assetID(asset), path)
|
||||
}
|
||||
if strings.TrimSpace(item.CandidateID) != "" && strings.TrimSpace(candidate.LocalPath) != "" && strings.TrimSpace(candidate.LocalPath) != path {
|
||||
return fmt.Errorf("image_candidates_gate: asset %q candidate_id %q points to local_path %q, want %q", assetID(asset), item.CandidateID, candidate.LocalPath, path)
|
||||
}
|
||||
if strings.TrimSpace(candidate.SourceURL) == "" {
|
||||
return fmt.Errorf("image_candidates_gate: selected candidate for asset %q has empty source_url", assetID(asset))
|
||||
}
|
||||
if strings.TrimSpace(candidate.SelectionReason) == "" {
|
||||
return fmt.Errorf("image_candidates_gate: selected candidate for asset %q has empty selection_reason", assetID(asset))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectedImageCandidatesByID(file imageCandidatesFile) map[string]imageCandidate {
|
||||
out := make(map[string]imageCandidate)
|
||||
for _, candidate := range file.Candidates {
|
||||
if !candidate.Selected {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(candidate.ID)
|
||||
if id != "" {
|
||||
out[id] = candidate
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedImageCandidatesByLocalPath(file imageCandidatesFile) map[string]imageCandidate {
|
||||
out := make(map[string]imageCandidate)
|
||||
for _, candidate := range file.Candidates {
|
||||
if !candidate.Selected {
|
||||
continue
|
||||
}
|
||||
localPath := strings.TrimSpace(candidate.LocalPath)
|
||||
if localPath != "" {
|
||||
out[localPath] = candidate
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedCandidateForInventoryItem(item assetInventoryItem, byID, byPath map[string]imageCandidate) (imageCandidate, bool) {
|
||||
candidateID := strings.TrimSpace(item.CandidateID)
|
||||
if candidateID != "" {
|
||||
candidate, ok := byID[candidateID]
|
||||
return candidate, ok
|
||||
}
|
||||
candidate, ok := byPath[strings.TrimSpace(item.Path)]
|
||||
return candidate, ok
|
||||
}
|
||||
|
||||
func validateImageCandidateSearchBreadth(file imageCandidatesFile) error {
|
||||
selectedCount := 0
|
||||
coverHeroCandidates := 0
|
||||
selectedCoverHeroFromUser := false
|
||||
roleCandidateCount := map[string]int{}
|
||||
selectedImportantRoles := map[string]bool{}
|
||||
userProvidedImportantRoles := map[string]bool{}
|
||||
for _, candidate := range file.Candidates {
|
||||
role := strings.TrimSpace(candidate.AssetRole)
|
||||
if role != "" {
|
||||
roleCandidateCount[role]++
|
||||
}
|
||||
if role == "hero_photo" && strings.TrimSpace(candidate.FitRole) == "full_bleed" {
|
||||
coverHeroCandidates++
|
||||
if candidate.Selected && strings.TrimSpace(candidate.SourceClass) == "user_provided" {
|
||||
selectedCoverHeroFromUser = true
|
||||
}
|
||||
}
|
||||
if candidate.Selected {
|
||||
selectedCount++
|
||||
if isImportantImageRole(role) {
|
||||
selectedImportantRoles[role] = true
|
||||
if strings.TrimSpace(candidate.SourceClass) == "user_provided" {
|
||||
userProvidedImportantRoles[role] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if selectedCount == 0 {
|
||||
return fmt.Errorf("image_candidates_gate: requires_real_images=true but no selected image candidate exists")
|
||||
}
|
||||
if coverHeroCandidates > 0 && coverHeroCandidates < 3 && !selectedCoverHeroFromUser {
|
||||
return fmt.Errorf("image_candidates_gate: cover hero search needs at least 3 candidates, got %d", coverHeroCandidates)
|
||||
}
|
||||
for role := range selectedImportantRoles {
|
||||
if userProvidedImportantRoles[role] {
|
||||
continue
|
||||
}
|
||||
if roleCandidateCount[role] < 2 {
|
||||
return fmt.Errorf("image_candidates_gate: role %q needs at least 2 candidates, got %d", role, roleCandidateCount[role])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isImportantImageRole(role string) bool {
|
||||
switch role {
|
||||
case "hero_photo", "scene_photo", "factory_photo", "store_photo", "people_photo", "transparent_subject", "floating_product", "logo", "chip_device", "ui_screenshot", "product_screen":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func inventoryItemByPath(inventory assetInventoryFile) map[string]assetInventoryItem {
|
||||
out := make(map[string]assetInventoryItem)
|
||||
for _, item := range inventory.Items {
|
||||
if path := strings.TrimSpace(item.Path); path != "" {
|
||||
out[path] = item
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
53
internal/svglide/image_candidates_test.go
Normal file
53
internal/svglide/image_candidates_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateImageCandidatesGateRejectsReadyImageWithoutSelectedCandidate(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected ready image without selected candidate to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "image_candidates_gate") {
|
||||
t.Fatalf("error = %v, want image_candidates_gate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateImageCandidatesGateRejectsCandidateIDPathMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","usage":"Hero photo","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[{"id":"c1","query":"brand hero photo","source_url":"https://example.com/other.jpg","source_class":"user_provided","format":"jpg","width":1600,"height":900,"has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","local_path":"assets/images/other.jpg","score_bp":9200,"selected":true,"selection_reason":"user-provided high-resolution hero photo","format_exception_reason":"","rejection_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","candidate_id":"c1","selection_reason":"user-provided high-resolution hero photo"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil || !strings.Contains(err.Error(), "candidate_id") {
|
||||
t.Fatalf("expected candidate_id/path mismatch failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateImageCandidatesGateAllowsExplicitNoImageDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"no_image_reason":"chart-only deck; no real raster image required","candidates":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
|
||||
mustWriteNoChartAssetsForTest(t)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("explicit no-image deck rejected: %v", err)
|
||||
}
|
||||
}
|
||||
201
internal/svglide/image_usage.go
Normal file
201
internal/svglide/image_usage.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const imageUsageReportPath = "receipts/image_usage.json"
|
||||
|
||||
type ImageUsageReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []ImageUsageSlide `json:"slides"`
|
||||
Issues []ImageUsageIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ImageUsageSlide struct {
|
||||
SlideID string `json:"slide_id"`
|
||||
Assets []ImageUsageAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type ImageUsageAsset struct {
|
||||
AssetID string `json:"asset_id"`
|
||||
Path string `json:"path"`
|
||||
Href string `json:"href"`
|
||||
AssetRole string `json:"asset_role"`
|
||||
FitRole string `json:"fit_role"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
AreaBP int `json:"area_bp"`
|
||||
UsageStatus string `json:"usage_status"`
|
||||
}
|
||||
|
||||
type ImageUsageIssue struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func EvaluateImageUsageRun(safeRoot string, deck authorDeck, manifest deckAssetsFile, inventory assetInventoryFile) ImageUsageReport {
|
||||
report := ImageUsageReport{Status: "passed", Slides: []ImageUsageSlide{}, Issues: []ImageUsageIssue{}}
|
||||
inventoryByPath := inventoryItemByPath(inventory)
|
||||
readyAssetsByPath := readyAssetsByPath(manifest)
|
||||
usageByPath := map[string]ImageUsageAsset{}
|
||||
for _, slide := range deck.Slides {
|
||||
slideUsage := ImageUsageSlide{SlideID: strings.TrimSpace(slide.ID), Assets: []ImageUsageAsset{}}
|
||||
raw, err := readRunRegularArtifact(safeRoot, strings.TrimSpace(slide.Path))
|
||||
if err != nil {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.image_usage.read_slide", Path: slide.Path, Message: err.Error()})
|
||||
report.Slides = append(report.Slides, slideUsage)
|
||||
continue
|
||||
}
|
||||
usages, issues := extractSlideImageUsages(slide.Path, raw, readyAssetsByPath, inventoryByPath)
|
||||
if len(issues) > 0 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, issues...)
|
||||
}
|
||||
for _, usage := range usages {
|
||||
usageByPath[usage.Path] = usage
|
||||
slideUsage.Assets = append(slideUsage.Assets, usage)
|
||||
}
|
||||
report.Slides = append(report.Slides, slideUsage)
|
||||
}
|
||||
for _, asset := range manifest.Assets {
|
||||
if !isRasterImageAsset(asset) {
|
||||
continue
|
||||
}
|
||||
path := assetPath(asset)
|
||||
item, ok := inventoryByPath[path]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
usage, used := usageByPath[path]
|
||||
if !used {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.quality.image_usage_missing", Path: path, Message: fmt.Sprintf("ready image asset %q is not referenced by any slide SVG", assetID(asset))})
|
||||
continue
|
||||
}
|
||||
if item.FitRole == "full_bleed" && usage.AreaBP < 4500 {
|
||||
report.Status = "failed"
|
||||
report.Issues = append(report.Issues, ImageUsageIssue{Code: "svglide.quality.image_usage_area", Path: path, Message: fmt.Sprintf("asset %q fit_role=full_bleed but SVG area is only %d bp", assetID(asset), usage.AreaBP)})
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func writeImageUsageReport(safeRoot string, report ImageUsageReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, imageUsageReportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func extractSlideImageUsages(slidePath string, raw []byte, readyAssetsByPath map[string]deckAsset, inventoryByPath map[string]assetInventoryItem) ([]ImageUsageAsset, []ImageUsageIssue) {
|
||||
out := []ImageUsageAsset{}
|
||||
issues := []ImageUsageIssue{}
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(raw)))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "image" {
|
||||
continue
|
||||
}
|
||||
attrs := parseSVGAttrs(start.Attr)
|
||||
href := attrs["href"]
|
||||
normalized := normalizeSlideAssetHref(slidePath, href)
|
||||
asset, assetOK := readyAssetsByPath[normalized]
|
||||
if !assetOK {
|
||||
issues = append(issues, ImageUsageIssue{
|
||||
Code: "svglide.quality.image_usage_unregistered",
|
||||
Path: strings.TrimSpace(slidePath),
|
||||
Message: fmt.Sprintf("slide SVG image href %q resolves to %q, which is not registered as a ready asset", href, normalized),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if !isRasterImageAsset(asset) {
|
||||
continue
|
||||
}
|
||||
item, ok := inventoryByPath[normalized]
|
||||
if !ok {
|
||||
issues = append(issues, ImageUsageIssue{
|
||||
Code: "svglide.quality.image_usage_missing_inventory",
|
||||
Path: normalized,
|
||||
Message: fmt.Sprintf("ready image asset %q is referenced by SVG but missing from asset_inventory", assetID(asset)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
width := parseImageUsageFloatAttr(attrs["width"])
|
||||
height := parseImageUsageFloatAttr(attrs["height"])
|
||||
out = append(out, ImageUsageAsset{
|
||||
AssetID: item.ID,
|
||||
Path: normalized,
|
||||
Href: href,
|
||||
AssetRole: item.AssetRole,
|
||||
FitRole: item.FitRole,
|
||||
X: parseImageUsageFloatAttr(attrs["x"]),
|
||||
Y: parseImageUsageFloatAttr(attrs["y"]),
|
||||
Width: width,
|
||||
Height: height,
|
||||
AreaBP: areaBP(width, height),
|
||||
UsageStatus: "matched",
|
||||
})
|
||||
}
|
||||
return out, issues
|
||||
}
|
||||
|
||||
func parseSVGAttrs(attrs []xml.Attr) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, attr := range attrs {
|
||||
out[attr.Name.Local] = attr.Value
|
||||
if attr.Name.Space != "" {
|
||||
out[attr.Name.Space+":"+attr.Name.Local] = attr.Value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSlideAssetHref(slidePath, href string) string {
|
||||
href = strings.TrimSpace(href)
|
||||
if href == "" || strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") || filepath.IsAbs(href) {
|
||||
return strings.TrimPrefix(filepath.ToSlash(href), "./")
|
||||
}
|
||||
base := filepath.Dir(filepath.ToSlash(slidePath))
|
||||
normalized := filepath.Clean(filepath.Join(base, href))
|
||||
return strings.TrimPrefix(filepath.ToSlash(normalized), "./")
|
||||
}
|
||||
|
||||
func parseImageUsageFloatAttr(raw string) float64 {
|
||||
raw = strings.TrimSpace(strings.TrimSuffix(raw, "px"))
|
||||
value, _ := strconv.ParseFloat(raw, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func areaBP(width, height float64) int {
|
||||
if width <= 0 || height <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(width * height * 10000 / (1280 * 720))
|
||||
}
|
||||
|
||||
func readyAssetsByPath(manifest deckAssetsFile) map[string]deckAsset {
|
||||
out := map[string]deckAsset{}
|
||||
for _, asset := range manifest.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
if path := strings.TrimSpace(assetPath(asset)); path != "" {
|
||||
out[path] = asset
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
97
internal/svglide/image_usage_test.go
Normal file
97
internal/svglide/image_usage_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestImageUsageRejectsSelectedImageNotReferencedBySVG(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><text x="80" y="120">No image</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing") {
|
||||
t.Fatalf("expected selected image missing from SVG to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageRejectsFullBleedHeroUsedAsThumbnail(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="900" y="500" width="180" height="100"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_area") {
|
||||
t.Fatalf("expected full-bleed hero thumbnail usage to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageParsesSingleQuotedAndXLinkHrefImage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" xlink:href='../assets/images/hero.jpg' x='0' y='0' width='1280' height='720'/><text x="80" y="120">Cover</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"official high-resolution hero photo"}]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing") {
|
||||
t.Fatalf("xlink:href image should be detected as used: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageRejectsUnregisteredSVGImageHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/temporary.jpg" x="0" y="0" width="1280" height="720"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_unregistered") {
|
||||
t.Fatalf("expected unregistered SVG image href to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUsageRejectsReferencedReadyImageMissingInventory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><image slide:role="image" slide:shape-type="image" href="../assets/images/hero.jpg" x="0" y="0" width="1280" height="720"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_usage_missing_inventory") {
|
||||
t.Fatalf("expected referenced ready image missing inventory to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func writeMinimalImageQualityDeckForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.jpg" x="0" y="0" width="1280" height="720"/><text x="80" y="120">Cover</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "full_bleed_hero", "full_bleed_photo_title")
|
||||
}
|
||||
226
internal/svglide/init.go
Normal file
226
internal/svglide/init.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type InitOptions struct {
|
||||
Title string
|
||||
Input string
|
||||
Topic string
|
||||
Language string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Now time.Time
|
||||
Overwrite bool
|
||||
AgentRuntime string
|
||||
AgentID string
|
||||
RouteProfile string
|
||||
}
|
||||
|
||||
func InitRun(root string, opts InitOptions) error {
|
||||
root = strings.TrimSpace(root)
|
||||
opts.Title = strings.TrimSpace(opts.Title)
|
||||
opts.Input = strings.TrimSpace(opts.Input)
|
||||
opts.Topic = strings.TrimSpace(opts.Topic)
|
||||
opts.Language = strings.TrimSpace(opts.Language)
|
||||
opts.AgentRuntime = strings.TrimSpace(opts.AgentRuntime)
|
||||
opts.AgentID = strings.TrimSpace(opts.AgentID)
|
||||
opts.RouteProfile = strings.TrimSpace(opts.RouteProfile)
|
||||
if root == "" {
|
||||
return fmt.Errorf("out path is required")
|
||||
}
|
||||
if opts.Title == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if (opts.Input == "") == (opts.Topic == "") {
|
||||
return fmt.Errorf("exactly one of input or topic is required")
|
||||
}
|
||||
safeRoot, err := validate.SafeOutputPath(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRunRoot(root, safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Input != "" {
|
||||
safeInput, err := validate.SafeInputPath(opts.Input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInputOutsideRunRoot(safeRoot, safeInput); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Input = safeInput
|
||||
}
|
||||
|
||||
if opts.Overwrite {
|
||||
return initOverwrite(safeRoot, opts)
|
||||
}
|
||||
|
||||
return initNoReplace(safeRoot, opts)
|
||||
}
|
||||
|
||||
func validateRunRoot(root string, safeRoot string) error {
|
||||
if filepath.Clean(root) == "." {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
canonicalCwd, err := vfs.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve working directory: %w", err)
|
||||
}
|
||||
if filepath.Clean(safeRoot) == filepath.Clean(canonicalCwd) {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputOutsideRunRoot(safeRoot string, safeInput string) error {
|
||||
root := filepath.Clean(safeRoot)
|
||||
input := filepath.Clean(safeInput)
|
||||
rel, err := filepath.Rel(root, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot compare input and output paths: %w", err)
|
||||
}
|
||||
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
|
||||
return fmt.Errorf("input path %q must be outside output run directory %q", safeInput, safeRoot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initNoReplace(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("%s already exists or cannot be created; refusing to overwrite: %w", safeRoot, err)
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func initOverwrite(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.RemoveAll(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func writeClaimedRunDirectory(safeRoot string, opts InitOptions) error {
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = vfs.RemoveAll(safeRoot)
|
||||
}
|
||||
}()
|
||||
if err := writeRunDirectory(safeRoot, safeRoot, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRunDirectory(writeRoot string, runRoot string, opts InitOptions) error {
|
||||
for _, dir := range []string{
|
||||
"request",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"assets/images",
|
||||
"slides",
|
||||
"schemas",
|
||||
"receipts",
|
||||
} {
|
||||
if err := vfs.MkdirAll(filepath.Join(writeRoot, dir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: opts.Title,
|
||||
Input: opts.Input,
|
||||
Topic: opts.Topic,
|
||||
Language: opts.Language,
|
||||
Audience: opts.Audience,
|
||||
DeliveryMode: opts.DeliveryMode,
|
||||
Pages: opts.Pages,
|
||||
Out: runRoot,
|
||||
Now: opts.Now,
|
||||
AgentRuntime: opts.AgentRuntime,
|
||||
AgentID: opts.AgentID,
|
||||
RouteProfile: opts.RouteProfile,
|
||||
})
|
||||
run.Policy.Overwrite = opts.Overwrite
|
||||
if err := writeJSON(filepath.Join(writeRoot, "run.json"), run); err != nil {
|
||||
return err
|
||||
}
|
||||
request := map[string]any{
|
||||
"title": opts.Title,
|
||||
"audience": opts.Audience,
|
||||
"delivery_mode": opts.DeliveryMode,
|
||||
"pages": opts.Pages,
|
||||
"intent": run.Intent,
|
||||
"agent": run.Agent,
|
||||
}
|
||||
if opts.Input != "" {
|
||||
request["input"] = opts.Input
|
||||
}
|
||||
if opts.Topic != "" {
|
||||
request["topic"] = opts.Topic
|
||||
}
|
||||
if opts.Language != "" {
|
||||
request["language"] = opts.Language
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "request.json"), request); err != nil {
|
||||
return err
|
||||
}
|
||||
source := map[string]string{"type": "topic", "topic": opts.Topic}
|
||||
if opts.Input != "" {
|
||||
source = map[string]string{"path": opts.Input, "type": "local"}
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "source_manifest.json"), map[string]any{
|
||||
"sources": []map[string]string{source},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeStaticFiles(writeRoot)
|
||||
}
|
||||
|
||||
func writeStaticFiles(root string) error {
|
||||
if err := writeText(filepath.Join(root, "README.md"), renderRunREADME()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePromptManifest(root); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, schema := range DefaultSchemas() {
|
||||
if err := writeText(filepath.Join(root, "schemas", name), schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderRunREADME() string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Local Run\n\n")
|
||||
b.WriteString("This directory is a local agent-neutral SVG slides runtime. It does not publish to Feishu Slides.\n")
|
||||
return b.String()
|
||||
}
|
||||
536
internal/svglide/init_test.go
Normal file
536
internal/svglide/init_test.go
Normal file
@@ -0,0 +1,536 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitRunWritesDirectoryContract(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
canonicalCwd, err := filepath.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "demo"
|
||||
wantInput := filepath.Join(canonicalCwd, "source.md")
|
||||
err = InitRun(root, InitOptions{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Now: time.Date(2026, 7, 2, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"run.json",
|
||||
"README.md",
|
||||
"prompt_manifest.json",
|
||||
"request/request.json",
|
||||
"request/source_manifest.json",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"schemas/request.schema.json",
|
||||
"schemas/deck.schema.json",
|
||||
"receipts",
|
||||
"slides",
|
||||
"assets/images",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
|
||||
t.Fatalf("missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
if run.Input != wantInput {
|
||||
t.Fatalf("run.Input = %q, want %q", run.Input, wantInput)
|
||||
}
|
||||
|
||||
requestRaw, err := os.ReadFile(filepath.Join(root, "request", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(requestRaw, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request["title"] != "Demo" || request["input"] != wantInput || request["audience"] != "产品负责人" || request["delivery_mode"] != "self_read" || request["pages"] != float64(8) {
|
||||
t.Fatalf("unexpected request.json: %+v", request)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(root, "request", "source_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Sources []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest.Sources) != 1 || manifest.Sources[0].Path != wantInput || manifest.Sources[0].Type != "local" {
|
||||
t.Fatalf("unexpected source_manifest.json: %+v", manifest)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "prompts")); !os.IsNotExist(err) {
|
||||
t.Fatalf("prompts directory should not be generated per run, stat err = %v", err)
|
||||
}
|
||||
|
||||
promptRaw, err := os.ReadFile(filepath.Join(root, "prompt_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := string(promptRaw)
|
||||
for _, want := range []string{"mode_system_prompt_svg", "svg_reference", "tools/slides_edit.md", "tools/resolve_image_assets.md", "tools/generate_svg_chart.md", "tools/generate_vega_lite_chart.md"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt manifest missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
schemaRaw, err := os.ReadFile(filepath.Join(root, "schemas", "deck.schema.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deckSchema map[string]any
|
||||
if err := json.Unmarshal(schemaRaw, &deckSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := deckSchema["properties"]; !ok || !strings.Contains(string(schemaRaw), "key_message") {
|
||||
t.Fatalf("deck schema missing properties/key_message: %s", string(schemaRaw))
|
||||
}
|
||||
if !strings.Contains(string(schemaRaw), `"minItems": 1`) || !strings.Contains(string(schemaRaw), `^slides/[^/]+\\.svg$`) {
|
||||
t.Fatalf("deck schema missing minItems/path pattern: %s", string(schemaRaw))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"source_manifest.schema.json",
|
||||
"sources.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"slide_copy_plan.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"assets_manifest.schema.json",
|
||||
"image_candidates.schema.json",
|
||||
"asset_inventory.schema.json",
|
||||
"chart_manifest.schema.json",
|
||||
"image_usage.schema.json",
|
||||
"chart_quality.schema.json",
|
||||
"typography_contract.schema.json",
|
||||
"quality.schema.json",
|
||||
"receipt.schema.json",
|
||||
"lint.schema.json",
|
||||
"preview.schema.json",
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", name, err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("schema %s is not valid JSON: %v", name, err)
|
||||
}
|
||||
if schema["type"] == nil {
|
||||
t.Fatalf("schema %s missing type: %s", name, string(raw))
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{name: "request.schema.json", want: []string{`"purpose"`, `"language"`, `"visual_style_query"`}},
|
||||
{name: "design_brief.schema.json", want: []string{`"visual_system"`, `"narrative_spine"`, `"depth"`, `"tone"`}},
|
||||
{name: "deck.schema.json", want: []string{`"main_title"`, `"style_instruction"`, `"aesthetic_direction"`}},
|
||||
{name: "sources.schema.json", want: []string{`"retrieval"`}},
|
||||
{name: "slide_content.schema.json", want: []string{`"source_refs"`, `"minItems"`, `"visuals"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "slide_copy_plan.schema.json", want: []string{`"audience_copy"`, `"production_instruction"`}},
|
||||
{name: "assets_plan.schema.json", want: []string{`"experiment_unrestricted_assets"`, `"slide_id"`, `"status"`, `"deferred"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "image_candidates.schema.json", want: []string{`"requires_real_images"`, `"format_exception_reason"`, `"selection_reason"`}},
|
||||
{name: "asset_inventory.schema.json", want: []string{`"large_ok"`, `"candidate_id"`, `"format_exception_reason"`}},
|
||||
{name: "image_usage.schema.json", want: []string{`"area_bp"`, `"usage_status"`}},
|
||||
{name: "chart_manifest.schema.json", want: []string{`"vega-lite"`, `"spec_path"`, `"svg_path"`}},
|
||||
{name: "chart_quality.schema.json", want: []string{`"missing_unit_count"`, `"missing_source_count"`, `"decorative_chart_count"`}},
|
||||
{name: "typography_contract.schema.json", want: []string{`"display"`, `"number"`, `"label"`}},
|
||||
{name: "quality.schema.json", want: []string{`"metrics"`, `"real_image_assets"`, `"vega_lite_spec_assets"`}},
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", tc.name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", tc.name, err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("schema %s missing %s: %s", tc.name, want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRuntimeBindingMentionsCopyPlanAndAssetInventory(t *testing.T) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var binding PromptAssetContract
|
||||
found := false
|
||||
for _, asset := range assets {
|
||||
if asset.ID == "svglide_local_runtime_binding" {
|
||||
binding = asset
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing svglide_local_runtime_binding")
|
||||
}
|
||||
raw, err := readPromptAssetFile(binding.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, want := range []string{"image_candidates", "asset_inventory", "image_usage", "chart_manifest", "typography_contract", "slide_copy_plan", "audience_copy", "production_instruction"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("runtime binding missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "run.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRootWithoutRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantREADME := "keep this readme\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(wantREADME), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
gotREADME, readErr := os.ReadFile(filepath.Join(root, "README.md"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(gotREADME) != wantREADME {
|
||||
t.Fatalf("README overwritten: got %q, want %q", string(gotREADME), wantREADME)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected existing root refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunOverwriteReplacesOldRunDirectory(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(filepath.Join(root, "slides"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "slides", "old.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "slides", "old.svg")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old slide should be removed, stat err = %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !run.Policy.Overwrite {
|
||||
t.Fatalf("Policy.Overwrite = false, want true: %+v", run.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsOverlappingInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
input string
|
||||
overwrite bool
|
||||
}{
|
||||
{name: "same path overwrite", root: "source.md", input: "source.md", overwrite: true},
|
||||
{name: "input under output overwrite", root: "demo", input: "demo/source.md", overwrite: true},
|
||||
{name: "input under output no overwrite", root: "demo", input: "demo/source.md", overwrite: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.MkdirAll(filepath.Dir(tt.input), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(tt.input, []byte("source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(tt.root, InitOptions{Title: "Demo", Input: tt.input, Overwrite: tt.overwrite})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping input/output refusal")
|
||||
}
|
||||
got, readErr := os.ReadFile(tt.input)
|
||||
if readErr != nil {
|
||||
t.Fatalf("source should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "source" {
|
||||
t.Fatalf("source content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsUnsafePaths(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "absolute root", root: filepath.Join(cwd, "demo"), opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping root", root: "../escape", opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping input", root: "demo", opts: InitOptions{Title: "Demo", Input: "../source.md"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected unsafe path refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsRootResolvingToCWDWhenOverwrite(t *testing.T) {
|
||||
for _, root := range []string{".", "./", "subdir/.."} {
|
||||
t.Run(root, func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
markerPath := filepath.Join(cwd, "keep.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err == nil {
|
||||
t.Fatal("expected root resolving to CWD to be rejected")
|
||||
}
|
||||
got, readErr := os.ReadFile(markerPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("marker should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "keep" {
|
||||
t.Fatalf("marker content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPromptManifestContracts(t *testing.T) {
|
||||
manifest, err := ResolvedPromptManifest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manifest.Source != anyGenPromptRoot {
|
||||
t.Fatalf("Source = %q, want %q", manifest.Source, anyGenPromptRoot)
|
||||
}
|
||||
if manifest.Runtime != "agent" {
|
||||
t.Fatalf("Runtime = %q, want agent", manifest.Runtime)
|
||||
}
|
||||
entries := map[string]PromptManifestEntry{}
|
||||
for _, entry := range manifest.Entries {
|
||||
entries[entry.Name] = entry
|
||||
}
|
||||
for _, want := range []string{"anygen_source_full", "anygen_svg_readme", "mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding", "svglide_visual_quality_overlay", "resolve_design_brief", "slide_outline", "activate_slides_edit", "slides_edit", "finish_slides_edit", "resolve_image_assets", "generate_vega_lite_chart", "generate_svg_chart", "slides_convert", "slides_parse_template"} {
|
||||
if entries[want].Path == "" {
|
||||
t.Fatalf("manifest missing %q: %+v", want, manifest.Entries)
|
||||
}
|
||||
}
|
||||
if entries["anygen_source_full"].Path != "docs/vendor/anygen-svg/source.full.md" || entries["anygen_source_full"].Always || entries["anygen_source_full"].SHA256 == "" {
|
||||
t.Fatalf("anygen_source_full entry = %+v, want hashed provenance-only source.full.md path", entries["anygen_source_full"])
|
||||
}
|
||||
if entries["anygen_svg_readme"].Path != "skills/lark-slides/references/anygen-svg/README.md" || !entries["anygen_svg_readme"].Always {
|
||||
t.Fatalf("anygen_svg_readme entry = %+v, want always README path", entries["anygen_svg_readme"])
|
||||
}
|
||||
if !entries["mode_system_prompt_svg"].Always || !entries["svg_reference"].Always {
|
||||
t.Fatalf("core prompt entries must be always available: %+v", manifest.Entries)
|
||||
}
|
||||
if entries["svglide_local_runtime_binding"].Role != "runtime_binding" || !entries["svglide_local_runtime_binding"].Always {
|
||||
t.Fatalf("runtime binding entry = %+v, want always runtime_binding", entries["svglide_local_runtime_binding"])
|
||||
}
|
||||
if entries["svglide_visual_quality_overlay"].Role != "runtime_binding" || !entries["svglide_visual_quality_overlay"].Always {
|
||||
t.Fatalf("visual quality overlay entry = %+v, want always runtime_binding", entries["svglide_visual_quality_overlay"])
|
||||
}
|
||||
if entries["activate_slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("activate_slides_edit stage = %q, want %q", entries["activate_slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("slides_edit stage = %q, want %q", entries["slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["generate_svg_chart"].Stage != StageAssets {
|
||||
t.Fatalf("generate_svg_chart stage = %q, want %q", entries["generate_svg_chart"].Stage, StageAssets)
|
||||
}
|
||||
if entries["generate_vega_lite_chart"].Stage != StageAssets {
|
||||
t.Fatalf("generate_vega_lite_chart stage = %q, want %q", entries["generate_vega_lite_chart"].Stage, StageAssets)
|
||||
}
|
||||
if entries["resolve_image_assets"].Stage != StageAssets {
|
||||
t.Fatalf("resolve_image_assets stage = %q, want %q", entries["resolve_image_assets"].Stage, StageAssets)
|
||||
}
|
||||
promptPaths, err := PromptPathsForStage(StageSVGAuthor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := strings.Join(promptPaths, "\n")
|
||||
if strings.Contains(paths, "source.full.md") {
|
||||
t.Fatalf("SVG author prompt paths should not require source snapshot:\n%s", paths)
|
||||
}
|
||||
for _, want := range []string{"README.md", "mode_system_prompt_svg.md", "svg_reference.md", "svglide_local_runtime_binding.md", "svglide_visual_quality_overlay.md", "tools/activate_slides_edit.md", "tools/slides_edit.md", "tools/compute_custom_shape_bbox.md"} {
|
||||
if !strings.Contains(paths, want) {
|
||||
t.Fatalf("SVG author prompt paths missing %q:\n%s", want, paths)
|
||||
}
|
||||
}
|
||||
if strings.Contains(paths, "tools/slides_convert.md") || strings.Contains(paths, "tools/slides_parse_template.md") {
|
||||
t.Fatalf("local SVG author prompt paths must not expose legacy tool prompts:\n%s", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsBlankRequiredFields(t *testing.T) {
|
||||
blankRoot := " "
|
||||
t.Chdir(t.TempDir())
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "root", root: blankRoot, opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "title", root: "title", opts: InitOptions{Title: " \t", Input: "source.md"}},
|
||||
{name: "input", root: "input", opts: InitOptions{Title: "Demo", Input: " \t"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected blank field refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunAcceptsTopicOnlyIntent(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
opts := InitOptions{
|
||||
Title: "电影介绍",
|
||||
Now: time.Date(2026, 7, 3, 10, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
}
|
||||
setStringInitOptionField(t, &opts, "Topic", "介绍一部电影")
|
||||
setStringInitOptionField(t, &opts, "Language", "zh")
|
||||
setStringInitOptionField(t, &opts, "AgentRuntime", "fake-agent")
|
||||
setStringInitOptionField(t, &opts, "AgentID", "test-agent-1")
|
||||
|
||||
if err := InitRun("demo", opts); err != nil {
|
||||
t.Fatalf("topic-only init should succeed without --input: %v", err)
|
||||
}
|
||||
|
||||
runRaw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run map[string]any
|
||||
if err := json.Unmarshal(runRaw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run["runtime"] == "codex" || run["runtime"] == "fake-agent" {
|
||||
t.Fatalf("run.runtime = %v, want agent-neutral protocol runtime separate from agent runtime", run["runtime"])
|
||||
}
|
||||
agent, ok := run["agent"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("run.agent missing or invalid: %+v", run)
|
||||
}
|
||||
if agent["runtime"] != "fake-agent" || agent["id"] != "test-agent-1" {
|
||||
t.Fatalf("run.agent = %+v, want fake-agent/test-agent-1", agent)
|
||||
}
|
||||
intent, ok := run["intent"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("run.intent missing or invalid: %+v", run)
|
||||
}
|
||||
if intent["source_mode"] != "topic" || intent["topic"] != "介绍一部电影" || intent["language"] != "zh" {
|
||||
t.Fatalf("run.intent = %+v, want topic-only zh intent", intent)
|
||||
}
|
||||
|
||||
requestRaw, err := os.ReadFile(filepath.Join("demo", "request", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(requestRaw, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input, ok := request["input"]; ok && input != "" {
|
||||
t.Fatalf("topic-only request.json input = %v, want absent or empty", input)
|
||||
}
|
||||
if request["intent"] == nil || request["agent"] == nil {
|
||||
t.Fatalf("request.json missing intent/agent: %+v", request)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join("demo", "request", "source_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Sources []map[string]string `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest.Sources) != 1 || manifest.Sources[0]["type"] != "topic" || manifest.Sources[0]["topic"] != "介绍一部电影" {
|
||||
t.Fatalf("source_manifest.json = %+v, want one topic source", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func setStringInitOptionField(t *testing.T, opts *InitOptions, name string, value string) {
|
||||
t.Helper()
|
||||
field := reflect.ValueOf(opts).Elem().FieldByName(name)
|
||||
if !field.IsValid() {
|
||||
t.Fatalf("InitOptions missing %s field required by agent runtime protocol", name)
|
||||
}
|
||||
if field.Kind() != reflect.String || !field.CanSet() {
|
||||
t.Fatalf("InitOptions.%s = %s canSet=%v, want settable string", name, field.Kind(), field.CanSet())
|
||||
}
|
||||
field.SetString(value)
|
||||
}
|
||||
20
internal/svglide/io.go
Normal file
20
internal/svglide/io.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(path, raw, 0o644)
|
||||
}
|
||||
|
||||
func writeText(path string, content string) error {
|
||||
return validate.AtomicWrite(path, []byte(content), 0o644)
|
||||
}
|
||||
203
internal/svglide/metrics.go
Normal file
203
internal/svglide/metrics.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SemanticMetrics struct {
|
||||
SlideCount int `json:"slide_count"`
|
||||
SlidesWithSlideRole int `json:"slides_with_slide_role"`
|
||||
ImageCount int `json:"image_count"`
|
||||
TextCount int `json:"text_count"`
|
||||
NoteCount int `json:"note_count"`
|
||||
SourceRefCount int `json:"source_ref_count"`
|
||||
MissingAssetCount int `json:"missing_asset_count"`
|
||||
SlidesWithoutSourceRefs int `json:"slides_without_source_refs"`
|
||||
VisibleLeakCount int `json:"visible_leak_count"`
|
||||
FontTokenCount int `json:"font_token_count"`
|
||||
MissingFontTokenCount int `json:"missing_font_token_count"`
|
||||
}
|
||||
|
||||
func MissingAssetCountForRun(safeRoot string, run Run) int {
|
||||
metrics, err := ComputeSemanticMetrics(safeRoot, run)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return metrics.MissingAssetCount
|
||||
}
|
||||
|
||||
func ComputeSemanticMetrics(safeRoot string, run Run) (SemanticMetrics, error) {
|
||||
var metrics SemanticMetrics
|
||||
deck, err := readAuthorDeck(safeRoot, semanticDeckPath(run))
|
||||
if err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
metrics.SlideCount = len(deck.Slides)
|
||||
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err == nil {
|
||||
sourceRefBySlideID := make(map[string]int, len(content.Slides))
|
||||
for _, slide := range content.Slides {
|
||||
count := 0
|
||||
for _, ref := range slide.SourceRefs {
|
||||
if strings.TrimSpace(ref) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
sourceRefBySlideID[strings.TrimSpace(slide.ID)] = count
|
||||
metrics.SourceRefCount += count
|
||||
}
|
||||
for _, slide := range deck.Slides {
|
||||
if sourceRefBySlideID[strings.TrimSpace(slide.ID)] == 0 {
|
||||
metrics.SlidesWithoutSourceRefs++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readyAssets := map[string]deckAsset{}
|
||||
if assets, err := readAssetsManifest(safeRoot); err == nil {
|
||||
for _, asset := range assets.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
path := assetPath(asset)
|
||||
if path != "" {
|
||||
readyAssets[path] = asset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
svg := string(raw)
|
||||
metrics.VisibleLeakCount += countVisibleLeakMarkers(svg)
|
||||
fontTokens := countFontTokens(svg)
|
||||
metrics.FontTokenCount += fontTokens
|
||||
if fontTokens < 4 {
|
||||
metrics.MissingFontTokenCount += 4 - fontTokens
|
||||
}
|
||||
if strings.Contains(svg, `slide:role="slide"`) || strings.Contains(svg, `slide:role='slide'`) {
|
||||
metrics.SlidesWithSlideRole++
|
||||
}
|
||||
metrics.TextCount += strings.Count(svg, "<text")
|
||||
metrics.TextCount += strings.Count(svg, `slide:shape-type="text"`)
|
||||
metrics.NoteCount += strings.Count(svg, "<slide:note")
|
||||
for _, ref := range activeSVGAssetRefs(svg) {
|
||||
switch ref.Kind {
|
||||
case "image":
|
||||
metrics.ImageCount++
|
||||
}
|
||||
resolvedHref, hrefErr := svgHrefRunPath(slidePath, ref.Href)
|
||||
if hrefErr != nil {
|
||||
metrics.MissingAssetCount++
|
||||
continue
|
||||
}
|
||||
asset, ok := readyAssets[resolvedHref]
|
||||
if !ok {
|
||||
metrics.MissingAssetCount++
|
||||
continue
|
||||
}
|
||||
if err := readyAssetLocalAvailability(safeRoot, run, asset); err != nil {
|
||||
metrics.MissingAssetCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func countVisibleLeakMarkers(svg string) int {
|
||||
count := 0
|
||||
lower := strings.ToLower(visibleSemanticText(svg))
|
||||
for _, marker := range []string{
|
||||
"sources:",
|
||||
"source note",
|
||||
"production_instruction",
|
||||
"图片要完整",
|
||||
"必须让眼镜完整出现",
|
||||
"不要裁切",
|
||||
"来源来自官网",
|
||||
} {
|
||||
count += strings.Count(lower, strings.ToLower(marker))
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func visibleSemanticText(svg string) string {
|
||||
decoder := xml.NewDecoder(strings.NewReader(svg))
|
||||
var builder strings.Builder
|
||||
excludedDepth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return builder.String()
|
||||
}
|
||||
return svg
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
if excludedDepth > 0 || semanticTextExcludedElement(typed) {
|
||||
excludedDepth++
|
||||
}
|
||||
case xml.CharData:
|
||||
if excludedDepth == 0 {
|
||||
builder.WriteByte(' ')
|
||||
builder.Write(typed)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if excludedDepth > 0 {
|
||||
excludedDepth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func semanticTextExcludedElement(start xml.StartElement) bool {
|
||||
if start.Name.Space == slideNamespace && start.Name.Local == "note" {
|
||||
return true
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "defs", "style", "script", "metadata", "title", "desc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func countFontTokens(svg string) int {
|
||||
count := 0
|
||||
for _, token := range []string{"--font-display", "--font-body", "--font-number", "--font-label"} {
|
||||
if strings.Contains(svg, token) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func readyAssetLocalAvailability(safeRoot string, run Run, asset deckAsset) error {
|
||||
path := assetPath(asset)
|
||||
if strings.HasPrefix(path, "https://") {
|
||||
if normalizedRouteProfile(run.RouteProfile) == RouteProfileLocalSVGDeck {
|
||||
return fmt.Errorf("local_svg_deck ready image asset path %q must be a local assets/images/<file>", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, _, exists, err := lstatRunPath(safeRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("asset path %q is missing or not a regular file", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
500
internal/svglide/preview.go
Normal file
500
internal/svglide/preview.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const defaultPreviewPath = "preview.html"
|
||||
const previewReceiptPath = "receipts/preview.json"
|
||||
|
||||
type PreviewReport struct {
|
||||
Status string `json:"status"`
|
||||
MissingAssetCount int `json:"missing_asset_count"`
|
||||
BrowserMissingAssetCount int `json:"browser_missing_asset_count"`
|
||||
RenderedVisual string `json:"rendered_visual,omitempty"`
|
||||
RenderedVisualIssueCount int `json:"rendered_visual_issue_count,omitempty"`
|
||||
Slides []PreviewSlideReport `json:"slides"`
|
||||
}
|
||||
|
||||
type PreviewSlideReport struct {
|
||||
Path string `json:"path"`
|
||||
Rendered bool `json:"rendered"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type previewDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []previewDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type previewDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type previewPageData struct {
|
||||
Title string
|
||||
Status string
|
||||
SlideCount int
|
||||
RenderedCount int
|
||||
Slides []previewPageSlide
|
||||
}
|
||||
|
||||
type previewPageSlide struct {
|
||||
Number int
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Role string
|
||||
KeyMessage string
|
||||
Path string
|
||||
Rendered bool
|
||||
Message string
|
||||
}
|
||||
|
||||
func WritePreview(root string) (PreviewReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return PreviewReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return writeFailedPreview(safeRoot, run, "", "deck artifact path is empty")
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q: %v", deckPath, err))
|
||||
}
|
||||
var deck previewDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := PreviewReport{Slides: make([]PreviewSlideReport, 0, len(deck.Slides))}
|
||||
pageSlides := make([]previewPageSlide, 0, len(deck.Slides))
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, pathErr := previewSlideObjectPath(slide.Path)
|
||||
pageSlide := previewPageSlide{
|
||||
Number: i + 1,
|
||||
ID: strings.TrimSpace(slide.ID),
|
||||
Title: strings.TrimSpace(slide.Title),
|
||||
Summary: strings.TrimSpace(slide.Summary),
|
||||
Role: strings.TrimSpace(slide.Role),
|
||||
KeyMessage: strings.TrimSpace(slide.KeyMessage),
|
||||
Path: slidePath,
|
||||
}
|
||||
item := PreviewSlideReport{Path: slidePath}
|
||||
if pathErr != nil {
|
||||
item.Message = pathErr.Error()
|
||||
} else if slidePath == "" {
|
||||
item.Path = "(slide)"
|
||||
pageSlide.Path = item.Path
|
||||
item.Message = "slide path must not be empty"
|
||||
} else if raw, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
|
||||
item.Message = err.Error()
|
||||
} else {
|
||||
item.Rendered = true
|
||||
pageSlide.Rendered = true
|
||||
for _, ref := range activeSVGAssetRefs(string(raw)) {
|
||||
resolvedHref, hrefErr := svgHrefRunPath(slidePath, ref.Href)
|
||||
if hrefErr != nil {
|
||||
report.BrowserMissingAssetCount++
|
||||
item.Rendered = false
|
||||
pageSlide.Rendered = false
|
||||
item.Message = appendPreviewMessage(item.Message, hrefErr.Error())
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(resolvedHref, "data:") || strings.HasPrefix(resolvedHref, "http://") || strings.HasPrefix(resolvedHref, "https://") {
|
||||
continue
|
||||
}
|
||||
if _, err := readRunRegularArtifact(safeRoot, resolvedHref); err != nil {
|
||||
report.BrowserMissingAssetCount++
|
||||
item.Rendered = false
|
||||
pageSlide.Rendered = false
|
||||
item.Message = appendPreviewMessage(item.Message, fmt.Sprintf("browser asset href %q resolves missing path %q", ref.Href, resolvedHref))
|
||||
}
|
||||
}
|
||||
}
|
||||
pageSlide.Message = item.Message
|
||||
report.Slides = append(report.Slides, item)
|
||||
pageSlides = append(pageSlides, pageSlide)
|
||||
}
|
||||
visualReport := EvaluateRenderedVisualRun(safeRoot, deck)
|
||||
if err := writeRenderedVisualReport(safeRoot, visualReport); err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.RenderedVisual = renderedVisualReceiptPath
|
||||
report.RenderedVisualIssueCount = visualReport.Metrics.IssueCount
|
||||
if visualReport.Status != "passed" {
|
||||
for i := range report.Slides {
|
||||
if renderedVisualSlideFailed(visualReport, report.Slides[i].Path) {
|
||||
report.Slides[i].Rendered = false
|
||||
report.Slides[i].Message = appendPreviewMessage(report.Slides[i].Message, "rendered visual gate failed")
|
||||
pageSlides[i].Rendered = false
|
||||
pageSlides[i].Message = appendPreviewMessage(pageSlides[i].Message, "rendered visual gate failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
report = normalizePreviewReport(report)
|
||||
report.MissingAssetCount = MissingAssetCountForRun(safeRoot, run)
|
||||
|
||||
if err := writePreviewArtifacts(safeRoot, run, deck.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeFailedPreview(safeRoot string, run Run, path string, message string) (PreviewReport, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
path = "(deck)"
|
||||
}
|
||||
report := normalizePreviewReport(PreviewReport{
|
||||
MissingAssetCount: MissingAssetCountForRun(safeRoot, run),
|
||||
RenderedVisual: renderedVisualReceiptPath,
|
||||
Slides: []PreviewSlideReport{{
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}},
|
||||
})
|
||||
pageSlides := []previewPageSlide{{
|
||||
Number: 1,
|
||||
Title: "Preview failed",
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}}
|
||||
if err := writePreviewArtifacts(safeRoot, run, run.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
visualReport := RenderedVisualReport{
|
||||
Status: "failed",
|
||||
Metrics: RenderedVisualMetrics{Slides: 1, IssueCount: 1, OutOfCanvasCount: 1},
|
||||
Issues: []RenderedVisualIssue{{
|
||||
Path: path,
|
||||
Code: "svglide.rendered_visual.preview_failed",
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
}},
|
||||
Slides: []RenderedVisualSlideItem{{Path: path, Status: "failed", IssueCount: 1}},
|
||||
}
|
||||
if err := writeRenderedVisualReport(safeRoot, visualReport); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func normalizePreviewReport(report PreviewReport) PreviewReport {
|
||||
if report.Slides == nil {
|
||||
report.Slides = []PreviewSlideReport{}
|
||||
}
|
||||
report.Status = "passed"
|
||||
for i := range report.Slides {
|
||||
report.Slides[i].Path = strings.TrimSpace(report.Slides[i].Path)
|
||||
if report.Slides[i].Path == "" {
|
||||
report.Slides[i].Path = "(slide)"
|
||||
}
|
||||
if !report.Slides[i].Rendered {
|
||||
report.Status = "failed"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func appendPreviewMessage(existing string, next string) string {
|
||||
existing = strings.TrimSpace(existing)
|
||||
next = strings.TrimSpace(next)
|
||||
if existing == "" {
|
||||
return next
|
||||
}
|
||||
if next == "" {
|
||||
return existing
|
||||
}
|
||||
return existing + "; " + next
|
||||
}
|
||||
|
||||
func previewSlideObjectPath(path string) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("slide path must not be empty")
|
||||
}
|
||||
if strings.Contains(raw, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(raw, "%") {
|
||||
return "", fmt.Errorf("slide path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(raw, ":") || strings.Contains(raw, "//") {
|
||||
return "", fmt.Errorf("slide path %q must be a local slides/*.svg path", raw)
|
||||
}
|
||||
parts := strings.Split(raw, "/")
|
||||
if len(parts) != 2 || parts[0] != "slides" {
|
||||
return "", fmt.Errorf("slide path %q must match slides/<file>.svg", raw)
|
||||
}
|
||||
fileName := parts[1]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("slide path %q must include a slide file name", raw)
|
||||
}
|
||||
if strings.Contains(fileName, "/") || strings.Contains(fileName, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must not contain nested directories", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("slide path %q must not contain dot segments", raw)
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(fileName)) != ".svg" {
|
||||
return "", fmt.Errorf("slide path %q must end with .svg", raw)
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(raw))
|
||||
if cleaned != raw {
|
||||
return "", fmt.Errorf("slide path %q must already be normalized", raw)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writePreviewArtifacts(safeRoot string, run Run, title string, report PreviewReport, slides []previewPageSlide) error {
|
||||
report = normalizePreviewReport(report)
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
htmlRaw, err := renderPreviewHTML(title, report, slides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(target, htmlRaw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, previewReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(receiptTarget, raw, 0o644)
|
||||
}
|
||||
|
||||
func renderPreviewHTML(title string, report PreviewReport, slides []previewPageSlide) ([]byte, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "SVGlide Preview"
|
||||
}
|
||||
var rendered int
|
||||
for _, slide := range slides {
|
||||
if slide.Rendered {
|
||||
rendered++
|
||||
}
|
||||
}
|
||||
data := previewPageData{
|
||||
Title: title,
|
||||
Status: report.Status,
|
||||
SlideCount: len(slides),
|
||||
RenderedCount: rendered,
|
||||
Slides: slides,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := previewTemplate.Execute(&b, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
var previewTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} - SVGlide Preview</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--ink: #1f2933;
|
||||
--muted: #657286;
|
||||
--line: #d8dee8;
|
||||
--accent: #1d7a62;
|
||||
--warn: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.status.failed { background: var(--warn); }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.deck {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.slide {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 24px rgba(31,41,51,.06);
|
||||
}
|
||||
.frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
object {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.missing {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
color: var(--warn);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.details {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.details h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.label {
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
}
|
||||
.path {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.message { color: var(--warn); overflow-wrap: anywhere; }
|
||||
@media (max-width: 860px) {
|
||||
.bar { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.meta { flex-wrap: wrap; white-space: normal; }
|
||||
.slide { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="bar">
|
||||
<h1>{{.Title}}</h1>
|
||||
<div class="meta">
|
||||
<span class="status {{.Status}}">{{.Status}}</span>
|
||||
<span>{{.RenderedCount}} / {{.SlideCount}} rendered</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="deck">
|
||||
{{range .Slides}}
|
||||
<article class="slide">
|
||||
<div class="frame">
|
||||
{{if .Rendered}}
|
||||
<object data="{{.Path}}" type="image/svg+xml" aria-label="{{.Title}}"></object>
|
||||
{{else}}
|
||||
<div class="missing">{{.Message}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{printf "%02d" .Number}}. {{.Title}}</h2>
|
||||
{{if .Summary}}<div><span class="label">Summary</span><br>{{.Summary}}</div>{{end}}
|
||||
{{if .KeyMessage}}<div><span class="label">Key Message</span><br>{{.KeyMessage}}</div>{{end}}
|
||||
{{if .Role}}<div><span class="label">Role</span><br>{{.Role}}</div>{{end}}
|
||||
<div><span class="label">Path</span><br><span class="path">{{.Path}}</span></div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
347
internal/svglide/preview_test.go
Normal file
347
internal/svglide/preview_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePreviewWritesHTMLAndReceipt(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || !report.Slides[0].Rendered || report.Slides[0].Path != "slides/01.svg" {
|
||||
t.Fatalf("Slides = %+v, want rendered slides/01.svg", report.Slides)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
for _, want := range []string{"Demo - SVGlide Preview", `<link rel="icon" href="data:,">`, `data="slides/01.svg"`, "01. Slide", "Key Message"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("preview.html missing %q:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "passed" || len(receipt.Slides) != 1 || !receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want passed rendered slide", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewReportsMissingAssetsFromSVGAndManifest(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" href="assets/images/missing.png"/></svg>`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[{"id":"hero","slide_id":"slide-1","kind":"image","local_path":"assets/images/missing.png","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.MissingAssetCount != 1 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 1", report.MissingAssetCount)
|
||||
}
|
||||
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.MissingAssetCount != 1 {
|
||||
t.Fatalf("receipt MissingAssetCount = %d, want 1", receipt.MissingAssetCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewFailsOnRenderedVisualOverflow(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="92" y="318" font-size="25">Revenue declined 4.3% year over year, but gross margin reached 46.6% and diluted EPS set a March-quarter record.</text></svg>`)
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.RenderedVisual != renderedVisualReceiptPath || report.RenderedVisualIssueCount == 0 {
|
||||
t.Fatalf("rendered visual fields = %q/%d, want receipt and issues", report.RenderedVisual, report.RenderedVisualIssueCount)
|
||||
}
|
||||
var visual RenderedVisualReport
|
||||
raw, err := os.ReadFile(filepath.Join("demo", renderedVisualReceiptPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &visual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if visual.Status != "failed" || !renderedVisualHasCode(visual, "svglide.rendered_visual.text_overflow") {
|
||||
t.Fatalf("visual = %+v, want text overflow failure", visual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewEscapesDeckText(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDeckAt(t, filepath.Join("demo", "outline", "deck.json"), previewDeck{
|
||||
Title: `<Deck & Demo>`,
|
||||
Slides: []previewDeckSlide{{
|
||||
ID: "cover",
|
||||
Title: `<Cover & One>`,
|
||||
Summary: `Summary <script>bad()</script>`,
|
||||
Role: "cover",
|
||||
KeyMessage: `Message & context`,
|
||||
Path: "slides/01.svg",
|
||||
}},
|
||||
})
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
if _, err := WritePreview("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
if strings.Contains(html, "<script>bad()</script>") {
|
||||
t.Fatalf("preview.html contains unescaped script:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<script>bad()</script>") || !strings.Contains(html, "<Deck & Demo>") {
|
||||
t.Fatalf("preview.html missing escaped deck text:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewUsesRunArtifactDeckAndPreviewPath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
run.Artifacts.Preview = "public/deck.html"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/missing.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "public", "deck.html")); err != nil {
|
||||
t.Fatalf("missing custom preview path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); !os.IsNotExist(err) {
|
||||
t.Fatalf("default preview should not be written when artifact path is custom, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewReportsUnsafeSlidePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slidePath string
|
||||
filePath string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "escape",
|
||||
slidePath: "../outside.svg",
|
||||
filePath: "outside.svg",
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "remote scheme",
|
||||
slidePath: "https:/evil.example/a.svg",
|
||||
filePath: filepath.Join("demo", "https:", "evil.example", "a.svg"),
|
||||
wantMessage: "local slides/*.svg",
|
||||
},
|
||||
{
|
||||
name: "encoded dot segment",
|
||||
slidePath: "slides/%2e%2e.svg",
|
||||
filePath: filepath.Join("demo", "slides", "%2e%2e.svg"),
|
||||
wantMessage: "percent encoding",
|
||||
},
|
||||
{
|
||||
name: "nested directory",
|
||||
slidePath: "slides/nested/01.svg",
|
||||
filePath: filepath.Join("demo", "slides", "nested", "01.svg"),
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "backslash",
|
||||
slidePath: `slides\01.svg`,
|
||||
filePath: filepath.Join("demo", `slides\01.svg`),
|
||||
wantMessage: "forward slashes",
|
||||
},
|
||||
{
|
||||
name: "wrong extension",
|
||||
slidePath: "slides/01.png",
|
||||
filePath: filepath.Join("demo", "slides", "01.png"),
|
||||
wantMessage: ".svg",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.slidePath)
|
||||
writeValidateTestFile(t, tt.filePath, visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || report.Slides[0].Rendered {
|
||||
t.Fatalf("Slides = %+v, want unrendered slide", report.Slides)
|
||||
}
|
||||
if !strings.Contains(report.Slides[0].Message, tt.wantMessage) {
|
||||
t.Fatalf("Message = %q, want %q", report.Slides[0].Message, tt.wantMessage)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want failed unrendered slide", receipt)
|
||||
}
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(htmlRaw), `data="`) {
|
||||
t.Fatalf("preview should not embed unsafe slide path:\n%s", string(htmlRaw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewWritesFailureArtifactsForDeckReadFailures(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html for failed deck read: %v", err)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Path != "outline/deck.json" {
|
||||
t.Fatalf("preview receipt = %+v, want failed deck report", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.html")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "preview.html")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "preview.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipt symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview receipt overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptsDirectorySymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipts directory symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "preview.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("preview receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readPreviewReceipt(t *testing.T) PreviewReport {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "preview.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt PreviewReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func writeDeckAt(t *testing.T, path string, deck previewDeck) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
1267
internal/svglide/prompt.go
Normal file
1267
internal/svglide/prompt.go
Normal file
File diff suppressed because it is too large
Load Diff
900
internal/svglide/prompt_contract.go
Normal file
900
internal/svglide/prompt_contract.go
Normal file
@@ -0,0 +1,900 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolAnyGenSVGSlides = "anygen-svg-slides"
|
||||
)
|
||||
|
||||
type PromptAssetContract struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Role string `json:"role" yaml:"role"`
|
||||
OrchestratedBy string `json:"orchestrated_by,omitempty" yaml:"orchestrated_by,omitempty"`
|
||||
Invocation string `json:"invocation,omitempty" yaml:"invocation,omitempty"`
|
||||
Stage string `json:"stage,omitempty" yaml:"stage,omitempty"`
|
||||
Order int `json:"order,omitempty" yaml:"order,omitempty"`
|
||||
Cardinality string `json:"cardinality,omitempty" yaml:"cardinality,omitempty"`
|
||||
Requires []string `json:"requires,omitempty" yaml:"requires,omitempty"`
|
||||
Condition string `json:"condition,omitempty" yaml:"condition,omitempty"`
|
||||
Trigger []string `json:"trigger,omitempty" yaml:"trigger,omitempty"`
|
||||
Consumes []string `json:"consumes,omitempty" yaml:"consumes,omitempty"`
|
||||
Produces []string `json:"produces,omitempty" yaml:"produces,omitempty"`
|
||||
CompletionGate []string `json:"completion_gate,omitempty" yaml:"completion_gate,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty" yaml:"phase_anchors,omitempty"`
|
||||
Profiles []string `json:"profiles,omitempty" yaml:"profiles,omitempty"`
|
||||
Exposure string `json:"exposure,omitempty" yaml:"exposure,omitempty"`
|
||||
Rules []any `json:"-" yaml:"rules,omitempty"`
|
||||
Path string `json:"path" yaml:"-"`
|
||||
SHA256 string `json:"sha256" yaml:"-"`
|
||||
}
|
||||
|
||||
type AnyGenOrchestrationGraph struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Orchestrator PromptAssetContract `json:"orchestrator"`
|
||||
ProtocolReference PromptAssetContract `json:"protocol_reference"`
|
||||
Assets []PromptAssetContract `json:"assets"`
|
||||
}
|
||||
|
||||
type ToolInvocationContract struct {
|
||||
Protocol string `json:"protocol"`
|
||||
RequiredCalls []ToolCallRequirement `json:"required_calls"`
|
||||
ConditionalCalls []ToolCallRequirement `json:"conditional_calls"`
|
||||
}
|
||||
|
||||
type ToolCallRequirement struct {
|
||||
ID string `json:"id"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
Invocation string `json:"invocation,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
Cardinality string `json:"cardinality"`
|
||||
Condition string `json:"condition"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Produces []string `json:"produces"`
|
||||
}
|
||||
|
||||
type StagePromptContract struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Stage string `json:"stage"`
|
||||
ContextReceipt string `json:"context_receipt,omitempty"`
|
||||
Orchestrator string `json:"orchestrator"`
|
||||
ProtocolReference string `json:"protocol_reference"`
|
||||
RequiredPromptIDs []string `json:"required_prompt_ids"`
|
||||
ConditionalPromptIDs []string `json:"conditional_prompt_ids,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty"`
|
||||
}
|
||||
|
||||
type PromptContextAsset struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type PromptContext struct {
|
||||
ReadPolicy string `json:"read_policy"`
|
||||
Authority string `json:"authority"`
|
||||
Assets []PromptContextAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type AgentTask struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Stage string `json:"stage"`
|
||||
Objective string `json:"objective"`
|
||||
Orchestrator string `json:"orchestrator"`
|
||||
ProtocolReference string `json:"protocol_reference"`
|
||||
RequiredPrompts []string `json:"required_prompts"`
|
||||
RequiredCalls []ToolCallRequirement `json:"required_calls"`
|
||||
ConditionalCalls []ToolCallRequirement `json:"conditional_calls,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
CompletionGate []string `json:"completion_gate"`
|
||||
ToolCallReceiptDir string `json:"tool_call_receipt_dir"`
|
||||
PromptContext PromptContext `json:"prompt_context"`
|
||||
}
|
||||
|
||||
type PromptContextReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Protocol string `json:"protocol"`
|
||||
AgentTask AgentTask `json:"agent_task"`
|
||||
PromptContract StagePromptContract `json:"prompt_contract"`
|
||||
ToolInvocationContract ToolInvocationContract `json:"tool_invocation_contract"`
|
||||
AssetHashes map[string]string `json:"asset_hashes"`
|
||||
}
|
||||
|
||||
func LoadAnyGenPromptAssets() ([]PromptAssetContract, error) {
|
||||
manifest := DefaultPromptManifest()
|
||||
assets := make([]PromptAssetContract, 0, len(manifest.Entries))
|
||||
ids := map[string]bool{}
|
||||
for _, entry := range manifest.Entries {
|
||||
asset, err := loadPromptAssetContract(entry.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expectedID := entry.ID
|
||||
if expectedID == "" {
|
||||
expectedID = entry.Name
|
||||
}
|
||||
if asset.ID != expectedID {
|
||||
return nil, fmt.Errorf("%s: prompt asset id = %q, want %q", entry.Path, asset.ID, expectedID)
|
||||
}
|
||||
if len(asset.Profiles) == 0 {
|
||||
asset.Profiles = slices.Clone(entry.Profiles)
|
||||
}
|
||||
if asset.Exposure == "" {
|
||||
asset.Exposure = entry.Exposure
|
||||
}
|
||||
if ids[asset.ID] {
|
||||
return nil, fmt.Errorf("duplicate prompt asset id %q", asset.ID)
|
||||
}
|
||||
ids[asset.ID] = true
|
||||
assets = append(assets, asset)
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func loadPromptAssetContract(path string) (PromptAssetContract, error) {
|
||||
raw, err := readPromptAssetFile(path)
|
||||
if err != nil {
|
||||
return PromptAssetContract{}, err
|
||||
}
|
||||
frontmatter, err := semanticMarkdownFrontmatter(path, raw)
|
||||
if err != nil {
|
||||
return PromptAssetContract{}, err
|
||||
}
|
||||
var asset PromptAssetContract
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(frontmatter))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&asset); err != nil {
|
||||
return PromptAssetContract{}, fmt.Errorf("%s frontmatter: %w", path, err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return PromptAssetContract{}, fmt.Errorf("%s frontmatter must contain a single YAML document", path)
|
||||
}
|
||||
return PromptAssetContract{}, fmt.Errorf("%s frontmatter: %w", path, err)
|
||||
}
|
||||
asset.Path = filepath.ToSlash(filepath.Clean(path))
|
||||
asset.SHA256, err = promptAssetSHAStrict(path)
|
||||
if err != nil {
|
||||
return PromptAssetContract{}, err
|
||||
}
|
||||
if err := validatePromptAssetContract(asset); err != nil {
|
||||
return PromptAssetContract{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func validatePromptAssetContract(asset PromptAssetContract) error {
|
||||
if strings.TrimSpace(asset.ID) == "" {
|
||||
return fmt.Errorf("missing id")
|
||||
}
|
||||
if strings.TrimSpace(asset.Role) == "" {
|
||||
return fmt.Errorf("missing role")
|
||||
}
|
||||
if strings.TrimSpace(asset.Invocation) == "" {
|
||||
return fmt.Errorf("missing invocation")
|
||||
}
|
||||
switch asset.Role {
|
||||
case "source_snapshot", "reference_index", "semantic_contract", "runtime_binding":
|
||||
if asset.Invocation != "reference" {
|
||||
return fmt.Errorf("role %s must use invocation reference", asset.Role)
|
||||
}
|
||||
case "orchestrator":
|
||||
if asset.Invocation != "required" || asset.ID != "mode_system_prompt_svg" {
|
||||
return fmt.Errorf("orchestrator must be mode_system_prompt_svg with required invocation")
|
||||
}
|
||||
case "protocol_reference":
|
||||
if asset.Invocation != "required" || asset.ID != "svg_reference" {
|
||||
return fmt.Errorf("protocol_reference must be svg_reference with required invocation")
|
||||
}
|
||||
case "tool_prompt":
|
||||
if asset.OrchestratedBy != "mode_system_prompt_svg" {
|
||||
return fmt.Errorf("tool prompt %s must be orchestrated_by mode_system_prompt_svg", asset.ID)
|
||||
}
|
||||
if asset.Invocation != "required" && asset.Invocation != "conditional" {
|
||||
return fmt.Errorf("tool prompt %s uses unsupported invocation %q", asset.ID, asset.Invocation)
|
||||
}
|
||||
if strings.TrimSpace(asset.Stage) == "" {
|
||||
return fmt.Errorf("tool prompt %s missing stage", asset.ID)
|
||||
}
|
||||
if strings.TrimSpace(asset.Cardinality) == "" {
|
||||
return fmt.Errorf("tool prompt %s missing cardinality", asset.ID)
|
||||
}
|
||||
if strings.TrimSpace(asset.Condition) == "" {
|
||||
return fmt.Errorf("tool prompt %s missing condition", asset.ID)
|
||||
}
|
||||
if len(asset.Consumes) == 0 {
|
||||
return fmt.Errorf("tool prompt %s missing consumes", asset.ID)
|
||||
}
|
||||
if len(asset.Produces) == 0 {
|
||||
return fmt.Errorf("tool prompt %s missing produces", asset.ID)
|
||||
}
|
||||
if asset.Invocation == "conditional" && len(asset.Trigger) == 0 {
|
||||
return fmt.Errorf("conditional tool prompt %s missing trigger", asset.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported role %q", asset.Role)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildAnyGenOrchestrationGraph() (AnyGenOrchestrationGraph, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return AnyGenOrchestrationGraph{}, err
|
||||
}
|
||||
var orchestrators []PromptAssetContract
|
||||
var references []PromptAssetContract
|
||||
for _, asset := range assets {
|
||||
switch asset.Role {
|
||||
case "orchestrator":
|
||||
orchestrators = append(orchestrators, asset)
|
||||
case "protocol_reference":
|
||||
references = append(references, asset)
|
||||
}
|
||||
}
|
||||
if len(orchestrators) != 1 {
|
||||
return AnyGenOrchestrationGraph{}, fmt.Errorf("expected exactly one orchestrator, got %d", len(orchestrators))
|
||||
}
|
||||
if len(references) != 1 {
|
||||
return AnyGenOrchestrationGraph{}, fmt.Errorf("expected exactly one protocol reference, got %d", len(references))
|
||||
}
|
||||
return AnyGenOrchestrationGraph{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
Orchestrator: orchestrators[0],
|
||||
ProtocolReference: references[0],
|
||||
Assets: assets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func PromptAssetsForProfileStage(profile string, stage string) ([]PromptAssetContract, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PromptAssetContract, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
if !promptAssetAllowedForProfile(asset.Profiles, profile) {
|
||||
continue
|
||||
}
|
||||
if asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.AlwaysForPromptContext(stage) || asset.Stage == stage {
|
||||
out = append(out, asset)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func BuildToolInvocationContract(run Run, stage string) (ToolInvocationContract, error) {
|
||||
assets, err := PromptAssetsForProfileStage(run.RouteProfile, stage)
|
||||
if err != nil {
|
||||
return ToolInvocationContract{}, err
|
||||
}
|
||||
contract := ToolInvocationContract{Protocol: ProtocolAnyGenSVGSlides}
|
||||
for _, asset := range assets {
|
||||
if asset.Role != "tool_prompt" {
|
||||
continue
|
||||
}
|
||||
req := toolRequirementFromAsset(asset)
|
||||
switch asset.Invocation {
|
||||
case "required":
|
||||
contract.RequiredCalls = append(contract.RequiredCalls, req)
|
||||
case "conditional":
|
||||
contract.ConditionalCalls = append(contract.ConditionalCalls, req)
|
||||
}
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func RequiredPromptContractForStage(stage string, run Run) (StagePromptContract, error) {
|
||||
assets, err := PromptAssetsForProfileStage(run.RouteProfile, stage)
|
||||
if err != nil {
|
||||
return StagePromptContract{}, err
|
||||
}
|
||||
contract := StagePromptContract{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
Stage: stage,
|
||||
ContextReceipt: promptContextReceiptPath(stage),
|
||||
Orchestrator: "mode_system_prompt_svg",
|
||||
ProtocolReference: "svg_reference",
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.Role == "runtime_binding" || asset.AlwaysForPromptContext(stage) {
|
||||
if asset.Invocation == "conditional" {
|
||||
contract.ConditionalPromptIDs = appendUnique(contract.ConditionalPromptIDs, asset.ID)
|
||||
} else {
|
||||
contract.RequiredPromptIDs = appendUnique(contract.RequiredPromptIDs, asset.ID)
|
||||
}
|
||||
}
|
||||
if asset.Stage == stage && len(asset.PhaseAnchors) > 0 {
|
||||
contract.PhaseAnchors = append(contract.PhaseAnchors, asset.PhaseAnchors...)
|
||||
}
|
||||
}
|
||||
if stage == StageResearch {
|
||||
contract.PhaseAnchors = []string{"Phase 3 - Build source material"}
|
||||
}
|
||||
if stage == StageSlideContent {
|
||||
contract.PhaseAnchors = []string{"Phase 6 - Write slide_content.md"}
|
||||
}
|
||||
if stage == StageAssets {
|
||||
contract.PhaseAnchors = appendUnique(contract.PhaseAnchors, "Phase 7 - Lock the visual direction & plan visuals")
|
||||
contract.PhaseAnchors = appendUnique(contract.PhaseAnchors, "<visuals>")
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func (asset PromptAssetContract) AlwaysForPromptContext(stage string) bool {
|
||||
return asset.Role == "reference_index" || asset.Role == "semantic_contract" || asset.Role == "runtime_binding" || asset.Stage == stage
|
||||
}
|
||||
|
||||
func RequiredToolCallsForStage(stage string, run Run) ([]ToolCallRequirement, error) {
|
||||
contract, err := BuildToolInvocationContract(run, stage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var calls []ToolCallRequirement
|
||||
for _, call := range contract.RequiredCalls {
|
||||
if call.Stage == stage {
|
||||
calls = append(calls, call)
|
||||
}
|
||||
}
|
||||
return calls, nil
|
||||
}
|
||||
|
||||
func TriggeredConditionalToolCalls(stage string, run Run, safeRoot string) ([]ToolCallRequirement, error) {
|
||||
contract, err := BuildToolInvocationContract(run, stage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var calls []ToolCallRequirement
|
||||
for _, call := range contract.ConditionalCalls {
|
||||
if call.Stage != stage {
|
||||
continue
|
||||
}
|
||||
matched, err := conditionMatched(call.Condition, run, safeRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if matched {
|
||||
calls = append(calls, call)
|
||||
}
|
||||
}
|
||||
return calls, nil
|
||||
}
|
||||
|
||||
func BuildAgentTask(stage Stage, run Run, safeRoot string, inputs, outputs []string) (AgentTask, StagePromptContract, ToolInvocationContract, error) {
|
||||
promptContract, err := RequiredPromptContractForStage(stage.Name, run)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
promptContext, err := promptContextForPromptContract(promptContract)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
requiredCalls, err := RequiredToolCallsForStage(stage.Name, run)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
conditionalCalls, err := TriggeredConditionalToolCalls(stage.Name, run, safeRoot)
|
||||
if err != nil {
|
||||
return AgentTask{}, StagePromptContract{}, ToolInvocationContract{}, err
|
||||
}
|
||||
stageContract := ToolInvocationContract{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
RequiredCalls: requiredCalls,
|
||||
ConditionalCalls: conditionalCalls,
|
||||
}
|
||||
task := AgentTask{
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
Stage: stage.Name,
|
||||
Objective: stageObjective(stage.Name),
|
||||
Orchestrator: promptContract.Orchestrator,
|
||||
ProtocolReference: promptContract.ProtocolReference,
|
||||
RequiredPrompts: promptContract.RequiredPromptIDs,
|
||||
RequiredCalls: requiredCalls,
|
||||
ConditionalCalls: conditionalCalls,
|
||||
PhaseAnchors: promptContract.PhaseAnchors,
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
CompletionGate: completionGateForStage(stage.Name, requiredCalls, conditionalCalls),
|
||||
ToolCallReceiptDir: filepath.ToSlash(filepath.Join("receipts", "tool_calls", stage.Name)),
|
||||
PromptContext: promptContext,
|
||||
}
|
||||
return task, promptContract, stageContract, nil
|
||||
}
|
||||
|
||||
func WritePromptContextReceipt(safeRoot string, stageName string, task AgentTask, promptContract StagePromptContract, toolContract ToolInvocationContract) error {
|
||||
assetHashes := map[string]string{}
|
||||
for _, asset := range task.PromptContext.Assets {
|
||||
assetHashes[asset.ID] = asset.SHA256
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, promptContextReceiptPath(stageName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, PromptContextReceipt{
|
||||
Stage: stageName,
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
AgentTask: task,
|
||||
PromptContract: promptContract,
|
||||
ToolInvocationContract: toolContract,
|
||||
AssetHashes: assetHashes,
|
||||
})
|
||||
}
|
||||
|
||||
func ValidatePromptContextForStage(safeRoot string, stageName string, run Run) (PromptContextReceipt, error) {
|
||||
if stageName == StageRequest {
|
||||
return PromptContextReceipt{}, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, promptContextReceiptPath(stageName))
|
||||
if err != nil {
|
||||
return PromptContextReceipt{}, fmt.Errorf("missing_prompt_context: %w", err)
|
||||
}
|
||||
var receipt PromptContextReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return PromptContextReceipt{}, fmt.Errorf("invalid prompt context receipt: %w", err)
|
||||
}
|
||||
if receipt.Stage != stageName {
|
||||
return PromptContextReceipt{}, fmt.Errorf("wrong_stage_prompt_context: got %q want %q", receipt.Stage, stageName)
|
||||
}
|
||||
expectedContract, err := RequiredPromptContractForStage(stageName, run)
|
||||
if err != nil {
|
||||
return PromptContextReceipt{}, err
|
||||
}
|
||||
expectedContext, err := promptContextForPromptContract(expectedContract)
|
||||
if err != nil {
|
||||
return PromptContextReceipt{}, err
|
||||
}
|
||||
allowedIDs := make(map[string]string, len(expectedContext.Assets))
|
||||
requiredIDs := make(map[string]string, len(expectedContext.Assets))
|
||||
for _, asset := range expectedContext.Assets {
|
||||
allowedIDs[asset.ID] = asset.SHA256
|
||||
if !asset.Required {
|
||||
continue
|
||||
}
|
||||
requiredIDs[asset.ID] = asset.SHA256
|
||||
}
|
||||
for id, want := range requiredIDs {
|
||||
got, ok := receipt.AssetHashes[id]
|
||||
if !ok {
|
||||
return PromptContextReceipt{}, fmt.Errorf("missing_prompt_context_asset: %s", id)
|
||||
}
|
||||
if got != want {
|
||||
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", id, got, want)
|
||||
}
|
||||
}
|
||||
for _, asset := range receipt.AgentTask.PromptContext.Assets {
|
||||
want, ok := allowedIDs[asset.ID]
|
||||
if !ok {
|
||||
return PromptContextReceipt{}, fmt.Errorf("prompt context asset %q is not allowed for route profile %q stage %q", asset.ID, run.RouteProfile, stageName)
|
||||
}
|
||||
if strings.TrimSpace(asset.SHA256) != "" && asset.SHA256 != want {
|
||||
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", asset.ID, asset.SHA256, want)
|
||||
}
|
||||
}
|
||||
for id, want := range receipt.AssetHashes {
|
||||
expectedHash, ok := allowedIDs[id]
|
||||
if !ok {
|
||||
return PromptContextReceipt{}, fmt.Errorf("prompt context asset %q is not allowed for route profile %q stage %q", id, run.RouteProfile, stageName)
|
||||
}
|
||||
if expectedHash != want {
|
||||
return PromptContextReceipt{}, fmt.Errorf("stale_prompt_context: prompt %s hash %s want %s", id, want, expectedHash)
|
||||
}
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func ValidateToolCallReceiptsForStage(safeRoot string, stageName string, run Run, receipt PromptContextReceipt) error {
|
||||
if stageName == StageRequest {
|
||||
return nil
|
||||
}
|
||||
requiredCalls, err := RequiredToolCallsForStage(stageName, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conditionalCalls, err := TriggeredConditionalToolCalls(stageName, run, safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calls := append([]ToolCallRequirement{}, requiredCalls...)
|
||||
calls = append(calls, conditionalCalls...)
|
||||
promptIDs := promptIDsFromReceipt(receipt)
|
||||
for _, call := range calls {
|
||||
path := filepath.Join("receipts", "tool_calls", stageName, call.ID+".json")
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("missing_tool_call: %s: %w", call.ID, err)
|
||||
}
|
||||
var toolReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
CallID string `json:"call_id"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
Invocation string `json:"invocation"`
|
||||
Condition string `json:"condition"`
|
||||
ConditionMatched bool `json:"condition_matched"`
|
||||
Order int `json:"order"`
|
||||
Cardinality string `json:"cardinality"`
|
||||
Status string `json:"status"`
|
||||
Consumed []string `json:"consumed"`
|
||||
Produced []string `json:"produced"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &toolReceipt); err != nil {
|
||||
return fmt.Errorf("%s: invalid tool call receipt: %w", path, err)
|
||||
}
|
||||
if toolReceipt.Stage != stageName || toolReceipt.CallID != call.ID || toolReceipt.PromptID != call.PromptID || toolReceipt.Status != StatusDone {
|
||||
return fmt.Errorf("%s: receipt does not satisfy tool call %s", path, call.ID)
|
||||
}
|
||||
if toolReceipt.Invocation != call.Invocation || toolReceipt.Condition != call.Condition || toolReceipt.Cardinality != call.Cardinality || toolReceipt.Order != call.Order {
|
||||
return fmt.Errorf("%s: receipt contract mismatch for tool call %s", path, call.ID)
|
||||
}
|
||||
if !toolReceipt.ConditionMatched {
|
||||
return fmt.Errorf("%s: condition_matched must be true for required tool call %s", path, call.ID)
|
||||
}
|
||||
if !stringSlicesEqual(toolReceipt.Consumed, call.Consumes) {
|
||||
return fmt.Errorf("%s: consumed artifacts = %v, want %v", path, toolReceipt.Consumed, call.Consumes)
|
||||
}
|
||||
if !stringSlicesEqual(toolReceipt.Produced, call.Produces) {
|
||||
return fmt.Errorf("%s: produced artifacts = %v, want %v", path, toolReceipt.Produced, call.Produces)
|
||||
}
|
||||
if !promptIDs[toolReceipt.PromptID] {
|
||||
return fmt.Errorf("%s: prompt_id %q is not in current prompt context", path, toolReceipt.PromptID)
|
||||
}
|
||||
if err := validateToolReceiptArtifactsExist(safeRoot, path, "consumed", toolReceipt.Consumed); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateToolReceiptArtifactsExist(safeRoot, path, "produced", toolReceipt.Produced); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateArtifactPromptContractForStage(safeRoot string, stageName string, outputs []string) error {
|
||||
if stageName == StageRequest || stageName == StageValidatePreviewRepair {
|
||||
return nil
|
||||
}
|
||||
for _, output := range outputs {
|
||||
if hasGlobMeta(output) || !strings.HasSuffix(output, ".json") || strings.HasPrefix(output, "receipts/") || output == "quality_report.json" {
|
||||
continue
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var artifact struct {
|
||||
PromptContract StagePromptContract `json:"prompt_contract"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &artifact); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", output, err)
|
||||
}
|
||||
if artifact.PromptContract.Protocol == "" {
|
||||
return fmt.Errorf("%s: missing prompt_contract", output)
|
||||
}
|
||||
if artifact.PromptContract.Stage != stageName {
|
||||
return fmt.Errorf("%s: prompt_contract.stage = %q, want %q", output, artifact.PromptContract.Stage, stageName)
|
||||
}
|
||||
if artifact.PromptContract.Orchestrator != "mode_system_prompt_svg" {
|
||||
return fmt.Errorf("%s: prompt_contract.orchestrator = %q, want mode_system_prompt_svg", output, artifact.PromptContract.Orchestrator)
|
||||
}
|
||||
if artifact.PromptContract.ProtocolReference != "svg_reference" {
|
||||
return fmt.Errorf("%s: prompt_contract.protocol_reference = %q, want svg_reference", output, artifact.PromptContract.ProtocolReference)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptContextReceiptPath(stage string) string {
|
||||
return filepath.ToSlash(filepath.Join("receipts", "prompt_context", stage+".json"))
|
||||
}
|
||||
|
||||
func promptContextForPromptContract(contract StagePromptContract) (PromptContext, error) {
|
||||
ids := append([]string{}, contract.RequiredPromptIDs...)
|
||||
ids = append(ids, contract.ConditionalPromptIDs...)
|
||||
assets := make([]PromptContextAsset, 0, len(ids))
|
||||
assetByID, err := promptAssetsByID()
|
||||
if err != nil {
|
||||
return PromptContext{}, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
asset, ok := assetByID[id]
|
||||
if !ok {
|
||||
return PromptContext{}, fmt.Errorf("prompt context references unknown prompt id %q", id)
|
||||
}
|
||||
assets = append(assets, PromptContextAsset{
|
||||
ID: id,
|
||||
Role: asset.Role,
|
||||
Path: asset.Path,
|
||||
SHA256: asset.SHA256,
|
||||
Required: slices.Contains(contract.RequiredPromptIDs, id),
|
||||
})
|
||||
}
|
||||
return PromptContext{
|
||||
ReadPolicy: "read_required_assets_before_authoring",
|
||||
Authority: "cli_runtime_protocol",
|
||||
Assets: assets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toolRequirementFromAsset(asset PromptAssetContract) ToolCallRequirement {
|
||||
return ToolCallRequirement{
|
||||
ID: asset.ID,
|
||||
Stage: asset.Stage,
|
||||
PromptID: asset.ID,
|
||||
Invocation: asset.Invocation,
|
||||
Order: asset.Order,
|
||||
Cardinality: asset.Cardinality,
|
||||
Condition: asset.Condition,
|
||||
Consumes: slices.Clone(asset.Consumes),
|
||||
Produces: slices.Clone(asset.Produces),
|
||||
}
|
||||
}
|
||||
|
||||
func promptPathByID(id string) string {
|
||||
for _, entry := range DefaultPromptManifest().Entries {
|
||||
entryID := entry.ID
|
||||
if entryID == "" {
|
||||
entryID = entry.Name
|
||||
}
|
||||
if entryID == id {
|
||||
return entry.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func promptRoleByID(id string) string {
|
||||
for _, entry := range DefaultPromptManifest().Entries {
|
||||
entryID := entry.ID
|
||||
if entryID == "" {
|
||||
entryID = entry.Name
|
||||
}
|
||||
if entryID == id {
|
||||
return entry.Role
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func promptAssetSHA(path string) string {
|
||||
hash, err := promptAssetSHAStrict(path)
|
||||
if err == nil {
|
||||
return hash
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
raw = []byte("missing:" + path)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func promptAssetSHAStrict(path string) (string, error) {
|
||||
raw, err := readPromptAssetFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func readPromptAssetFile(path string) ([]byte, error) {
|
||||
readPath := resolvePromptAssetReadPath(path)
|
||||
raw, err := os.ReadFile(readPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read prompt asset %q: %w", path, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func resolvePromptAssetReadPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return path
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
return filepath.Join(repoRoot, path)
|
||||
}
|
||||
|
||||
func promptAssetsByID() (map[string]PromptAssetContract, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]PromptAssetContract, len(assets))
|
||||
for _, asset := range assets {
|
||||
out[asset.ID] = asset
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func promptIDsFromReceipt(receipt PromptContextReceipt) map[string]bool {
|
||||
ids := make(map[string]bool, len(receipt.AgentTask.PromptContext.Assets)+len(receipt.AssetHashes))
|
||||
for _, asset := range receipt.AgentTask.PromptContext.Assets {
|
||||
ids[asset.ID] = true
|
||||
}
|
||||
for id := range receipt.AssetHashes {
|
||||
ids[id] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateToolReceiptArtifactsExist(safeRoot string, receiptPath string, field string, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return fmt.Errorf("%s: %s must not be empty", receiptPath, field)
|
||||
}
|
||||
for _, rel := range paths {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
return fmt.Errorf("%s: %s contains empty path", receiptPath, field)
|
||||
}
|
||||
if hasGlobMeta(rel) {
|
||||
matches, err := filepath.Glob(filepath.Join(safeRoot, filepath.Clean(rel)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s glob %q invalid: %w", receiptPath, field, rel, err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("%s: %s glob %q matched no artifacts", receiptPath, field, rel)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := readRunRegularArtifact(safeRoot, rel); err != nil {
|
||||
return fmt.Errorf("%s: %s artifact %q invalid: %w", receiptPath, field, rel, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stageObjective(stage string) string {
|
||||
switch stage {
|
||||
case StageRequestResolution:
|
||||
return "识别用户请求的真实实体、主题类型、置信度和歧义;低置信度时阻断后续研究。"
|
||||
case StageResearch:
|
||||
return "基于用户主题和本地/网页资料建立 source material。"
|
||||
case StageDesignBrief:
|
||||
return "调用/遵守 resolve_design_brief,生成 narrative spine、depth、tone、visual system。"
|
||||
case StageOutline:
|
||||
return "调用/遵守 slide_outline,生成 deck outline、页角色、key message 和 style instruction。"
|
||||
case StageSlideContent:
|
||||
return "按 mode_system_prompt_svg Phase 6 生成逐页内容稿、source refs 和 visual intents。"
|
||||
case StageAssets:
|
||||
return "按 <visuals> 规划/准备图片、图表、diagram、fallback;不得无理由全 diagram。"
|
||||
case StageSVGAuthor:
|
||||
return "调用/遵守 activate_slides_edit 和 slides_edit,按 svg_reference 写完整 SVG slides。"
|
||||
case StageValidatePreviewRepair:
|
||||
return "调用/遵守 finish_slides_edit,执行 validate、preview、quality、semantic repair。"
|
||||
default:
|
||||
return "初始化或推进当前 SVGlide run stage。"
|
||||
}
|
||||
}
|
||||
|
||||
func completionGateForStage(stage string, required, conditional []ToolCallRequirement) []string {
|
||||
var gates []string
|
||||
for _, call := range append(append([]ToolCallRequirement{}, required...), conditional...) {
|
||||
gates = append(gates, call.Produces...)
|
||||
}
|
||||
if len(gates) == 0 {
|
||||
switch stage {
|
||||
case StageResearch:
|
||||
gates = []string{"sources_material_ready"}
|
||||
case StageSlideContent:
|
||||
gates = []string{"slide_content_ready"}
|
||||
case StageAssets:
|
||||
gates = []string{"assets_plan_ready"}
|
||||
default:
|
||||
gates = []string{"stage_outputs_ready"}
|
||||
}
|
||||
}
|
||||
return gates
|
||||
}
|
||||
|
||||
func appendUnique(values []string, value string) []string {
|
||||
if value == "" || slices.Contains(values, value) {
|
||||
return values
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func stringSlicesEqual(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if strings.TrimSpace(got[i]) != strings.TrimSpace(want[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func conditionMatched(condition string, run Run, safeRoot string) (bool, error) {
|
||||
switch condition {
|
||||
case "", "always":
|
||||
return true, nil
|
||||
case "svg_has_custom_path":
|
||||
matches, _ := filepath.Glob(filepath.Join(safeRoot, "slides", "*.svg"))
|
||||
for _, path := range matches {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.Contains(string(raw), `slide:shape-type="custom"`) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
case "visual_type_chart":
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.Contains(string(raw), `"type":"chart"`) || strings.Contains(string(raw), `"type": "chart"`), nil
|
||||
case "required_chart_renderer_vega_lite":
|
||||
if contractRequiresVegaLiteChart(safeRoot) {
|
||||
return true, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.Contains(string(raw), `"type":"chart"`) || strings.Contains(string(raw), `"type": "chart"`), nil
|
||||
case "legacy_or_non_chart_svg_visual_reference_only":
|
||||
return run.RouteProfile == routeProfileImportedPPTX || run.RouteProfile == routeProfileLegacyEditor, nil
|
||||
case "input_is_pptx":
|
||||
if run.RouteProfile != routeProfileImportedPPTX {
|
||||
return false, nil
|
||||
}
|
||||
return strings.EqualFold(filepath.Ext(run.Intent.Input), ".pptx") || strings.EqualFold(filepath.Ext(run.Input), ".pptx"), nil
|
||||
case "template_requested":
|
||||
if run.RouteProfile != routeProfileTemplateReference {
|
||||
return false, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "request/request.json")
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.Contains(string(raw), `"template":true`) ||
|
||||
strings.Contains(string(raw), `"template": true`) ||
|
||||
strings.Contains(string(raw), `"template_requested":true`) ||
|
||||
strings.Contains(string(raw), `"template_requested": true`), nil
|
||||
case "outline_changed_after_initial_generation":
|
||||
return false, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func contractRequiresVegaLiteChart(safeRoot string) bool {
|
||||
for _, rel := range []string{"brief/visual_quality_contract.json", "request/entity_resolution.json"} {
|
||||
raw, err := readRunRegularArtifact(safeRoot, rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(raw), `"required_chart_renderer":"vega-lite"`) || strings.Contains(string(raw), `"required_chart_renderer": "vega-lite"`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
194
internal/svglide/prompt_manifest.go
Normal file
194
internal/svglide/prompt_manifest.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const anyGenPromptRoot = "skills/lark-slides/references/anygen-svg"
|
||||
const anyGenSourceFull = "docs/vendor/anygen-svg/source.full.md"
|
||||
|
||||
type PromptManifest struct {
|
||||
Source string `json:"source"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entries []PromptManifestEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type PromptManifestEntry struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Always bool `json:"always,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
OrchestratedBy string `json:"orchestrated_by,omitempty"`
|
||||
Invocation string `json:"invocation,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
Cardinality string `json:"cardinality,omitempty"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
Condition string `json:"condition,omitempty"`
|
||||
Trigger []string `json:"trigger,omitempty"`
|
||||
Consumes []string `json:"consumes,omitempty"`
|
||||
Produces []string `json:"produces,omitempty"`
|
||||
CompletionGate []string `json:"completion_gate,omitempty"`
|
||||
PhaseAnchors []string `json:"phase_anchors,omitempty"`
|
||||
Profiles []string `json:"profiles,omitempty"`
|
||||
Exposure string `json:"exposure,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultPromptManifest() PromptManifest {
|
||||
return PromptManifest{
|
||||
Source: anyGenPromptRoot,
|
||||
Runtime: "agent",
|
||||
Entries: []PromptManifestEntry{
|
||||
sourceSnapshotEntry("anygen_source_full", anyGenSourceFull),
|
||||
referenceEntry("anygen_svg_readme", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "README.md")), "reference_index"),
|
||||
referenceEntry("mode_system_prompt_svg", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "mode_system_prompt_svg.md")), "orchestrator"),
|
||||
referenceEntry("svg_reference", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svg_reference.md")), "protocol_reference"),
|
||||
referenceEntry("anygen_semantic_contract", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "semantic_contract.md")), "semantic_contract"),
|
||||
referenceEntry("svglide_local_runtime_binding", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svglide_local_runtime_binding.md")), "runtime_binding"),
|
||||
referenceEntry("svglide_visual_quality_overlay", filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svglide_visual_quality_overlay.md")), "runtime_binding"),
|
||||
toolEntry("resolve_design_brief", "resolve_design_brief", StageDesignBrief, 1, "once", "always", []string{"request/request.json", "research/research_notes.md"}, []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, []string{"design_brief_resolved", "typography_contract_resolved"}),
|
||||
toolEntry("slide_outline", "slide_outline", StageOutline, 2, "once", "always", []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, []string{"outline/deck.json"}, []string{"deck_outline_valid"}),
|
||||
toolEntry("activate_slides_edit", "activate_slides_edit", StageSVGAuthor, 3, "once", "always", []string{"outline/deck.json"}, []string{"receipts/tool_calls/svg_author/activate_slides_edit.json"}, []string{"slide_edit_activated"}),
|
||||
toolEntry("slides_edit", "slides_edit", StageSVGAuthor, 4, "once_or_more", "always", []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "brief/typography_contract.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "assets/image_candidates.json", "assets/charts/chart_briefs.json", "assets/charts/chart_manifest.json"}, []string{"slides/*.svg"}, []string{"svg_protocol_valid", "slide_matches_outline_content_assets"}),
|
||||
toolEntry("finish_slides_edit", "finish_slides_edit", StageValidatePreviewRepair, 5, "once", "always", []string{"slides/*.svg"}, []string{"receipts/lint.json", "receipts/preview.json", "receipts/rendered_visual.json", "receipts/image_usage.json", "receipts/chart_usage.json", "quality_report.json", "anygen_semantic_report.json", "visual_receipts.json", "creative_quality_report.json", "receipts/chart_quality.json"}, []string{"validate_preview_rendered_visual_quality_semantic_creative_chart_passed"}),
|
||||
conditionalToolEntry("slide_organize", "slide_organize", StageOutline, 6, "zero_or_more", "outline_changed_after_initial_generation", []string{"outline/deck.json"}, []string{"outline/deck.json"}, []string{"outline_structure_updated"}),
|
||||
conditionalToolEntry("compute_custom_shape_bbox", "compute_custom_shape_bbox", StageSVGAuthor, 7, "zero_or_more", "svg_has_custom_path", []string{"slides/*.svg"}, []string{"receipts/tool_calls/svg_author/compute_custom_shape_bbox.json"}, []string{"custom_shape_bbox_resolved"}),
|
||||
toolEntry("resolve_image_assets", "resolve_image_assets", StageAssets, 8, "once", "always", []string{"request/request.json", "request/entity_resolution.json", "research/sources.json", "outline/deck.json", "content/slide_content.json", "brief/visual_system.json"}, []string{"assets/image_candidates.json", "assets/assets_plan.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "receipts/tool_calls/assets/resolve_image_assets.json"}, []string{"image_candidates_recorded", "selected_images_have_source_url", "selected_images_have_role_fit_reason"}),
|
||||
conditionalToolEntry("generate_vega_lite_chart", "generate_vega_lite_chart", StageAssets, 9, "zero_or_more", "required_chart_renderer_vega_lite", []string{"content/slide_content.json", "research/sources.json", "assets/assets_manifest.json", "assets/charts/chart_briefs.json"}, []string{"assets/charts/chart_manifest.json", "assets/charts/specs/*.vl.json"}, []string{"vega_lite_specs_planned"}),
|
||||
conditionalToolEntry("generate_svg_chart", "generate_svg_chart", StageAssets, 10, "zero_or_more", "legacy_or_non_chart_svg_visual_reference_only", []string{"content/slide_content.json", "assets/assets_manifest.json"}, []string{"assets/assets_manifest.json"}, []string{"not_a_standard_chart_producer"}),
|
||||
legacyConditionalToolEntry("slides_convert", "slides_convert", StageResearch, 11, "zero_or_more", "input_is_pptx", []string{"request/source_manifest.json"}, []string{"research/sources.json"}, []string{"slides_converted"}, []string{routeProfileImportedPPTX}),
|
||||
legacyConditionalToolEntry("slides_parse_template", "slides_parse_template", StageAssets, 12, "zero_or_more", "template_requested", []string{"request/request.json"}, []string{"assets/assets_manifest.json"}, []string{"template_parsed"}, []string{routeProfileTemplateReference}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func referenceEntry(id, path, role string) PromptManifestEntry {
|
||||
return PromptManifestEntry{Name: id, ID: id, Path: path, Always: true, Role: role, Invocation: "reference", Profiles: runtimeProfiles(), Exposure: "runtime"}
|
||||
}
|
||||
|
||||
func sourceSnapshotEntry(id, path string) PromptManifestEntry {
|
||||
entry := referenceEntry(id, path, "source_snapshot")
|
||||
entry.Always = false
|
||||
entry.Exposure = "audit"
|
||||
return entry
|
||||
}
|
||||
|
||||
func toolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string) PromptManifestEntry {
|
||||
return PromptManifestEntry{
|
||||
Name: name,
|
||||
ID: name,
|
||||
Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", file+".md")),
|
||||
Stage: stage,
|
||||
Role: "tool_prompt",
|
||||
OrchestratedBy: "mode_system_prompt_svg",
|
||||
Invocation: "required",
|
||||
Order: order,
|
||||
Cardinality: cardinality,
|
||||
Requires: []string{"mode_system_prompt_svg", "svg_reference"},
|
||||
Condition: condition,
|
||||
Trigger: []string{"initial_deck_generation"},
|
||||
Consumes: consumes,
|
||||
Produces: produces,
|
||||
CompletionGate: gate,
|
||||
Profiles: runtimeProfiles(),
|
||||
Exposure: "runtime",
|
||||
}
|
||||
}
|
||||
|
||||
func conditionalToolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string) PromptManifestEntry {
|
||||
entry := toolEntry(name, file, stage, order, cardinality, condition, consumes, produces, gate)
|
||||
entry.Invocation = "conditional"
|
||||
entry.Trigger = []string{condition}
|
||||
return entry
|
||||
}
|
||||
|
||||
func legacyConditionalToolEntry(name, file string, stage string, order int, cardinality, condition string, consumes, produces, gate []string, profiles []string) PromptManifestEntry {
|
||||
entry := conditionalToolEntry(name, file, stage, order, cardinality, condition, consumes, produces, gate)
|
||||
entry.Profiles = profiles
|
||||
entry.Exposure = "legacy"
|
||||
return entry
|
||||
}
|
||||
|
||||
func runtimeProfiles() []string {
|
||||
return []string{RouteProfileLocalSVGDeck, routeProfileImportedPPTX, routeProfileTemplateReference}
|
||||
}
|
||||
|
||||
func ResolvedPromptManifest() (PromptManifest, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return PromptManifest{}, err
|
||||
}
|
||||
entries := make([]PromptManifestEntry, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
entries = append(entries, PromptManifestEntry{
|
||||
Name: asset.ID,
|
||||
ID: asset.ID,
|
||||
Path: asset.Path,
|
||||
SHA256: asset.SHA256,
|
||||
Stage: asset.Stage,
|
||||
Always: asset.Role == "reference_index" || asset.Role == "semantic_contract" || asset.Role == "orchestrator" || asset.Role == "protocol_reference" || asset.Role == "runtime_binding",
|
||||
Role: asset.Role,
|
||||
OrchestratedBy: asset.OrchestratedBy,
|
||||
Invocation: asset.Invocation,
|
||||
Order: asset.Order,
|
||||
Cardinality: asset.Cardinality,
|
||||
Requires: asset.Requires,
|
||||
Condition: asset.Condition,
|
||||
Trigger: asset.Trigger,
|
||||
Consumes: asset.Consumes,
|
||||
Produces: asset.Produces,
|
||||
CompletionGate: asset.CompletionGate,
|
||||
PhaseAnchors: asset.PhaseAnchors,
|
||||
Profiles: asset.Profiles,
|
||||
Exposure: asset.Exposure,
|
||||
})
|
||||
}
|
||||
return PromptManifest{Source: anyGenPromptRoot, Runtime: "agent", Entries: entries}, nil
|
||||
}
|
||||
|
||||
func PromptPathsForStage(stage string) ([]string, error) {
|
||||
return PromptPathsForStageForProfile(RouteProfileLocalSVGDeck, stage)
|
||||
}
|
||||
|
||||
func PromptPathsForStageForProfile(profile string, stage string) ([]string, error) {
|
||||
manifest, err := ResolvedPromptManifest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := make([]string, 0, len(manifest.Entries))
|
||||
for _, entry := range manifest.Entries {
|
||||
if !promptAssetAllowedForProfile(entry.Profiles, profile) {
|
||||
continue
|
||||
}
|
||||
if entry.Always || entry.Stage == stage {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func promptAssetAllowedForProfile(profiles []string, profile string) bool {
|
||||
if strings.TrimSpace(profile) == "" {
|
||||
profile = RouteProfileLocalSVGDeck
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range profiles {
|
||||
if candidate == profile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writePromptManifest(root string) error {
|
||||
manifest, err := ResolvedPromptManifest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(filepath.Join(root, "prompt_manifest.json"), manifest)
|
||||
}
|
||||
1211
internal/svglide/quality.go
Normal file
1211
internal/svglide/quality.go
Normal file
File diff suppressed because it is too large
Load Diff
961
internal/svglide/quality_test.go
Normal file
961
internal/svglide/quality_test.go
Normal file
@@ -0,0 +1,961 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckQualityAllowsExplicitLocalSourceWithoutFullPageWebSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"source.md","title":"Local Source","excerpt":"Input","usage":"Support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["local1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only slide; no image assets required"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsTopicDeckWithoutFullPageWebOrExplicitLocalSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"source1","path":"source.md","title":"Weak Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["source1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.research") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.research", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsSlideContentWithoutSourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":[],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.source_refs") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.source_refs", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingVisualAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityFailsEntityDeckWithoutRealVisualAssets(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/request.json", `{"title":"NVIDIA Financial Report","topic":"Generate a comprehensive financial report for Q4 2023 for Nvidia."}`)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"resolved_entity":{"name":"NVIDIA","type":"company","confidence_bp":9500,"confidence_band":"high","reason":"Named public company"},"ambiguity":{"status":"resolved","candidates":[]},"research_required":true,"clarification_question":""}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"NVIDIA Financial Report","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Financial report","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"nvda","path":"https://investor.nvidia.com/","title":"NVIDIA IR","excerpt":"Financial report","usage":"financial data","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Financial report","source_refs":["nvda"],"visuals":[{"id":"chart","type":"none","instruction":"Chart-only cover"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"This data-report deck does not require raster images; charts are enough."}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[],"no_image_reason":"This data-report deck does not require raster images; charts are enough."}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<rect width="1280" height="720" fill="#fff"/><text x="80" y="120" font-size="48">NVIDIA Q4 Financial Report</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if report.Metrics.VisualAssetIssueCount == 0 {
|
||||
t.Fatalf("visual asset issue count = 0, want > 0")
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visual_asset.cover_real_hero_missing") {
|
||||
t.Fatalf("issues = %+v, want cover hero missing", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityPassesAnyGenReadyRun(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
if report.Metrics.Slides != 1 || report.Metrics.Sources != 1 || report.Metrics.WebSources != 1 || report.Metrics.Assets != 1 || report.Metrics.SlidesWithSourceRef != 1 || report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics = %+v, want all ones", report.Metrics)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
var written QualityReport
|
||||
if err := json.Unmarshal(raw, &written); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written.Status != "passed" {
|
||||
t.Fatalf("written status = %q, want passed", written.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsBrandOfficialSiteWithLowImageCoverage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{
|
||||
"resolved_entity":{"name":"KANEKO OPTICAL","type":"brand"},
|
||||
"visual_quality_contract":{
|
||||
"profile":"brand_official_site",
|
||||
"requires_real_images":true,
|
||||
"min_image_coverage_bp":7000,
|
||||
"min_unique_images":6,
|
||||
"min_official_images":4,
|
||||
"allow_repeated_hero_only":false,
|
||||
"reason":"真实品牌官网主题需要官网图片资产支撑。"
|
||||
}
|
||||
}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Kaneko","slides":[
|
||||
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"},
|
||||
{"id":"s2","title":"Thesis","summary":"Thesis","role":"thesis","key_message":"Thesis","path":"slides/02.svg"},
|
||||
{"id":"s3","title":"History","summary":"History","role":"history","key_message":"History","path":"slides/03.svg"},
|
||||
{"id":"s4","title":"Factory","summary":"Factory","role":"factory","key_message":"Factory","path":"slides/04.svg"},
|
||||
{"id":"s5","title":"Product","summary":"Product","role":"product","key_message":"Product","path":"slides/05.svg"},
|
||||
{"id":"s6","title":"Retail","summary":"Retail","role":"retail","key_message":"Retail","path":"slides/06.svg"},
|
||||
{"id":"s7","title":"Process","summary":"Process","role":"process","key_message":"Process","path":"slides/07.svg"},
|
||||
{"id":"s8","title":"Closing","summary":"Closing","role":"closing","key_message":"Closing","path":"slides/08.svg"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[
|
||||
{"id":"kaneko-home","path":"https://www.kaneko-optical.co.jp/zh-CHS/","title":"Home","excerpt":"Official site","usage":"identity","retrieval":"full_page"},
|
||||
{"id":"user-hero-image","path":"/Users/bytedance/Downloads/image_gwnb.png","title":"User image","excerpt":"Hero","usage":"visual reference","retrieval":"user_provided"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[
|
||||
{"id":"s1","content":"Cover","source_refs":["kaneko-home","user-hero-image"],"visuals":[{"id":"asset-cover","type":"image","instruction":"Hero"}]},
|
||||
{"id":"s2","content":"Thesis","source_refs":["kaneko-home"],"visuals":[{"id":"none-s2","type":"none","instruction":"Native diagram"}]},
|
||||
{"id":"s3","content":"History","source_refs":["kaneko-home"],"visuals":[{"id":"none-s3","type":"none","instruction":"Native timeline"}]},
|
||||
{"id":"s4","content":"Factory","source_refs":["kaneko-home"],"visuals":[{"id":"none-s4","type":"none","instruction":"Native matrix"}]},
|
||||
{"id":"s5","content":"Product","source_refs":["kaneko-home"],"visuals":[{"id":"none-s5","type":"none","instruction":"Native cards"}]},
|
||||
{"id":"s6","content":"Retail","source_refs":["kaneko-home"],"visuals":[{"id":"none-s6","type":"none","instruction":"Native metrics"}]},
|
||||
{"id":"s7","content":"Process","source_refs":["kaneko-home"],"visuals":[{"id":"none-s7","type":"none","instruction":"Native rail"}]},
|
||||
{"id":"s8","content":"Closing","source_refs":["kaneko-home","user-hero-image"],"visuals":[{"id":"asset-closing","type":"image","instruction":"Hero again"}]}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
|
||||
{"id":"asset-cover","slide_id":"s1","visual_id":"asset-cover","kind":"image","local_path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","status":"ready","usage":"cover"},
|
||||
{"id":"asset-closing","slide_id":"s8","visual_id":"asset-closing","kind":"image","local_path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","status":"ready","usage":"closing"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[
|
||||
{"id":"asset-cover","path":"assets/images/hero.png","source_url":"file:///Users/bytedance/Downloads/image_gwnb.png","width":1704,"height":868,"semantic_type":"brand hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":""}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png"/><text>Cover</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/slides/08.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png"/><text>Closing</text></svg>`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for low image coverage; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_coverage") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.image_coverage", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsWeakCoverWhenVisualContractRequiresStrongCover(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand","must_have":{"strong_cover":true}}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","visual_intent":"Use a strong first impression","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/hero.png" x="40" y="40" width="320" height="180"/><text x="48" y="260">Cover</text></svg>`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for weak cover; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.weak_cover") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.weak_cover", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsPosterOnlyCoverWhenRealHeroImageRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","cover_requires_real_hero_image":true}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"NVIDIA Report","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"nvidia","path":"https://www.nvidia.com/en-us/about-nvidia/","title":"NVIDIA","excerpt":"Official source","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["nvidia"],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/generated-chip.svg","source_url":"","status":"ready","usage":"Generated chip hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/generated-chip.svg", `<svg/>`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/generated-chip.svg" x="0" y="0" width="960" height="540"/><text x="48" y="120" font-size="72">NVIDIA</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "full_bleed_hero", "full_bleed_generated_svg")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for generated SVG cover; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.cover_real_hero_image") {
|
||||
t.Fatalf("issues = %+v, want cover_real_hero_image", report.Issues)
|
||||
}
|
||||
if report.Metrics.CoverRealHeroImage {
|
||||
t.Fatalf("cover_real_hero_image = true, want false for generated SVG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityDoesNotCountPreviewSlideSVGImagesAsRealImages(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", `{"resolved_entity":{"name":"Demo","type":"brand"},"visual_quality_contract":{"profile":"brand_official_site","requires_real_images":true,"min_image_coverage_bp":10000,"min_unique_images":1,"forbid_preview_wrapper_images_as_real_images":true}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"slides/01.svg","source_url":"","status":"ready","usage":"Preview wrapper"}]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<text x="48" y="80">Cover</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Metrics.RealImageAssets != 0 || report.Metrics.SlidesWithRealImageAssets != 0 {
|
||||
t.Fatalf("metrics = %+v, preview slide SVG must not count as real image", report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.preview_wrapper_image") {
|
||||
t.Fatalf("issues = %+v, want preview_wrapper_image", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingVegaLiteManifestWhenRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1}}`)
|
||||
mustWriteVegaLiteQualityDeck(t)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for missing Vega-Lite manifest", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.missing_chart_manifest") {
|
||||
t.Fatalf("issues = %+v, want missing_chart_manifest", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsHandwrittenChartWhenVegaLiteRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","required_chart_renderer":"vega-lite","min_chart_svg_assets":1,"min_vega_lite_specs":1}}`)
|
||||
mustWriteVegaLiteQualityDeck(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"svg","charts":[{"id":"revenue","slide_id":"s1","renderer":"svg","svg_path":"assets/charts/revenue.svg"}]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for hand-written SVG chart", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.chart_renderer") {
|
||||
t.Fatalf("issues = %+v, want chart_renderer", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingTypographyContractWhenRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"profile":"data_report","typography_contract_required":true}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Financial Report","slides":[{"id":"s1","title":"Summary","summary":"Summary","role":"cover","key_message":"Summary","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"none","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only report summary"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for missing typography contract", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.missing_typography_contract") && !qualityIssueCodesContain(report.Issues, "svglide.quality.typography_contract") {
|
||||
t.Fatalf("issues = %+v, want typography contract failure", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVisualContractMissingRealImagesFixtureFailsClosed(t *testing.T) {
|
||||
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture := filepath.Join(repoRoot, "testdata", "svglide", "strict_visual_contract_missing_real_images")
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
runRoot := filepath.Join(cwd, "fixture")
|
||||
copyTestDir(t, fixture, runRoot)
|
||||
|
||||
report, err := CheckQuality("fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
for _, want := range []string{"svglide.quality.image_coverage", "svglide.quality.cover_real_hero_image"} {
|
||||
if !qualityIssueCodesContain(report.Issues, want) {
|
||||
t.Fatalf("issues = %+v, want %s", report.Issues, want)
|
||||
}
|
||||
}
|
||||
if report.Metrics.RealImageAssets != 0 || report.Metrics.GeneratedSVGAssets == 0 {
|
||||
t.Fatalf("metrics = %+v, want generated SVG but zero real images", report.Metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func copyTestDir(t *testing.T, src string, dst string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
if entry.IsDir() {
|
||||
copyTestDir(t, srcPath, dstPath)
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dstPath, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsLowEvidenceDensityWhenVisualContractRequiresEvidenceGrid(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand_factory","must_have":{"evidence_page_min_visuals":4,"visual_roles_required":["hero_cover","evidence_grid"]}}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[
|
||||
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","visual_intent":"Full bleed cover image","key_message":"Cover","path":"slides/01.svg"},
|
||||
{"id":"s2","title":"Process","summary":"Process","role":"process","visual_role":"evidence_grid","visual_intent":"Use process images as evidence","key_message":"Process","path":"slides/02.svg"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[
|
||||
{"id":"s1","content":"Cover","source_refs":["web1"],"visuals":[{"id":"cover","type":"image","instruction":"Cover image"}]},
|
||||
{"id":"s2","content":"Process","source_refs":["web1"],"visuals":[{"id":"p1","type":"image","instruction":"Process 1"},{"id":"p2","type":"image","instruction":"Process 2"}]}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
|
||||
{"id":"cover","slide_id":"s1","visual_id":"cover","kind":"image","local_path":"assets/images/cover.png","source_url":"https://example.com/cover.png","status":"ready","usage":"Cover"},
|
||||
{"id":"p1","slide_id":"s2","visual_id":"p1","kind":"image","local_path":"assets/images/p1.png","source_url":"https://example.com/p1.png","status":"ready","usage":"Process 1"},
|
||||
{"id":"p2","slide_id":"s2","visual_id":"p2","kind":"image","local_path":"assets/images/p2.png","source_url":"https://example.com/p2.png","status":"ready","usage":"Process 2"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/cover.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p1.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p2.png", "png")
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/cover.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/slides/02.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/p1.png" x="40" y="40" width="320" height="180"/><image slide:role="image" href="../assets/images/p2.png" x="400" y="40" width="320" height="180"/><text x="48" y="300">Process</text></svg>`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for low evidence density; metrics=%+v", report.Status, report.Metrics)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.low_evidence_density") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.low_evidence_density", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKanekoCalibrationFixtureIsQualityFloorOnly(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "svglide", "visual_quality", "kaneko_baseline_calibration.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
BenchmarkUsage string `json:"benchmark_usage"`
|
||||
DeckType string `json:"deck_type"`
|
||||
Minimums struct {
|
||||
StrongCover bool `json:"strong_cover"`
|
||||
EvidencePageMinVisuals int `json:"evidence_page_min_visuals"`
|
||||
SemanticImageCoverageMinBP int `json:"semantic_image_coverage_min_bp"`
|
||||
} `json:"minimums"`
|
||||
QualityDimensions []string `json:"quality_dimensions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fixture.BenchmarkUsage != "quality_floor_only" {
|
||||
t.Fatalf("benchmark_usage = %q, want quality_floor_only", fixture.BenchmarkUsage)
|
||||
}
|
||||
if fixture.DeckType != "brand_factory" || !fixture.Minimums.StrongCover || fixture.Minimums.EvidencePageMinVisuals < 12 || fixture.Minimums.SemanticImageCoverageMinBP < 9000 {
|
||||
t.Fatalf("fixture = %+v, want脱敏质量下限指标", fixture)
|
||||
}
|
||||
if strings.Contains(string(raw), "<svg") || strings.Contains(string(raw), "viewBox") || strings.Contains(string(raw), "foreignObject") {
|
||||
t.Fatalf("fixture leaks SVG source: %s", string(raw))
|
||||
}
|
||||
if len(fixture.QualityDimensions) == 0 {
|
||||
t.Fatalf("quality_dimensions empty: %+v", fixture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsExperimentAssetsAndDeferredUnsupportedVisuals(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Full page excerpt","usage":"evidence","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"Chart claim","summary":"Needs chart later","role":"content","key_message":"Chart is deferred","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Chart-backed point","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a remote hero image"},{"id":"chart1","type":"chart","instruction":"Use a real chart when chart generation is enabled"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"},{"id":"chart1","slide_id":"s1","type":"chart","path":"","usage":"Deferred chart generation","status":"deferred"}]}`)
|
||||
mustWriteQualitySlideWithImage(t, "https://example.com/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsAbsoluteReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
mustWriteQualitySlideWithImage(t, outside)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityAllowsHeroPhotoJPGWhenFullBleedReady(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"hero","path":"assets/images/hero.jpg","source_url":"https://example.com/hero.jpg","width":1600,"height":900,"semantic_type":"hero","large_ok":true,"full_bleed_ok":true,"recommended_use":"cover","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","selection_reason":"high-resolution official hero photo"}]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if qualityIssueCodesContain(report.Issues, "svglide.quality.image_role_format") {
|
||||
t.Fatalf("hero jpg should not be rejected as PNG-only: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityRejectsTransparentSubjectWithoutAlphaOrFallback(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/product.jpg" x="120" y="80" width="520" height="360"/><text x="80" y="620">Cover</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"product","slide_id":"s1","kind":"image","local_path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","status":"ready","usage":"Floating product"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"product","path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","width":1200,"height":800,"semantic_type":"product","large_ok":true,"full_bleed_ok":false,"recommended_use":"floating product","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"floating_product","fit_role":"floating_subject","selection_reason":""}]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.image_role_format") {
|
||||
t.Fatalf("expected floating product jpg without alpha/format_exception_reason to fail: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityAllowsTransparentSubjectWithFormatExceptionReason(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
root := "demo"
|
||||
writeMinimalImageQualityDeckForTest(t)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/product.jpg" x="120" y="80" width="520" height="360"/><text x="80" y="620">Cover</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"product","slide_id":"s1","kind":"image","local_path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","status":"ready","usage":"Floating product"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"items":[{"id":"product","path":"assets/images/product.jpg","source_url":"https://example.com/product.jpg","width":1200,"height":800,"semantic_type":"product","large_ok":true,"full_bleed_ok":false,"recommended_use":"floating product","avoid_reason":"","format":"jpg","has_alpha":false,"asset_role":"floating_product","fit_role":"floating_subject","selection_reason":"best official product image despite no transparent cutout","format_exception_reason":"official source only provides JPG; SVG author must mask/crop on clean background"}]}`)
|
||||
|
||||
report, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if qualityIssueCodesContain(report.Issues, "svglide.quality.image_role_format") {
|
||||
t.Fatalf("structured format_exception_reason should satisfy image format exception: %#v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsEmptyVisuals(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visuals") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.visuals", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsVisualAssetTypeMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsAllDiagramWithoutNoImageReason(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Movie Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Movie hook","path":"slides/01.svg"},{"id":"s2","title":"Context","summary":"Context summary","role":"content","key_message":"Movie context","path":"slides/02.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/movie","title":"Movie Source","excerpt":"Movie excerpt","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":["web1"],"visuals":[{"id":"diagram1","type":"diagram","instruction":"Diagram fallback"}]},{"id":"s2","content":"Context","source_refs":["web1"],"visuals":[{"id":"diagram2","type":"diagram","instruction":"Diagram fallback"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"diagram1","slide_id":"s1","type":"diagram","path":"assets/images/diagram1.svg","usage":"Diagram fallback","status":"ready"},{"id":"diagram2","slide_id":"s2","type":"diagram","path":"assets/images/diagram2.svg","usage":"Diagram fallback","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/diagram1.svg", `<svg/>`)
|
||||
mustWriteTestFile(t, "demo/assets/images/diagram2.svg", `<svg/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want semantic failure for all-diagram fallback without no_image_reason; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainRule(report.Findings, "no_silent_all_diagram_fallback") {
|
||||
t.Fatalf("findings = %+v, want no_silent_all_diagram_fallback", report.Findings)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "anygen_semantic_report.json")); err != nil {
|
||||
t.Fatalf("missing anygen_semantic_report.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func semanticFindingsContainRule(findings []SemanticFinding, ruleID string) bool {
|
||||
for _, finding := range findings {
|
||||
if finding.RuleID == ruleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsUnsafeReadyImageAssetPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
assetPath string
|
||||
setupAsset func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "file url",
|
||||
assetPath: "file:///tmp/secret.png",
|
||||
},
|
||||
{
|
||||
name: "absolute path",
|
||||
assetPath: "/Users/example/secret.png",
|
||||
},
|
||||
{
|
||||
name: "missing local asset",
|
||||
assetPath: "assets/images/missing.png",
|
||||
},
|
||||
{
|
||||
name: "symlink local asset",
|
||||
assetPath: "assets/images/hero.png",
|
||||
setupAsset: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile("outside-hero.png", []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if tt.setupAsset != nil {
|
||||
tt.setupAsset(t)
|
||||
}
|
||||
writeSemanticImageDeck(t, tt.assetPath)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for unsafe ready image asset path %q; findings=%+v", report.Status, tt.assetPath, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.asset_path", report.Findings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsBrowserRelativeMissingImageHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="assets/images/hero.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed because browser resolves href against slides/ directory; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportAllowsBrowserRelativeImageHrefAndNoteSources(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Deck","slides":[{"id":"s1","title":"Cover","summary":"Cover","role":"cover","key_message":"Cover","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Cover","source_refs":["kaneko-home"],"visuals":[{"id":"hero","type":"image","instruction":"Hero"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"hero","slide_id":"s1","visual_id":"hero","kind":"image","local_path":"assets/images/hero.png","source_url":"https://example.com/hero.png","status":"ready","usage":"Hero"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/hero.png", "png")
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<slide:note>Sources: kaneko-home</slide:note><image slide:role="image" href="../assets/images/hero.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed for browser-relative image href and note source marker; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if report.Metrics.MissingAssetCount != 0 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 0", report.Metrics.MissingAssetCount)
|
||||
}
|
||||
if report.Metrics.VisibleLeakCount != 0 {
|
||||
t.Fatalf("VisibleLeakCount = %d, want 0 for slide:note source marker", report.Metrics.VisibleLeakCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportAllowsRegisteredChartHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, "../assets/charts/revenue.svg")
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed for registered chart href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsUnregisteredChartHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[]}`, "../assets/charts/revenue.svg")
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for unregistered chart href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsUnsafeReadyChartHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
writeSemanticChartDeck(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"file:///tmp/secret.svg","usage":"Revenue chart","status":"ready"}]}`, "file:///tmp/secret.svg")
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for unsafe chart href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.browser_asset_path") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.browser_asset_path", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsChartAssetUsedAsImageHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
|
||||
writeSemanticDeckWithSlideBody(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, `<image slide:role="image" href="../assets/charts/revenue.svg" x="80" y="80" width="640" height="360"/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for chart asset used as image href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_type") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.asset_type", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsExternalUseHref(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg/>`)
|
||||
writeSemanticDeckWithSlideBody(t, `{"mode":"experiment_unrestricted_assets","no_image_reason":"Chart-only deck; no photo assets required","assets":[{"id":"chart1","slide_id":"s1","type":"chart","path":"assets/charts/revenue.svg","usage":"Revenue chart","status":"ready"}]}`, `<use href="../assets/charts/revenue.svg" x="80" y="80" width="640" height="360"/>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed for external use href; findings=%+v", report.Status, report.Findings)
|
||||
}
|
||||
if !semanticFindingsContainCode(report.Findings, "svglide.semantic.asset_type") {
|
||||
t.Fatalf("findings = %+v, want svglide.semantic.asset_type", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func semanticFindingsContainCode(findings []SemanticFinding, code string) bool {
|
||||
for _, finding := range findings {
|
||||
if finding.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeSemanticImageDeck(t *testing.T, assetPath string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Image Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Image hook","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+assetPath+`","usage":"Hero image","status":"ready"}]}`)
|
||||
mustWriteQualitySlideWithImage(t, assetPath)
|
||||
}
|
||||
|
||||
func writeSemanticChartDeck(t *testing.T, assetsPlan string, chartHref string) {
|
||||
t.Helper()
|
||||
writeSemanticDeckWithSlideBody(t, assetsPlan, `<rect slide:role="chart" href="`+chartHref+`" x="80" y="80" width="640" height="360"/>`)
|
||||
}
|
||||
|
||||
func writeSemanticDeckWithSlideBody(t *testing.T, assetsPlan string, slideBody string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Chart Deck","slides":[{"id":"s1","title":"Revenue","summary":"Revenue summary","role":"content","key_message":"Revenue changed","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Revenue changed","source_refs":[],"visuals":[{"id":"chart1","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", assetsPlan)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/>`+slideBody+`<text x="48" y="500">Revenue</text></svg>`)
|
||||
}
|
||||
|
||||
func TestCheckQualityCountsSlidesWithVisualsPerPage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"},{"id":"logo","type":"diagram","instruction":"Support diagram"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"},{"id":"logo","slide_id":"s1","type":"diagram","path":"assets/images/logo.svg","usage":"Support diagram","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "logo.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics.slides_with_visuals = %d, want 1", report.Metrics.SlidesWithVisuals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsSymlinkReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("outside-hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteQualitySlideWithImage(t, "assets/images/hero.png")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityUsesOutlineDeckNotRunArtifactDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeStatusTestRunFile(t, run)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "custom"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/custom/deck.json", `{"title":"Custom Deck","slides":[{"id":"c1","title":"Custom 1","summary":"Custom summary 1","role":"cover","key_message":"Custom key 1","path":"slides/01.svg"},{"id":"c2","title":"Custom 2","summary":"Custom summary 2","role":"content","key_message":"Custom key 2","path":"slides/02.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]},{"id":"c1","content":"Custom claim 1","source_refs":["web1"],"visuals":[{"id":"v2","type":"none","instruction":"Text-only"}]},{"id":"c2","content":"Custom claim 2","source_refs":["web1"],"visuals":[{"id":"v3","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only slide; no image assets required"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "quiet_synthesis", "single_claim_poster")
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.Slides != 1 {
|
||||
t.Fatalf("metrics.slides = %d, want 1 from outline/deck.json", report.Metrics.Slides)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteVegaLiteQualityDeck(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Financial Deck","slides":[{"id":"s1","title":"Revenue","summary":"Revenue trend","role":"content","key_message":"Revenue expanded","layout_family":"data_scoreboard","layout_archetype":"data_scoreboard","layout_signature":"single_chart","story_function":"proof","primary_asset_role":"financial chart","fusion_candidate":false,"path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"10k","path":"https://example.com/annual-report","title":"Annual report","excerpt":"Revenue was 60.9 billion","usage":"financial data","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Revenue expanded","source_refs":["10k"],"visuals":[{"id":"revenue","type":"chart","instruction":"Revenue chart"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[{"id":"revenue","slide_id":"s1","visual_id":"revenue","kind":"chart","local_path":"assets/charts/revenue.svg","source_url":"","status":"ready","usage":"Revenue chart"}],"no_image_reason":"Financial chart page; no photo required"}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/revenue.svg", `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/><rect slide:role="chart" href="../assets/charts/revenue.svg" x="80" y="96" width="720" height="320"/><text x="48" y="480">Revenue: 60.9B</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"proof","layout_family":"data_scoreboard","layout_archetype":"data_scoreboard","layout_signature":"single_chart","thumbnail_job":"chart proof","visual_center":"revenue chart","topic_fit_claim":"matches financial report","information_density_plan":"one main chart and one sourced note","page_difference_from_previous":"first chart page","primary_asset":"assets/charts/revenue.svg","asset_role":"financial proof","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"financial analysis chart","data_visual_rationale":"shows revenue change with sourced numbers","source_evidence":["FY revenue 60.9 billion from 10k"],"fusion_spec":{"enabled":false},"qa_expectations":["chart has numeric evidence"]}]}`)
|
||||
}
|
||||
|
||||
func mustWriteQualitySlideWithImage(t *testing.T, href string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<rect width="960" height="540" fill="#fff"/><image slide:role="image" href="`+svgHrefForTestAsset(href)+`" x="40" y="40" width="320" height="180"/><text x="48" y="260">Claim</text></svg>`)
|
||||
mustWriteQualityVisualReceiptForTest(t, "s1", "character_product_focus", "image_claim")
|
||||
}
|
||||
|
||||
func mustWriteQualityVisualReceiptForTest(t *testing.T, slideID string, family string, signature string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"`+slideID+`","story_job":"hook","layout_family":"`+family+`","layout_archetype":"`+inferAuthorLayoutArchetype(family, signature)+`","layout_signature":"`+signature+`","thumbnail_job":"readable claim","visual_center":"topic claim and supporting visual","topic_fit_claim":"matches the requested topic","information_density_plan":"one clear claim with supporting proof","page_difference_from_previous":"distinct opening treatment","primary_asset":"hero","asset_role":"topic anchor","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"focused editorial slide","data_visual_rationale":"","source_evidence":["web1 supports this claim"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
}
|
||||
|
||||
func svgHrefForTestAsset(path string) string {
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
return "../" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func qualityIssueCodesContain(issues []QualityIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
153
internal/svglide/receipt.go
Normal file
153
internal/svglide/receipt.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type validationLintReceipt struct {
|
||||
Status string `json:"status"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
func writeValidationArtifacts(safeRoot string, report ValidationReport) error {
|
||||
report = normalizeValidationReport(report)
|
||||
lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(validationLintReceipt{
|
||||
Status: validationReceiptStatus(report),
|
||||
Issues: report.Issues,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644)
|
||||
}
|
||||
|
||||
func normalizeValidationReport(report ValidationReport) ValidationReport {
|
||||
if report.Issues == nil {
|
||||
report.Issues = []ValidationIssue{}
|
||||
}
|
||||
report.OK = len(report.Issues) == 0
|
||||
for i := range report.Issues {
|
||||
report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path)
|
||||
if report.Issues[i].Path == "" {
|
||||
report.Issues[i].Path = "(deck)"
|
||||
}
|
||||
report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code)
|
||||
if report.Issues[i].Code == "" {
|
||||
report.Issues[i].Code = "svglide.validation"
|
||||
}
|
||||
report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity)
|
||||
if report.Issues[i].Severity == "" {
|
||||
report.Issues[i].Severity = "error"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func validationReceiptStatus(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "passed"
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
|
||||
func renderRepairQueue(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "No repair needed.\n"
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Repair Queue\n\n")
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) {
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return "", fmt.Errorf("run file path must not be root")
|
||||
}
|
||||
dirRel := filepath.Dir(cleanRel)
|
||||
if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := safeRunPath(safeRoot, cleanRel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run file path %q must not be a symlink", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("run file path %q must be a regular file", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return path, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err := vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := vfs.Mkdir(cur, 0o755); err != nil {
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
731
internal/svglide/rendered_visual.go
Normal file
731
internal/svglide/rendered_visual.go
Normal file
@@ -0,0 +1,731 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const renderedVisualReceiptPath = "receipts/rendered_visual.json"
|
||||
|
||||
type RenderedVisualReport struct {
|
||||
Status string `json:"status"`
|
||||
Metrics RenderedVisualMetrics `json:"metrics"`
|
||||
Issues []RenderedVisualIssue `json:"issues"`
|
||||
Slides []RenderedVisualSlideItem `json:"slides"`
|
||||
}
|
||||
|
||||
type RenderedVisualMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
OutOfCanvasCount int `json:"out_of_canvas_count"`
|
||||
TextOverflowCount int `json:"text_overflow_count"`
|
||||
TextCollisionCount int `json:"text_collision_count"`
|
||||
UnsafeEdgeCount int `json:"unsafe_edge_count"`
|
||||
ContainerTextOverflowCount int `json:"container_text_overflow_count"`
|
||||
ContainerPaddingRiskCount int `json:"container_padding_risk_count"`
|
||||
ForeignObjectOverlapCount int `json:"foreign_object_overlap_count"`
|
||||
TightLineHeightCount int `json:"tight_line_height_count"`
|
||||
BoldOveruseCount int `json:"bold_overuse_count"`
|
||||
SmallTextPaddingRiskCount int `json:"small_text_padding_risk_count"`
|
||||
}
|
||||
|
||||
type RenderedVisualIssue struct {
|
||||
Path string `json:"path"`
|
||||
SlideID string `json:"slide_id,omitempty"`
|
||||
ElementID string `json:"element_id,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
X float64 `json:"x,omitempty"`
|
||||
Y float64 `json:"y,omitempty"`
|
||||
Width float64 `json:"width,omitempty"`
|
||||
Height float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
type RenderedVisualSlideItem struct {
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
}
|
||||
|
||||
type renderedVisualBox struct {
|
||||
Path string
|
||||
ElementID string
|
||||
Kind string
|
||||
Text string
|
||||
X float64
|
||||
Y float64
|
||||
Width float64
|
||||
Height float64
|
||||
RequiredHeight float64
|
||||
FontSize float64
|
||||
LineHeight float64
|
||||
FontWeight float64
|
||||
}
|
||||
|
||||
type renderedVisualContainer struct {
|
||||
Path string
|
||||
ElementID string
|
||||
Kind string
|
||||
X float64
|
||||
Y float64
|
||||
Width float64
|
||||
Height float64
|
||||
RX float64
|
||||
Fill string
|
||||
Stroke string
|
||||
Opacity float64
|
||||
}
|
||||
|
||||
type renderedVisualParseState struct {
|
||||
TranslateX float64
|
||||
TranslateY float64
|
||||
FontSize float64
|
||||
LineHeight float64
|
||||
FontWeight float64
|
||||
TextAnchor string
|
||||
InText bool
|
||||
InForeign bool
|
||||
ElementID string
|
||||
TextX float64
|
||||
TextY float64
|
||||
Text string
|
||||
Foreign renderedVisualBox
|
||||
}
|
||||
|
||||
var renderedStyleNumberPattern = regexp.MustCompile(`(?i)(font-size|line-height)\s*:\s*([0-9.]+)`)
|
||||
var renderedFontWeightNumberPattern = regexp.MustCompile(`(?i)font-weight\s*:\s*([0-9.]+|bold|bolder)`)
|
||||
var renderedTransformTranslatePattern = regexp.MustCompile(`(?i)translate\(\s*([\-0-9.]+)(?:[\s,]+([\-0-9.]+))?`)
|
||||
var renderedHTMLTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
var renderedWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||
var renderedStyleValuePattern = regexp.MustCompile(`(?i)(fill|stroke|opacity)\s*:\s*([^;]+)`)
|
||||
|
||||
func EvaluateRenderedVisualRun(safeRoot string, deck previewDeck) RenderedVisualReport {
|
||||
report := RenderedVisualReport{
|
||||
Status: "passed",
|
||||
Issues: []RenderedVisualIssue{},
|
||||
Slides: make([]RenderedVisualSlideItem, 0, len(deck.Slides)),
|
||||
}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
item := RenderedVisualSlideItem{Path: strings.TrimSpace(slide.Path), Status: "passed"}
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(item.Path, "svglide.rendered_visual.slide_path", err.Error(), renderedVisualBox{}))
|
||||
report.Slides = append(report.Slides, item)
|
||||
continue
|
||||
}
|
||||
item.Path = slidePath
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(slidePath, "svglide.rendered_visual.read_slide", err.Error(), renderedVisualBox{}))
|
||||
report.Slides = append(report.Slides, item)
|
||||
continue
|
||||
}
|
||||
slideReport := evaluateRenderedVisualSVG(slidePath, raw)
|
||||
item.Status = slideReport.Status
|
||||
item.IssueCount = slideReport.Metrics.IssueCount
|
||||
report.Issues = append(report.Issues, slideReport.Issues...)
|
||||
report.Slides = append(report.Slides, item)
|
||||
}
|
||||
report.Metrics.Slides = len(report.Slides)
|
||||
renderedVisualFinalize(&report)
|
||||
return report
|
||||
}
|
||||
|
||||
func previewDeckFromAuthorDeck(deck authorDeck) previewDeck {
|
||||
out := previewDeck{
|
||||
Title: deck.Title,
|
||||
Slides: make([]previewDeckSlide, 0, len(deck.Slides)),
|
||||
}
|
||||
for _, slide := range deck.Slides {
|
||||
out.Slides = append(out.Slides, previewDeckSlide{
|
||||
ID: slide.ID,
|
||||
Title: slide.Title,
|
||||
Summary: slide.Summary,
|
||||
Role: slide.Role,
|
||||
KeyMessage: slide.KeyMessage,
|
||||
Path: slide.Path,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func evaluateRenderedVisualSVG(path string, raw []byte) RenderedVisualReport {
|
||||
width, height := svgViewBoxSize(string(raw))
|
||||
if width <= 0 {
|
||||
width = defaultSlideWidth
|
||||
}
|
||||
if height <= 0 {
|
||||
height = defaultSlideHeight
|
||||
}
|
||||
boxes := renderedVisualTextBoxes(raw, path)
|
||||
containers := renderedVisualContainers(raw, path, width, height)
|
||||
report := RenderedVisualReport{
|
||||
Status: "passed",
|
||||
Issues: []RenderedVisualIssue{},
|
||||
Slides: []RenderedVisualSlideItem{{Path: path, Status: "passed"}},
|
||||
}
|
||||
const edge = 6.0
|
||||
var boldChars, totalChars int
|
||||
var firstBold renderedVisualBox
|
||||
for _, box := range boxes {
|
||||
if box.Width <= 0 || box.Height <= 0 {
|
||||
continue
|
||||
}
|
||||
totalChars += len([]rune(box.Text))
|
||||
if box.FontWeight >= 700 {
|
||||
boldChars += len([]rune(box.Text))
|
||||
if firstBold.Text == "" {
|
||||
firstBold = box
|
||||
}
|
||||
}
|
||||
if box.Kind == "foreignObject" && box.RequiredHeight > box.Height*1.15 {
|
||||
issueBox := box
|
||||
issueBox.Height = box.RequiredHeight
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.text_box_overflow", "estimated foreignObject text height exceeds container height", issueBox))
|
||||
continue
|
||||
}
|
||||
if box.X < -edge || box.Y < -edge || box.X+box.Width > width+edge || box.Y+box.Height > height+edge {
|
||||
code := "svglide.rendered_visual.text_overflow"
|
||||
if box.Kind == "foreignObject" {
|
||||
code = "svglide.rendered_visual.text_box_overflow"
|
||||
}
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, code, "estimated text box extends outside slide canvas", box))
|
||||
continue
|
||||
}
|
||||
if box.X < edge || box.Y < edge || box.X+box.Width > width-edge || box.Y+box.Height > height-edge {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.unsafe_edge", "estimated text box is too close to slide edge", box))
|
||||
}
|
||||
if box.Kind == "foreignObject" && box.FontSize > 0 && box.LineHeight > 0 && box.LineHeight < box.FontSize*1.12 {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.tight_line_height", "foreignObject line-height is too tight for readable wrapped text", box))
|
||||
}
|
||||
if container, ok := nearestRenderedContainer(box, containers); ok {
|
||||
content := renderedBoxVisibleContentBounds(box)
|
||||
padding := renderedContainerPadding(container)
|
||||
if content.Y+content.Height > container.Y+container.Height-padding {
|
||||
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.container_text_overflow", "estimated text content exceeds visible card/container bottom padding", box, container))
|
||||
} else if box.FontSize > 0 && box.FontSize <= 16 {
|
||||
minMargin := minRenderedBoxMargin(content, container)
|
||||
riskPadding := math.Max(10, box.FontSize*0.75)
|
||||
if minMargin < riskPadding {
|
||||
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.small_text_padding_risk", "small text is too close to visible card/container edge", box, container))
|
||||
}
|
||||
}
|
||||
if content.X < container.X+padding || content.X+content.Width > container.X+container.Width-padding {
|
||||
report.Issues = append(report.Issues, renderedVisualContainerIssue(path, "svglide.rendered_visual.container_padding_risk", "estimated text content violates visible card/container horizontal padding", box, container))
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalChars >= 40 && boldChars*100 > totalChars*65 && firstBold.Text != "" {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.bold_overuse", "more than 65% of visible text is bold, flattening typographic hierarchy", firstBold))
|
||||
}
|
||||
for i := 0; i < len(boxes); i++ {
|
||||
for j := i + 1; j < len(boxes); j++ {
|
||||
a, b := boxes[i], boxes[j]
|
||||
if a.Kind == "foreignObject" && b.Kind == "foreignObject" {
|
||||
if renderedBoxesOverlap(a, b, 3) {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.foreign_object_collision", fmt.Sprintf("estimated foreignObject boxes collide: %q and %q", a.Text, b.Text), a))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a.Kind == "foreignObject" || b.Kind == "foreignObject" {
|
||||
continue
|
||||
}
|
||||
if renderedBoxesOverlap(a, b, 3) {
|
||||
report.Issues = append(report.Issues, renderedVisualIssue(path, "svglide.rendered_visual.text_collision", fmt.Sprintf("estimated text boxes collide: %q and %q", a.Text, b.Text), a))
|
||||
}
|
||||
}
|
||||
}
|
||||
report.Metrics.Slides = 1
|
||||
report.Slides[0].IssueCount = len(report.Issues)
|
||||
if len(report.Issues) > 0 {
|
||||
report.Slides[0].Status = "failed"
|
||||
}
|
||||
renderedVisualFinalize(&report)
|
||||
return report
|
||||
}
|
||||
|
||||
func renderedVisualTextBoxes(raw []byte, path string) []renderedVisualBox {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
stack := []renderedVisualParseState{{FontSize: 16}}
|
||||
var boxes []renderedVisualBox
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
current := stack[len(stack)-1]
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
next := current
|
||||
next.InText = false
|
||||
next.InForeign = current.InForeign
|
||||
next.Text = ""
|
||||
next.ElementID = renderedAttrValue(t.Attr, "id")
|
||||
next.TextAnchor = firstRenderedNonEmpty(renderedAttrValue(t.Attr, "text-anchor"), current.TextAnchor)
|
||||
next.FontSize = firstPositive(parseFloatAttr(t.Attr, "font-size"), parseStyleNumber(renderedAttrValue(t.Attr, "style"), "font-size"), current.FontSize)
|
||||
next.LineHeight = firstPositive(normalizeRenderedLineHeight(parseStyleNumber(renderedAttrValue(t.Attr, "style"), "line-height"), next.FontSize), current.LineHeight)
|
||||
next.FontWeight = firstPositive(parseFontWeightValue(renderedAttrValue(t.Attr, "font-weight")), parseFontWeightStyle(renderedAttrValue(t.Attr, "style")), current.FontWeight)
|
||||
dx, dy := parseTranslate(renderedAttrValue(t.Attr, "transform"))
|
||||
next.TranslateX += dx
|
||||
next.TranslateY += dy
|
||||
switch t.Name.Local {
|
||||
case "text":
|
||||
next.InText = true
|
||||
next.TextX = parseFloatAttr(t.Attr, "x") + next.TranslateX
|
||||
next.TextY = parseFloatAttr(t.Attr, "y") + next.TranslateY
|
||||
case "foreignObject":
|
||||
next.InForeign = true
|
||||
next.Foreign = renderedVisualBox{
|
||||
Path: path,
|
||||
ElementID: next.ElementID,
|
||||
Kind: "foreignObject",
|
||||
X: parseFloatAttr(t.Attr, "x") + next.TranslateX,
|
||||
Y: parseFloatAttr(t.Attr, "y") + next.TranslateY,
|
||||
Width: parseFloatAttr(t.Attr, "width"),
|
||||
Height: parseFloatAttr(t.Attr, "height"),
|
||||
FontSize: next.FontSize,
|
||||
LineHeight: next.LineHeight,
|
||||
FontWeight: next.FontWeight,
|
||||
}
|
||||
}
|
||||
stack = append(stack, next)
|
||||
case xml.CharData:
|
||||
for i := range stack {
|
||||
if stack[i].InText || stack[i].InForeign {
|
||||
stack[i].Text += string(t)
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) <= 1 {
|
||||
continue
|
||||
}
|
||||
top := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
switch t.Name.Local {
|
||||
case "text":
|
||||
text := normalizeRenderedText(top.Text)
|
||||
if text != "" {
|
||||
width := estimateRenderedTextWidth(text, top.FontSize)
|
||||
x := top.TextX
|
||||
switch strings.TrimSpace(top.TextAnchor) {
|
||||
case "middle":
|
||||
x -= width / 2
|
||||
case "end":
|
||||
x -= width
|
||||
}
|
||||
boxes = append(boxes, renderedVisualBox{
|
||||
Path: path,
|
||||
ElementID: top.ElementID,
|
||||
Kind: "text",
|
||||
Text: text,
|
||||
X: x,
|
||||
Y: top.TextY - top.FontSize*0.86,
|
||||
Width: width,
|
||||
Height: top.FontSize * 1.15,
|
||||
FontSize: top.FontSize,
|
||||
LineHeight: top.LineHeight,
|
||||
FontWeight: top.FontWeight,
|
||||
})
|
||||
}
|
||||
case "foreignObject":
|
||||
text := normalizeRenderedText(top.Text)
|
||||
if text != "" {
|
||||
requiredHeight := estimateForeignObjectTextHeight(text, top.FontSize, top.Foreign.Width)
|
||||
top.Foreign.Text = text
|
||||
top.Foreign.RequiredHeight = requiredHeight
|
||||
top.Foreign.FontSize = top.FontSize
|
||||
top.Foreign.LineHeight = top.LineHeight
|
||||
top.Foreign.FontWeight = top.FontWeight
|
||||
boxes = append(boxes, top.Foreign)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
|
||||
func renderedVisualFinalize(report *RenderedVisualReport) {
|
||||
report.Status = "passed"
|
||||
for _, issue := range report.Issues {
|
||||
report.Metrics.IssueCount++
|
||||
switch issue.Code {
|
||||
case "svglide.rendered_visual.text_overflow", "svglide.rendered_visual.text_box_overflow":
|
||||
report.Metrics.TextOverflowCount++
|
||||
case "svglide.rendered_visual.text_collision":
|
||||
report.Metrics.TextCollisionCount++
|
||||
case "svglide.rendered_visual.unsafe_edge":
|
||||
report.Metrics.UnsafeEdgeCount++
|
||||
case "svglide.rendered_visual.container_text_overflow":
|
||||
report.Metrics.ContainerTextOverflowCount++
|
||||
case "svglide.rendered_visual.container_padding_risk":
|
||||
report.Metrics.ContainerPaddingRiskCount++
|
||||
case "svglide.rendered_visual.foreign_object_collision":
|
||||
report.Metrics.ForeignObjectOverlapCount++
|
||||
case "svglide.rendered_visual.tight_line_height":
|
||||
report.Metrics.TightLineHeightCount++
|
||||
case "svglide.rendered_visual.bold_overuse":
|
||||
report.Metrics.BoldOveruseCount++
|
||||
case "svglide.rendered_visual.small_text_padding_risk":
|
||||
report.Metrics.SmallTextPaddingRiskCount++
|
||||
default:
|
||||
report.Metrics.OutOfCanvasCount++
|
||||
}
|
||||
}
|
||||
if report.Metrics.IssueCount > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
for i := range report.Slides {
|
||||
if report.Slides[i].IssueCount > 0 {
|
||||
report.Slides[i].Status = "failed"
|
||||
} else if report.Slides[i].Status == "" {
|
||||
report.Slides[i].Status = "passed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderedVisualContainerIssue(path, code, message string, box renderedVisualBox, container renderedVisualContainer) RenderedVisualIssue {
|
||||
issue := renderedVisualIssue(path, code, message, box)
|
||||
issue.Message = fmt.Sprintf("%s; nearest container x=%.2f y=%.2f width=%.2f height=%.2f", message, container.X, container.Y, container.Width, container.Height)
|
||||
return issue
|
||||
}
|
||||
|
||||
func renderedVisualIssue(path, code, message string, box renderedVisualBox) RenderedVisualIssue {
|
||||
return RenderedVisualIssue{
|
||||
Path: path,
|
||||
ElementID: box.ElementID,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
X: roundRenderedNumber(box.X),
|
||||
Y: roundRenderedNumber(box.Y),
|
||||
Width: roundRenderedNumber(box.Width),
|
||||
Height: roundRenderedNumber(box.Height),
|
||||
}
|
||||
}
|
||||
|
||||
func writeRenderedVisualReport(safeRoot string, report RenderedVisualReport) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, renderedVisualReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, report)
|
||||
}
|
||||
|
||||
func readRenderedVisualReport(safeRoot string) (RenderedVisualReport, bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, renderedVisualReceiptPath)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "missing or not a regular file") {
|
||||
return RenderedVisualReport{}, false, nil
|
||||
}
|
||||
return RenderedVisualReport{}, false, err
|
||||
}
|
||||
var report RenderedVisualReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return RenderedVisualReport{}, true, err
|
||||
}
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
func renderedVisualSlideFailed(report RenderedVisualReport, path string) bool {
|
||||
for _, slide := range report.Slides {
|
||||
if slide.Path == path && slide.Status == "failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderedBoxesOverlap(a, b renderedVisualBox, pad float64) bool {
|
||||
return a.X < b.X+b.Width+pad &&
|
||||
a.X+a.Width+pad > b.X &&
|
||||
a.Y < b.Y+b.Height+pad &&
|
||||
a.Y+a.Height+pad > b.Y
|
||||
}
|
||||
|
||||
func renderedVisualContainers(raw []byte, path string, slideWidth, slideHeight float64) []renderedVisualContainer {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
type state struct {
|
||||
translateX float64
|
||||
translateY float64
|
||||
}
|
||||
stack := []state{{}}
|
||||
var containers []renderedVisualContainer
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
current := stack[len(stack)-1]
|
||||
next := current
|
||||
dx, dy := parseTranslate(renderedAttrValue(t.Attr, "transform"))
|
||||
next.translateX += dx
|
||||
next.translateY += dy
|
||||
if t.Name.Local == "rect" {
|
||||
if renderedVisualRectLooksLikeChartMark(t.Attr) {
|
||||
stack = append(stack, next)
|
||||
continue
|
||||
}
|
||||
container := renderedVisualContainer{
|
||||
Path: path,
|
||||
ElementID: renderedAttrValue(t.Attr, "id"),
|
||||
Kind: "rect",
|
||||
X: parseFloatAttr(t.Attr, "x") + next.translateX,
|
||||
Y: parseFloatAttr(t.Attr, "y") + next.translateY,
|
||||
Width: parseFloatAttr(t.Attr, "width"),
|
||||
Height: parseFloatAttr(t.Attr, "height"),
|
||||
RX: parseFloatAttr(t.Attr, "rx"),
|
||||
Fill: firstRenderedNonEmpty(renderedAttrValue(t.Attr, "fill"), parseStyleValue(renderedAttrValue(t.Attr, "style"), "fill")),
|
||||
Stroke: firstRenderedNonEmpty(renderedAttrValue(t.Attr, "stroke"), parseStyleValue(renderedAttrValue(t.Attr, "style"), "stroke")),
|
||||
Opacity: firstPositive(parseFloatAttr(t.Attr, "opacity"), parseFloatLoose(parseStyleValue(renderedAttrValue(t.Attr, "style"), "opacity")), 1),
|
||||
}
|
||||
if isRenderedVisualContainer(container, slideWidth, slideHeight) {
|
||||
containers = append(containers, container)
|
||||
}
|
||||
}
|
||||
stack = append(stack, next)
|
||||
case xml.EndElement:
|
||||
if len(stack) > 1 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return containers
|
||||
}
|
||||
|
||||
func isRenderedVisualContainer(container renderedVisualContainer, slideWidth, slideHeight float64) bool {
|
||||
if container.Width <= 0 || container.Height <= 0 || slideWidth <= 0 || slideHeight <= 0 {
|
||||
return false
|
||||
}
|
||||
if container.Width/slideWidth < 0.04 || container.Height/slideHeight < 0.04 {
|
||||
return false
|
||||
}
|
||||
fill := strings.ToLower(strings.TrimSpace(container.Fill))
|
||||
stroke := strings.ToLower(strings.TrimSpace(container.Stroke))
|
||||
hasVisualStyle := container.RX > 0 || (fill != "" && fill != "none" && fill != "transparent") || (stroke != "" && stroke != "none" && stroke != "transparent")
|
||||
if !hasVisualStyle {
|
||||
return false
|
||||
}
|
||||
areaRatio := (container.Width * container.Height) / (slideWidth * slideHeight)
|
||||
if areaRatio > 0.55 {
|
||||
return false
|
||||
}
|
||||
touchesEdges := container.X <= 2 && container.Y <= 2 && container.X+container.Width >= slideWidth-2 && container.Y+container.Height >= slideHeight-2
|
||||
return !(areaRatio > 0.70 && touchesEdges)
|
||||
}
|
||||
|
||||
func renderedVisualRectLooksLikeChartMark(attrs []xml.Attr) bool {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
renderedAttrValue(attrs, "id"),
|
||||
renderedAttrValue(attrs, "class"),
|
||||
renderedAttrValue(attrs, "role"),
|
||||
renderedAttrValue(attrs, "aria-label"),
|
||||
renderedAttrValue(attrs, "data-mark"),
|
||||
renderedAttrValue(attrs, "data-role"),
|
||||
}, " "))
|
||||
for _, token := range []string{"mark-bar", "mark bar", "bar-mark", "axis", "tick", "plot", "vega", "data-point", "datapoint"} {
|
||||
if strings.Contains(haystack, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nearestRenderedContainer(box renderedVisualBox, containers []renderedVisualContainer) (renderedVisualContainer, bool) {
|
||||
content := renderedBoxVisibleContentBounds(box)
|
||||
centerX := content.X + content.Width/2
|
||||
var best renderedVisualContainer
|
||||
var bestArea float64
|
||||
for _, container := range containers {
|
||||
if centerX < container.X || centerX > container.X+container.Width {
|
||||
continue
|
||||
}
|
||||
overlapsVertically := content.Y < container.Y+container.Height && content.Y+content.Height > container.Y
|
||||
startsInside := box.Y >= container.Y && box.Y <= container.Y+container.Height
|
||||
if !overlapsVertically && !startsInside {
|
||||
continue
|
||||
}
|
||||
area := container.Width * container.Height
|
||||
if bestArea == 0 || area < bestArea {
|
||||
best = container
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
return best, bestArea > 0
|
||||
}
|
||||
|
||||
func renderedBoxVisibleContentBounds(box renderedVisualBox) renderedVisualBox {
|
||||
out := box
|
||||
if box.Kind == "foreignObject" && box.RequiredHeight > 0 {
|
||||
out.Height = box.RequiredHeight
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderedContainerPadding(container renderedVisualContainer) float64 {
|
||||
shortSide := math.Min(container.Width, container.Height)
|
||||
return math.Max(12, math.Min(24, shortSide*0.10))
|
||||
}
|
||||
|
||||
func minRenderedBoxMargin(box renderedVisualBox, container renderedVisualContainer) float64 {
|
||||
return math.Min(
|
||||
math.Min(box.X-container.X, container.X+container.Width-(box.X+box.Width)),
|
||||
math.Min(box.Y-container.Y, container.Y+container.Height-(box.Y+box.Height)),
|
||||
)
|
||||
}
|
||||
|
||||
func estimateForeignObjectTextHeight(text string, fontSize float64, width float64) float64 {
|
||||
if fontSize <= 0 {
|
||||
fontSize = 16
|
||||
}
|
||||
if width <= 0 {
|
||||
return fontSize * 1.3
|
||||
}
|
||||
plain := normalizeRenderedText(renderedHTMLTagPattern.ReplaceAllString(text, " "))
|
||||
textWidth := estimateRenderedTextWidth(plain, fontSize)
|
||||
lines := math.Max(1, math.Ceil(textWidth/(width*0.92)))
|
||||
return lines * fontSize * 1.22
|
||||
}
|
||||
|
||||
func estimateRenderedTextWidth(text string, fontSize float64) float64 {
|
||||
if fontSize <= 0 {
|
||||
fontSize = 16
|
||||
}
|
||||
var units float64
|
||||
for _, r := range text {
|
||||
switch {
|
||||
case r == ' ' || r == '\t' || r == '\n':
|
||||
units += 0.33
|
||||
case r < 128:
|
||||
if strings.ContainsRune(".,:;!|/\\'\"()-+%", r) {
|
||||
units += 0.36
|
||||
} else {
|
||||
units += 0.58
|
||||
}
|
||||
default:
|
||||
units += 1.0
|
||||
}
|
||||
}
|
||||
return units * fontSize
|
||||
}
|
||||
|
||||
func renderedAttrValue(attrs []xml.Attr, name string) string {
|
||||
for _, attr := range attrs {
|
||||
if attr.Name.Local == name {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFloatAttr(attrs []xml.Attr, name string) float64 {
|
||||
return parseFloatLoose(renderedAttrValue(attrs, name))
|
||||
}
|
||||
|
||||
func parseFloatLoose(value string) float64 {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, "px"))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseStyleNumber(style, name string) float64 {
|
||||
for _, match := range renderedStyleNumberPattern.FindAllStringSubmatch(style, -1) {
|
||||
if len(match) == 3 && strings.EqualFold(match[1], name) {
|
||||
return parseFloatLoose(match[2])
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseStyleValue(style, name string) string {
|
||||
for _, match := range renderedStyleValuePattern.FindAllStringSubmatch(style, -1) {
|
||||
if len(match) == 3 && strings.EqualFold(match[1], name) {
|
||||
return strings.TrimSpace(match[2])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFontWeightStyle(style string) float64 {
|
||||
match := renderedFontWeightNumberPattern.FindStringSubmatch(style)
|
||||
if len(match) != 2 {
|
||||
return 0
|
||||
}
|
||||
return parseFontWeightValue(match[1])
|
||||
}
|
||||
|
||||
func parseFontWeightValue(value string) float64 {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
switch value {
|
||||
case "bold", "bolder":
|
||||
return 700
|
||||
default:
|
||||
return parseFloatLoose(value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRenderedLineHeight(value, fontSize float64) float64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value < 4 && fontSize > 0 {
|
||||
return value * fontSize
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseTranslate(transform string) (float64, float64) {
|
||||
match := renderedTransformTranslatePattern.FindStringSubmatch(transform)
|
||||
if len(match) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
x := parseFloatLoose(match[1])
|
||||
y := 0.0
|
||||
if len(match) > 2 {
|
||||
y = parseFloatLoose(match[2])
|
||||
}
|
||||
return x, y
|
||||
}
|
||||
|
||||
func firstRenderedNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPositive(values ...float64) float64 {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeRenderedText(text string) string {
|
||||
text = renderedWhitespacePattern.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func roundRenderedNumber(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
143
internal/svglide/rendered_visual_test.go
Normal file
143
internal/svglide/rendered_visual_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderedVisualGateDetectsAppleSubtitleOverflow(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><text x="92" y="318" font-size="25">Revenue declined 4.3% year over year, but gross margin reached 46.6% and diluted EPS set a March-quarter record.</text></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_overflow") {
|
||||
t.Fatalf("report = %+v, want text_overflow", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsForeignObjectClip(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><foreignObject x="370" y="58" width="820" height="58" style="font-size:48px;line-height:1.16"><h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0">这支美国队的优势在结构,不只在星味</h2></foreignObject></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/02.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_box_overflow") {
|
||||
t.Fatalf("report = %+v, want text_box_overflow", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsTimelineCollision(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><g font-size="46"><text x="984" y="338">2022</text><text x="1160" y="338" text-anchor="end">2024</text></g></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/03.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.text_collision") {
|
||||
t.Fatalf("report = %+v, want text_collision", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsMetricCardInternalOverflow(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<rect x="930" y="134" width="190" height="118" rx="10" fill="#202420" stroke="#3d443f"/>
|
||||
<foreignObject x="950" y="239" width="150" height="129" style="font-size:16px">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">current assets / liabilities</div>
|
||||
</foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/05-ratios-peers.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.container_text_overflow") {
|
||||
t.Fatalf("report = %+v, want container_text_overflow", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateAllowsTextInsideCardPadding(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<rect x="100" y="100" width="280" height="150" rx="10" fill="#202420" stroke="#3d443f"/>
|
||||
<foreignObject x="124" y="126" width="220" height="84" style="font-size:18px">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">A fitted note with room.</div>
|
||||
</foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDoesNotTreatChartBarsAsTextContainers(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<rect x="120" y="160" width="280" height="320" class="mark-bar" fill="#76b900"/>
|
||||
<text x="132" y="188" font-size="22">$22.1B</text>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/chart.svg", []byte(svg))
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed for chart mark label", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsForeignObjectOverlap(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<foreignObject x="120" y="120" width="260" height="80" style="font-size:22px"><p xmlns="http://www.w3.org/1999/xhtml">First label</p></foreignObject>
|
||||
<foreignObject x="180" y="150" width="260" height="80" style="font-size:22px"><p xmlns="http://www.w3.org/1999/xhtml">Second label</p></foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/02.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.foreign_object_collision") {
|
||||
t.Fatalf("report = %+v, want foreign_object_collision", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsTightLineHeight(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<foreignObject x="120" y="120" width="420" height="90" style="font-size:20px;line-height:20px"><p xmlns="http://www.w3.org/1999/xhtml">Dense label copy with enough words to wrap into two lines.</p></foreignObject>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/03.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.tight_line_height") {
|
||||
t.Fatalf("report = %+v, want tight_line_height", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateDetectsBoldOveruse(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide">
|
||||
<text x="100" y="120" font-size="34" font-weight="800">Revenue acceleration is the story</text>
|
||||
<text x="100" y="180" font-size="28" font-weight="800">Margins expand while supply stays tight</text>
|
||||
<text x="100" y="236" font-size="24" font-weight="800">Every sentence should not be bold</text>
|
||||
</svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/04.svg", []byte(svg))
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, "svglide.rendered_visual.bold_overuse") {
|
||||
t.Fatalf("report = %+v, want bold_overuse", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateAllowsFittedText(t *testing.T) {
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 1280 720" slide:role="slide"><text x="92" y="120" font-size="24">Short title</text><foreignObject x="92" y="180" width="520" height="90" style="font-size:24px"><p xmlns="http://www.w3.org/1999/xhtml">One fitted sentence.</p></foreignObject></svg>`
|
||||
report := evaluateRenderedVisualSVG("slides/01.svg", []byte(svg))
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("report = %+v, want passed", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedVisualGateRegressionFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
code string
|
||||
}{
|
||||
{"apple subtitle", "apple_subtitle_overflow.svg", "svglide.rendered_visual.text_overflow"},
|
||||
{"apple cashflow", "apple_cashflow_callout_overflow.svg", "svglide.rendered_visual.text_overflow"},
|
||||
{"leica timeline", "leica_timeline_collision.svg", "svglide.rendered_visual.text_collision"},
|
||||
{"sports title", "sports_foreign_object_clip.svg", "svglide.rendered_visual.text_box_overflow"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "svglide", "rendered_visual", tt.file))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
report := evaluateRenderedVisualSVG(tt.file, raw)
|
||||
if report.Status != "failed" || !renderedVisualHasCode(report, tt.code) {
|
||||
t.Fatalf("report = %+v, want %s", report, tt.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func renderedVisualHasCode(report RenderedVisualReport, code string) bool {
|
||||
for _, issue := range report.Issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
863
internal/svglide/repair.go
Normal file
863
internal/svglide/repair.go
Normal file
@@ -0,0 +1,863 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type RepairReport struct {
|
||||
Status string `json:"status"`
|
||||
LintOK bool `json:"lint_ok"`
|
||||
Preview string `json:"preview"`
|
||||
Quality string `json:"quality"`
|
||||
Creative string `json:"creative"`
|
||||
Semantic string `json:"semantic"`
|
||||
Reauthored bool `json:"reauthored"`
|
||||
}
|
||||
|
||||
type DeliveryReceipt struct {
|
||||
Status string `json:"status"`
|
||||
RouteProfile string `json:"route_profile"`
|
||||
Orchestrator string `json:"orchestrator"`
|
||||
RuntimeBinding string `json:"runtime_binding"`
|
||||
Deck string `json:"deck"`
|
||||
SlidesDir string `json:"slides_dir"`
|
||||
Slides []string `json:"slides"`
|
||||
Preview DeliveryPreviewEvidence `json:"preview"`
|
||||
QualityReport string `json:"quality_report"`
|
||||
AnyGenSemanticReport string `json:"anygen_semantic_report"`
|
||||
VisualReceipts string `json:"visual_receipts"`
|
||||
CreativeQualityReport string `json:"creative_quality_report"`
|
||||
SemanticMetrics SemanticMetrics `json:"semantic_metrics"`
|
||||
StageStatus map[string]string `json:"stage_status"`
|
||||
FullChainEvidence FullChainEvidence `json:"full_chain_evidence"`
|
||||
LegacyRuntimeExecuted bool `json:"legacy_runtime_executed"`
|
||||
LegacyToolIDs []string `json:"legacy_tool_ids"`
|
||||
LegacyArtifactMatches []string `json:"legacy_artifact_matches"`
|
||||
CorePromptIDs []string `json:"core_prompt_ids"`
|
||||
ObservedPromptIDs []string `json:"observed_prompt_ids"`
|
||||
BlockedPromptIDs []string `json:"blocked_prompt_ids"`
|
||||
}
|
||||
|
||||
type DeliveryPreviewEvidence struct {
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
MissingAssetCount int `json:"missing_asset_count"`
|
||||
}
|
||||
|
||||
type FullChainEvidence struct {
|
||||
RunJSON string `json:"run_json"`
|
||||
Request string `json:"request"`
|
||||
SourceManifest string `json:"source_manifest"`
|
||||
EntityResolution string `json:"entity_resolution"`
|
||||
ResearchNotes string `json:"research_notes"`
|
||||
Sources string `json:"sources"`
|
||||
ResearchCoverage string `json:"research_coverage"`
|
||||
DesignBrief string `json:"design_brief"`
|
||||
VisualSystem string `json:"visual_system"`
|
||||
TypographyContract string `json:"typography_contract"`
|
||||
Outline string `json:"outline"`
|
||||
SlideContent string `json:"slide_content"`
|
||||
AssetManifest string `json:"asset_manifest"`
|
||||
RenderedVisual string `json:"rendered_visual"`
|
||||
QualityReport string `json:"quality_report"`
|
||||
CreativeQualityReport string `json:"creative_quality_report"`
|
||||
ChartRenderReport string `json:"chart_render_report"`
|
||||
ChartUsageReport string `json:"chart_usage_report"`
|
||||
ChartQualityReport string `json:"chart_quality_report"`
|
||||
Delivery string `json:"delivery"`
|
||||
StageReceipts map[string]string `json:"stage_receipts"`
|
||||
ScreenshotEvidence []string `json:"screenshot_evidence"`
|
||||
ManualPatch ManualPatchStatus `json:"manual_patch"`
|
||||
}
|
||||
|
||||
type ManualPatchStatus struct {
|
||||
Applied bool `json:"applied"`
|
||||
Files []string `json:"files"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func RepairRun(root string) (RepairReport, error) {
|
||||
return repairRun(root, EvaluateAnyGenSemantics)
|
||||
}
|
||||
|
||||
func RepairRunWithSemanticContractFile(root string, contractPath string) (RepairReport, error) {
|
||||
contract, err := LoadSemanticContractFile(contractPath)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
return RepairRunWithSemanticContract(root, contract)
|
||||
}
|
||||
|
||||
func RepairRunWithSemanticContract(root string, contract SemanticContract) (RepairReport, error) {
|
||||
return repairRun(root, func(root string) (AnyGenSemanticReport, error) {
|
||||
return EvaluateAnyGenSemanticsWithContract(root, contract)
|
||||
})
|
||||
}
|
||||
|
||||
func repairRun(root string, evaluateSemantic func(string) (AnyGenSemanticReport, error)) (RepairReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
lint, validateErr := ValidateRun(root)
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
|
||||
reauthored := false
|
||||
if !lint.OK {
|
||||
repairPaths, ok := authorRepairPaths(lint)
|
||||
if ok {
|
||||
if _, err := authorSlides(root, repairPaths); err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
reauthored = true
|
||||
lint, validateErr = ValidateRun(root)
|
||||
}
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
}
|
||||
|
||||
preview, err := WritePreview(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
quality, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
if quality.Status != "passed" {
|
||||
if err := writeQualityRepairQueue(safeRoot, quality); err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
}
|
||||
semantic, err := evaluateSemantic(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
report := RepairReport{
|
||||
Status: "failed",
|
||||
LintOK: lint.OK,
|
||||
Preview: preview.Status,
|
||||
Quality: quality.Status,
|
||||
Creative: creativeStatusFromQuality(safeRoot),
|
||||
Semantic: semantic.Status,
|
||||
Reauthored: reauthored,
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative == "passed" && report.Semantic == "passed" {
|
||||
report.Status = "passed"
|
||||
}
|
||||
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
artifacts := []string{
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"quality_report.json",
|
||||
anyGenSemanticReportPath,
|
||||
visualReceiptsPath,
|
||||
creativeQualityReportPath,
|
||||
chartRenderReceiptPath,
|
||||
chartUsageReceiptPath,
|
||||
chartQualityReportPath,
|
||||
"repair_queue.md",
|
||||
previewPath,
|
||||
}
|
||||
if report.Status == "passed" {
|
||||
artifacts = append(artifacts, deliveryReceiptPath)
|
||||
} else if report.LintOK && report.Preview == "passed" {
|
||||
artifacts = append(artifacts, deliveryReceiptPath)
|
||||
}
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: StageValidatePreviewRepair,
|
||||
Status: report.Status,
|
||||
Message: repairReceiptMessage(report),
|
||||
Artifacts: artifacts,
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
if report.Status == "passed" {
|
||||
if _, err := writeDeliveryReceiptWithStatus(safeRoot, run, StatusReady); err != nil {
|
||||
return report, err
|
||||
}
|
||||
} else if report.LintOK && report.Preview == "passed" {
|
||||
if _, err := writeDeliveryReceiptWithStatus(safeRoot, run, StatusNeedsRepair); err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
const deliveryReceiptPath = "receipts/delivery.json"
|
||||
const deliveryChartQualityReportPath = "receipts/chart_quality.json"
|
||||
|
||||
func writeDeliveryReceipt(safeRoot string, run Run) (DeliveryReceipt, error) {
|
||||
return writeDeliveryReceiptWithStatus(safeRoot, run, StatusReady)
|
||||
}
|
||||
|
||||
func writeDeliveryReceiptWithStatus(safeRoot string, run Run, status string) (DeliveryReceipt, error) {
|
||||
receipt, err := generateDeliveryReceiptWithStatus(safeRoot, run, status)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if err := writeDeliveryReceiptSchema(safeRoot); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if err := ValidateDeliveryReceiptAgainstRun(receipt, run); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, deliveryReceiptPath)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if err := writeJSON(target, receipt); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func writeDeliveryReceiptSchema(safeRoot string) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, "schemas/delivery.schema.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeText(target, DeliveryReceiptSchema)
|
||||
}
|
||||
|
||||
func GenerateDeliveryReceipt(safeRoot string, run Run) (DeliveryReceipt, error) {
|
||||
return generateDeliveryReceiptWithStatus(safeRoot, run, StatusReady)
|
||||
}
|
||||
|
||||
func generateDeliveryReceiptWithStatus(safeRoot string, run Run, status string) (DeliveryReceipt, error) {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
status = StatusReady
|
||||
}
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
deckPath = "outline/deck.json"
|
||||
}
|
||||
deck, err := readAuthorDeck(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
slides := make([]string, 0, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if _, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
slides = append(slides, slidePath)
|
||||
}
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
requiredReports := []string{previewPath, "quality_report.json", anyGenSemanticReportPath, creativeQualityReportPath, chartRenderReceiptPath, chartUsageReceiptPath, chartQualityReportPath}
|
||||
if status == StatusReady {
|
||||
requiredReports = append(requiredReports, visualReceiptsPath)
|
||||
}
|
||||
for _, rel := range requiredReports {
|
||||
if _, err := readRunRegularArtifact(safeRoot, rel); err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
}
|
||||
slidesDir := strings.TrimSpace(run.Artifacts.SlidesDir)
|
||||
if slidesDir == "" {
|
||||
slidesDir = "slides"
|
||||
}
|
||||
preview, err := readDeliveryPreviewEvidence(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
semantic, err := readDeliverySemanticReport(safeRoot)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
legacy, err := ScanLegacyRuntimeEvidence(safeRoot, run)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
fullChainEvidence, fullChainComplete, err := buildDeliveryFullChainEvidence(safeRoot, run, previewPath)
|
||||
if err != nil {
|
||||
return DeliveryReceipt{}, err
|
||||
}
|
||||
if status == StatusReady && !fullChainComplete {
|
||||
status = StatusNeedsRepair
|
||||
}
|
||||
receipt := DeliveryReceipt{
|
||||
Status: status,
|
||||
RouteProfile: normalizedRouteProfile(run.RouteProfile),
|
||||
Orchestrator: "mode_system_prompt_svg",
|
||||
RuntimeBinding: "svglide_local_runtime_binding",
|
||||
Deck: deckPath,
|
||||
SlidesDir: slidesDir,
|
||||
Slides: slides,
|
||||
Preview: preview,
|
||||
QualityReport: "quality_report.json",
|
||||
AnyGenSemanticReport: anyGenSemanticReportPath,
|
||||
VisualReceipts: visualReceiptsPath,
|
||||
CreativeQualityReport: creativeQualityReportPath,
|
||||
SemanticMetrics: semantic.Metrics,
|
||||
StageStatus: deliveryStageStatus(run),
|
||||
FullChainEvidence: fullChainEvidence,
|
||||
LegacyRuntimeExecuted: legacy.LegacyRuntimeExecuted,
|
||||
LegacyToolIDs: legacy.LegacyToolIDs,
|
||||
LegacyArtifactMatches: legacy.LegacyArtifactMatches,
|
||||
CorePromptIDs: []string{"mode_system_prompt_svg", "svg_reference", "svglide_local_runtime_binding"},
|
||||
ObservedPromptIDs: legacy.ObservedPromptIDs,
|
||||
BlockedPromptIDs: legacy.BlockedPromptIDs,
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func creativeStatusFromQuality(safeRoot string) string {
|
||||
raw, err := readRunRegularArtifact(safeRoot, creativeQualityReportPath)
|
||||
if err != nil {
|
||||
return "missing"
|
||||
}
|
||||
var report CreativeQualityReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return "invalid"
|
||||
}
|
||||
return strings.TrimSpace(report.Status)
|
||||
}
|
||||
|
||||
func ValidateDeliveryReceiptAgainstRun(receipt DeliveryReceipt, run Run) error {
|
||||
if normalizedRouteProfile(run.RouteProfile) == RouteProfileLocalSVGDeck && receipt.LegacyRuntimeExecuted {
|
||||
return fmt.Errorf("legacy runtime evidence found for local_svg_deck: tools=%s artifacts=%s", strings.Join(receipt.LegacyToolIDs, ","), strings.Join(receipt.LegacyArtifactMatches, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type LegacyRuntimeEvidence struct {
|
||||
LegacyRuntimeExecuted bool `json:"legacy_runtime_executed"`
|
||||
LegacyToolIDs []string `json:"legacy_tool_ids"`
|
||||
LegacyArtifactMatches []string `json:"legacy_artifact_matches"`
|
||||
ObservedPromptIDs []string `json:"observed_prompt_ids"`
|
||||
BlockedPromptIDs []string `json:"blocked_prompt_ids"`
|
||||
}
|
||||
|
||||
func ScanLegacyRuntimeEvidence(safeRoot string, run Run) (LegacyRuntimeEvidence, error) {
|
||||
legacyIDs, blockedIDs, err := legacyPromptIDSets(run)
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
evidence := LegacyRuntimeEvidence{
|
||||
LegacyToolIDs: []string{},
|
||||
LegacyArtifactMatches: []string{},
|
||||
ObservedPromptIDs: []string{},
|
||||
BlockedPromptIDs: sortedKeys(blockedIDs),
|
||||
}
|
||||
|
||||
toolMatches, err := filepath.Glob(filepath.Join(safeRoot, "receipts", "tool_calls", "*", "*.json"))
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
for _, path := range toolMatches {
|
||||
id := strings.TrimSuffix(filepath.Base(path), ".json")
|
||||
if !legacyIDs[id] {
|
||||
continue
|
||||
}
|
||||
evidence.LegacyToolIDs = appendUnique(evidence.LegacyToolIDs, id)
|
||||
if rel, err := filepath.Rel(safeRoot, path); err == nil {
|
||||
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
|
||||
contextMatches, err := filepath.Glob(filepath.Join(safeRoot, "receipts", "prompt_context", "*.json"))
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
for _, path := range contextMatches {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
var receipt PromptContextReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return LegacyRuntimeEvidence{}, fmt.Errorf("%s: invalid prompt context JSON: %w", filepath.ToSlash(path), err)
|
||||
}
|
||||
rel := filepath.ToSlash(path)
|
||||
if localRel, err := filepath.Rel(safeRoot, path); err == nil {
|
||||
rel = filepath.ToSlash(localRel)
|
||||
}
|
||||
for id := range promptIDsFromReceipt(receipt) {
|
||||
evidence.ObservedPromptIDs = appendUnique(evidence.ObservedPromptIDs, id)
|
||||
if blockedIDs[id] {
|
||||
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, rel+"#"+id)
|
||||
}
|
||||
}
|
||||
}
|
||||
artifactMatches, err := scanLegacyRunArtifacts(safeRoot)
|
||||
if err != nil {
|
||||
return LegacyRuntimeEvidence{}, err
|
||||
}
|
||||
for _, match := range artifactMatches {
|
||||
evidence.LegacyArtifactMatches = appendUnique(evidence.LegacyArtifactMatches, match)
|
||||
}
|
||||
|
||||
sort.Strings(evidence.LegacyToolIDs)
|
||||
sort.Strings(evidence.LegacyArtifactMatches)
|
||||
sort.Strings(evidence.ObservedPromptIDs)
|
||||
evidence.LegacyRuntimeExecuted = len(evidence.LegacyToolIDs) > 0 || len(artifactMatches) > 0 || legacyPromptObserved(evidence.LegacyArtifactMatches, blockedIDs)
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func scanLegacyRunArtifacts(safeRoot string) ([]string, error) {
|
||||
var matches []string
|
||||
err := filepath.WalkDir(safeRoot, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == safeRoot {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(safeRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if legacyRunArtifactMatch(rel) {
|
||||
matches = appendUnique(matches, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func legacyRunArtifactMatch(rel string) bool {
|
||||
lower := strings.ToLower(filepath.ToSlash(rel))
|
||||
base := strings.ToLower(filepath.Base(lower))
|
||||
ext := strings.ToLower(filepath.Ext(base))
|
||||
switch ext {
|
||||
case ".slides", ".pptx", ".sxsd", ".xml":
|
||||
return true
|
||||
}
|
||||
switch base {
|
||||
case "converted_pptx_manifest.json",
|
||||
"template_manifest.json",
|
||||
"sxsd_manifest.json",
|
||||
"editor_session.json",
|
||||
"publish_receipt.json",
|
||||
"readback_receipt.json",
|
||||
"live_create_receipt.json":
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "sxsd") || strings.Contains(lower, "legacy_editor") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func legacyPromptObserved(matches []string, blockedIDs map[string]bool) bool {
|
||||
for _, match := range matches {
|
||||
for id := range blockedIDs {
|
||||
if strings.HasSuffix(match, "#"+id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func legacyPromptIDSets(run Run) (map[string]bool, map[string]bool, error) {
|
||||
assets, err := LoadAnyGenPromptAssets()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
legacyIDs := map[string]bool{}
|
||||
blockedIDs := map[string]bool{}
|
||||
profile := normalizedRouteProfile(run.RouteProfile)
|
||||
for _, asset := range assets {
|
||||
if asset.Exposure != "legacy" {
|
||||
continue
|
||||
}
|
||||
legacyIDs[asset.ID] = true
|
||||
if !promptAssetAllowedForProfile(asset.Profiles, profile) {
|
||||
blockedIDs[asset.ID] = true
|
||||
}
|
||||
}
|
||||
return legacyIDs, blockedIDs, nil
|
||||
}
|
||||
|
||||
func buildDeliveryFullChainEvidence(safeRoot string, run Run, previewPath string) (FullChainEvidence, bool, error) {
|
||||
manualPatch, err := readManualPatchStatus(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return FullChainEvidence{}, false, err
|
||||
}
|
||||
evidence := FullChainEvidence{
|
||||
Delivery: deliveryReceiptPath,
|
||||
StageReceipts: map[string]string{},
|
||||
ScreenshotEvidence: []string{},
|
||||
ManualPatch: manualPatch,
|
||||
}
|
||||
|
||||
requiredArtifactsComplete := true
|
||||
for _, item := range []struct {
|
||||
rel string
|
||||
set func(string)
|
||||
}{
|
||||
{"run.json", func(path string) { evidence.RunJSON = path }},
|
||||
{"request/request.json", func(path string) { evidence.Request = path }},
|
||||
{"request/source_manifest.json", func(path string) { evidence.SourceManifest = path }},
|
||||
{"request/entity_resolution.json", func(path string) { evidence.EntityResolution = path }},
|
||||
{"research/research_notes.md", func(path string) { evidence.ResearchNotes = path }},
|
||||
{"research/sources.json", func(path string) { evidence.Sources = path }},
|
||||
{"research/research_coverage.json", func(path string) { evidence.ResearchCoverage = path }},
|
||||
{"brief/design_brief.json", func(path string) { evidence.DesignBrief = path }},
|
||||
{"brief/visual_system.json", func(path string) { evidence.VisualSystem = path }},
|
||||
{"brief/typography_contract.json", func(path string) { evidence.TypographyContract = path }},
|
||||
{"outline/deck.json", func(path string) { evidence.Outline = path }},
|
||||
{"content/slide_content.json", func(path string) { evidence.SlideContent = path }},
|
||||
{"assets/assets_manifest.json", func(path string) { evidence.AssetManifest = path }},
|
||||
{renderedVisualReceiptPath, func(path string) { evidence.RenderedVisual = path }},
|
||||
{"quality_report.json", func(path string) { evidence.QualityReport = path }},
|
||||
{creativeQualityReportPath, func(path string) { evidence.CreativeQualityReport = path }},
|
||||
{chartRenderReceiptPath, func(path string) { evidence.ChartRenderReport = path }},
|
||||
{chartUsageReceiptPath, func(path string) { evidence.ChartUsageReport = path }},
|
||||
{deliveryChartQualityReportPath, func(path string) { evidence.ChartQualityReport = path }},
|
||||
} {
|
||||
path, err := existingDeliveryEvidencePath(safeRoot, item.rel)
|
||||
if err != nil {
|
||||
return FullChainEvidence{}, false, err
|
||||
}
|
||||
item.set(path)
|
||||
if path == "" {
|
||||
requiredArtifactsComplete = false
|
||||
}
|
||||
}
|
||||
|
||||
stageReceiptsComplete := true
|
||||
for _, stage := range DefaultStages() {
|
||||
path, err := existingDeliveryEvidencePath(safeRoot, stage.Receipt)
|
||||
if err != nil {
|
||||
return FullChainEvidence{}, false, err
|
||||
}
|
||||
evidence.StageReceipts[stage.Name] = path
|
||||
if path == "" {
|
||||
stageReceiptsComplete = false
|
||||
continue
|
||||
}
|
||||
valid, err := validDeliveryStageReceipt(safeRoot, stage, path)
|
||||
if err != nil {
|
||||
return FullChainEvidence{}, false, err
|
||||
}
|
||||
if !valid {
|
||||
stageReceiptsComplete = false
|
||||
}
|
||||
}
|
||||
|
||||
screenshots, err := screenshotEvidencePaths(safeRoot)
|
||||
if err != nil {
|
||||
return FullChainEvidence{}, false, err
|
||||
}
|
||||
evidence.ScreenshotEvidence = screenshots
|
||||
return evidence, requiredArtifactsComplete && stageReceiptsComplete && len(screenshots) > 0, nil
|
||||
}
|
||||
|
||||
func existingDeliveryEvidencePath(safeRoot string, rel string) (string, error) {
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return "", nil
|
||||
}
|
||||
return filepath.ToSlash(filepath.Clean(rel)), nil
|
||||
}
|
||||
|
||||
func validDeliveryStageReceipt(safeRoot string, stage Stage, rel string) (bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var receipt StageReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if strings.TrimSpace(receipt.Stage) != stage.Name {
|
||||
return false, nil
|
||||
}
|
||||
switch strings.TrimSpace(receipt.Status) {
|
||||
case StatusDone, "passed":
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func screenshotEvidencePaths(safeRoot string) ([]string, error) {
|
||||
paths := []string{}
|
||||
for _, pattern := range []string{
|
||||
filepath.Join(safeRoot, "screenshots", "*"),
|
||||
filepath.Join(safeRoot, "contact-sheet*"),
|
||||
filepath.Join(safeRoot, "contact_sheet*"),
|
||||
filepath.Join(safeRoot, "receipts", "screenshots", "*"),
|
||||
filepath.Join(safeRoot, "receipts", "contact-sheet*"),
|
||||
filepath.Join(safeRoot, "receipts", "contact_sheet*"),
|
||||
} {
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, match := range matches {
|
||||
info, err := os.Lstat(match)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(safeRoot, match)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths = appendUnique(paths, filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func readManualPatchStatus(safeRoot string, previewPath string) (ManualPatchStatus, error) {
|
||||
const manualPatchPath = "receipts/manual_patch.json"
|
||||
exists, err := runRegularFileExists(safeRoot, manualPatchPath)
|
||||
if err != nil {
|
||||
return ManualPatchStatus{}, err
|
||||
}
|
||||
if !exists {
|
||||
return ManualPatchStatus{Files: []string{}}, nil
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, manualPatchPath)
|
||||
if err != nil {
|
||||
return ManualPatchStatus{}, err
|
||||
}
|
||||
var patch ManualPatchStatus
|
||||
if err := json.Unmarshal(raw, &patch); err != nil {
|
||||
return ManualPatchStatus{}, fmt.Errorf("%s: invalid JSON: %w", manualPatchPath, err)
|
||||
}
|
||||
if !patch.Applied && len(patch.Files) == 0 && strings.TrimSpace(patch.Reason) == "" {
|
||||
var wrapped struct {
|
||||
ManualPatch ManualPatchStatus `json:"manual_patch"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapped); err != nil {
|
||||
return ManualPatchStatus{}, fmt.Errorf("%s: invalid JSON: %w", manualPatchPath, err)
|
||||
}
|
||||
patch = wrapped.ManualPatch
|
||||
}
|
||||
return normalizeManualPatchStatus(patch, previewPath), nil
|
||||
}
|
||||
|
||||
func normalizeManualPatchStatus(patch ManualPatchStatus, previewPath string) ManualPatchStatus {
|
||||
files := make([]string, 0, len(patch.Files))
|
||||
previewPath = filepath.ToSlash(filepath.Clean(strings.TrimSpace(previewPath)))
|
||||
for _, file := range patch.Files {
|
||||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(file)))
|
||||
if clean == "." || filepath.IsAbs(clean) || strings.HasPrefix(clean, "../") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(clean, "slides/") || strings.HasPrefix(clean, "assets/") || clean == previewPath {
|
||||
files = appendUnique(files, clean)
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return ManualPatchStatus{
|
||||
Applied: patch.Applied || len(files) > 0,
|
||||
Files: files,
|
||||
Reason: strings.TrimSpace(patch.Reason),
|
||||
}
|
||||
}
|
||||
|
||||
func readDeliveryPreviewEvidence(safeRoot string, previewPath string) (DeliveryPreviewEvidence, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, previewReceiptPath)
|
||||
if err != nil {
|
||||
return DeliveryPreviewEvidence{}, err
|
||||
}
|
||||
var report PreviewReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return DeliveryPreviewEvidence{}, fmt.Errorf("%s: invalid JSON: %w", previewReceiptPath, err)
|
||||
}
|
||||
return DeliveryPreviewEvidence{
|
||||
Path: previewPath,
|
||||
Status: report.Status,
|
||||
MissingAssetCount: report.MissingAssetCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readDeliverySemanticReport(safeRoot string) (AnyGenSemanticReport, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, anyGenSemanticReportPath)
|
||||
if err != nil {
|
||||
return AnyGenSemanticReport{}, err
|
||||
}
|
||||
var report AnyGenSemanticReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return AnyGenSemanticReport{}, fmt.Errorf("%s: invalid JSON: %w", anyGenSemanticReportPath, err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func deliveryStageStatus(run Run) map[string]string {
|
||||
status := make(map[string]string, len(run.Stages))
|
||||
for _, stage := range run.Stages {
|
||||
status[stage.Name] = stage.Status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func normalizedRouteProfile(profile string) string {
|
||||
profile = strings.TrimSpace(profile)
|
||||
if profile == "" {
|
||||
return RouteProfileLocalSVGDeck
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]bool) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func canRepairByAuthoring(report ValidationReport) bool {
|
||||
_, ok := authorRepairPaths(report)
|
||||
return ok
|
||||
}
|
||||
|
||||
func authorRepairPaths(report ValidationReport) (map[string]bool, bool) {
|
||||
if report.OK || len(report.Issues) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, issue := range report.Issues {
|
||||
path, ok := repairIssueAuthorPath(issue)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
paths[path] = true
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return paths, true
|
||||
}
|
||||
|
||||
func canRepairIssueByAuthoring(issue ValidationIssue) bool {
|
||||
_, ok := repairIssueAuthorPath(issue)
|
||||
return ok
|
||||
}
|
||||
|
||||
func repairIssueAuthorPath(issue ValidationIssue) (string, bool) {
|
||||
path := strings.TrimSpace(issue.Path)
|
||||
slidePath, err := previewSlideObjectPath(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(issue.Code) {
|
||||
case "svglide.path":
|
||||
return slidePath, strings.Contains(issue.Message, "missing or not a regular file")
|
||||
case "svglide.xml", "svglide.root", "svglide.slide_role", "svglide.viewbox", "svglide.visible_content":
|
||||
return slidePath, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func repairReceiptMessage(report RepairReport) string {
|
||||
if report.Status == "passed" {
|
||||
if report.Reauthored {
|
||||
return "lint, preview, quality, creative, and semantic report passed after reauthoring"
|
||||
}
|
||||
return "lint, preview, quality, creative, and semantic report passed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality != "passed" {
|
||||
return "quality gate failed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative != "passed" {
|
||||
return "creative quality gate failed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" && report.Creative == "passed" && report.Semantic != "passed" {
|
||||
return "semantic gate failed"
|
||||
}
|
||||
if report.Reauthored {
|
||||
return "repair reauthored slides but lint or preview still failed"
|
||||
}
|
||||
return "lint or preview failed"
|
||||
}
|
||||
|
||||
func writeQualityRepairQueue(safeRoot string, report QualityReport) error {
|
||||
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(queuePath, []byte(renderQualityRepairQueue(report)), 0o644)
|
||||
}
|
||||
|
||||
func renderQualityRepairQueue(report QualityReport) string {
|
||||
if report.Status == "passed" || len(report.Issues) == 0 {
|
||||
return "No repair needed.\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# SVGlide Repair Queue\n\n")
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
|
||||
if suggestion := qualityRepairSuggestion(issue.Code); suggestion != "" {
|
||||
fmt.Fprintf(&b, " - Repair: %s\n", suggestion)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func qualityRepairSuggestion(code string) string {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "svglide.quality.weak_cover":
|
||||
return "Rebuild cover with a full-bleed hero image or poster-style composition, reducing copy to one title and one subtitle."
|
||||
case "svglide.quality.low_evidence_density":
|
||||
return "Add a dense evidence grid or process image matrix using semantically relevant assets."
|
||||
case "svglide.quality.repetitive_layout":
|
||||
return "Vary slide rhythm across hero, thesis, evidence, detail, comparison, and closing layouts."
|
||||
case "svglide.quality.low_semantic_image_coverage":
|
||||
return "Replace decorative or generic visuals with images that prove the slide message."
|
||||
case "svglide.chart_render.missing_node":
|
||||
return "Install or expose Node.js v20+ and rerun StageAssets completion."
|
||||
case "svglide.chart_render.missing_node_dependencies":
|
||||
return "Run npm --prefix internal/svglide/chart_renderer install, then rerun StageAssets completion."
|
||||
case "svglide.chart_quality.invalid_spec_json":
|
||||
return "Regenerate the Vega-Lite spec as valid JSON."
|
||||
case "svglide.chart_quality.unknown_source_id":
|
||||
return "Use a source_id present in research/sources.json."
|
||||
case "svglide.chart_usage.not_referenced":
|
||||
return "Embed the rendered chart with <rect slide:role=\"chart\" href=\"assets/charts/<id>.svg\" .../>."
|
||||
case "svglide.chart_usage.hand_drawn_chart":
|
||||
return "Replace hand-drawn chart primitives with a rendered Vega-Lite chart asset."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
394
internal/svglide/repair_test.go
Normal file
394
internal/svglide/repair_test.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepairRunAuthorsMissingSlidesAndWritesFinalReceipt(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if !report.LintOK {
|
||||
t.Fatalf("LintOK = false, want true: %+v", report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "passed" {
|
||||
t.Fatalf("Quality = %q, want passed: %+v", report.Quality, report)
|
||||
}
|
||||
if report.Creative != "passed" {
|
||||
t.Fatalf("Creative = %q, want passed: %+v", report.Creative, report)
|
||||
}
|
||||
if !report.Reauthored {
|
||||
t.Fatalf("Reauthored = false, want true: %+v", report)
|
||||
}
|
||||
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"receipts/chart_quality.json",
|
||||
"quality_report.json",
|
||||
"creative_quality_report.json",
|
||||
"visual_receipts.json",
|
||||
"receipts/validate_preview_repair.json",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["stage"] != StageValidatePreviewRepair {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageValidatePreviewRepair)
|
||||
}
|
||||
if receipt["status"] != "passed" {
|
||||
t.Fatalf("receipt status = %v, want passed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "lint, preview, quality, creative, and semantic report passed after reauthoring" {
|
||||
t.Fatalf("receipt message = %v, want semantic-aware pass message", receipt["message"])
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["updated_at"]; ok {
|
||||
t.Fatalf("receipt contains updated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairWritesDeliveryReceipt(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteFullChainStageReceiptsForTest(t)
|
||||
mustWriteFullChainEvidenceArtifactsForTest(t)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing delivery receipt after passed repair: %v", err)
|
||||
}
|
||||
var delivery map[string]any
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatalf("invalid delivery receipt: %v", err)
|
||||
}
|
||||
if delivery["status"] != "ready" || delivery["deck"] != "outline/deck.json" {
|
||||
t.Fatalf("delivery receipt = %+v, want ready deck path", delivery)
|
||||
}
|
||||
if delivery["route_profile"] != RouteProfileLocalSVGDeck {
|
||||
t.Fatalf("delivery route_profile = %v, want %s", delivery["route_profile"], RouteProfileLocalSVGDeck)
|
||||
}
|
||||
if delivery["orchestrator"] != "mode_system_prompt_svg" || delivery["runtime_binding"] != "svglide_local_runtime_binding" {
|
||||
t.Fatalf("delivery prompt core = %+v, want orchestrator and runtime binding", delivery)
|
||||
}
|
||||
preview, ok := delivery["preview"].(map[string]any)
|
||||
if !ok || preview["path"] != "preview.html" || preview["status"] != "passed" {
|
||||
t.Fatalf("delivery preview = %+v, want preview object with passed status", delivery["preview"])
|
||||
}
|
||||
if _, ok := delivery["semantic_metrics"].(map[string]any); !ok {
|
||||
t.Fatalf("delivery missing semantic_metrics: %+v", delivery)
|
||||
}
|
||||
if delivery["legacy_runtime_executed"] != false {
|
||||
t.Fatalf("delivery legacy_runtime_executed = %v, want false", delivery["legacy_runtime_executed"])
|
||||
}
|
||||
for _, key := range []string{"core_prompt_ids", "observed_prompt_ids", "blocked_prompt_ids", "stage_status"} {
|
||||
if delivery[key] == nil {
|
||||
t.Fatalf("delivery missing %s: %+v", key, delivery)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"quality_report", "anygen_semantic_report", "visual_receipts", "creative_quality_report"} {
|
||||
if delivery[key] == "" || delivery[key] == nil {
|
||||
t.Fatalf("delivery receipt missing %s: %+v", key, delivery)
|
||||
}
|
||||
}
|
||||
fullChain, ok := delivery["full_chain_evidence"].(map[string]any)
|
||||
if !ok || fullChain["chart_render_report"] != chartRenderReceiptPath || fullChain["chart_usage_report"] != chartUsageReceiptPath || fullChain["chart_quality_report"] != chartQualityReportPath {
|
||||
t.Fatalf("delivery full_chain_evidence missing chart reports: %+v", delivery["full_chain_evidence"])
|
||||
}
|
||||
screenshots, ok := fullChain["screenshot_evidence"].([]any)
|
||||
if !ok || len(screenshots) == 0 {
|
||||
t.Fatalf("delivery full_chain_evidence missing screenshots: %+v", fullChain)
|
||||
}
|
||||
slides, ok := delivery["slides"].([]any)
|
||||
if !ok || len(slides) != 1 || slides[0] != "slides/01.svg" {
|
||||
t.Fatalf("delivery slides = %+v, want slides/01.svg", delivery["slides"])
|
||||
}
|
||||
for _, rel := range []string{"outline/deck.json", "slides/01.svg", "preview.html", "quality_report.json"} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("delivery path %s missing: %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRejectsLegacyRuntimeEvidenceForLocalProfile(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/receipts/tool_calls/research/slides_convert.json", `{"call_id":"slides_convert","status":"done"}`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err == nil {
|
||||
t.Fatalf("expected legacy runtime evidence to reject delivery, got report %+v", report)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "legacy runtime") || !strings.Contains(err.Error(), "slides_convert") {
|
||||
t.Fatalf("error = %v, want legacy runtime evidence for slides_convert", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunFailsWhenQualityFails(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"research/local.md","title":"Local source","excerpt":"Local excerpt","usage":"support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.LintOK != true {
|
||||
t.Fatalf("LintOK = %v, want true: %+v", report.LintOK, report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "failed" {
|
||||
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
|
||||
}
|
||||
|
||||
qualityRaw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var quality map[string]any
|
||||
if err := json.Unmarshal(qualityRaw, &quality); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality["status"] != "failed" {
|
||||
t.Fatalf("quality status = %v, want failed: %+v", quality["status"], quality)
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["status"] != "failed" {
|
||||
t.Fatalf("receipt status = %v, want failed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "quality gate failed" {
|
||||
t.Fatalf("receipt message = %v, want quality gate failed", receipt["message"])
|
||||
}
|
||||
deliveryRaw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing delivery receipt for failed quality repair: %v", err)
|
||||
}
|
||||
var delivery map[string]any
|
||||
if err := json.Unmarshal(deliveryRaw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery["status"] != StatusNeedsRepair {
|
||||
t.Fatalf("delivery status = %v, want %s", delivery["status"], StatusNeedsRepair)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunWritesVisualQualityRepairQueue(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[
|
||||
{"id":"s1","title":"Cover","summary":"Cover","role":"cover","visual_role":"hero_cover","key_message":"Cover","path":"slides/01.svg"},
|
||||
{"id":"s2","title":"Process","summary":"Process","role":"process","visual_role":"evidence_grid","key_message":"Process","path":"slides/02.svg"}
|
||||
]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/brief/visual_quality_contract.json", `{"visual_quality_contract":{"mode":"default_floor","deck_type":"brand_factory","must_have":{"evidence_page_min_visuals":4}}}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[
|
||||
{"id":"s1","content":"Cover","source_refs":["web1"],"visuals":[{"id":"cover","type":"image","instruction":"Cover image"}]},
|
||||
{"id":"s2","content":"Process","source_refs":["web1"],"visuals":[{"id":"p1","type":"image","instruction":"Process 1"},{"id":"p2","type":"image","instruction":"Process 2"}]}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"assets":[
|
||||
{"id":"cover","slide_id":"s1","visual_id":"cover","kind":"image","local_path":"assets/images/cover.png","source_url":"https://example.com/cover.png","status":"ready","usage":"Cover"},
|
||||
{"id":"p1","slide_id":"s2","visual_id":"p1","kind":"image","local_path":"assets/images/p1.png","source_url":"https://example.com/p1.png","status":"ready","usage":"Process 1"},
|
||||
{"id":"p2","slide_id":"s2","visual_id":"p2","kind":"image","local_path":"assets/images/p2.png","source_url":"https://example.com/p2.png","status":"ready","usage":"Process 2"}
|
||||
]}`)
|
||||
mustWriteTestFile(t, "demo/assets/images/cover.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p1.png", "png")
|
||||
mustWriteTestFile(t, "demo/assets/images/p2.png", "png")
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/cover.png" x="0" y="0" width="960" height="540"/></svg>`)
|
||||
mustWriteTestFile(t, "demo/slides/02.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="../assets/images/p1.png" x="40" y="40" width="320" height="180"/><image slide:role="image" href="../assets/images/p2.png" x="400" y="40" width="320" height="180"/><text x="48" y="300">Process</text></svg>`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Quality != "failed" {
|
||||
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
|
||||
}
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(queue), "Add a dense evidence grid or process image matrix") {
|
||||
t.Fatalf("repair queue = %q, want visual quality repair suggestion", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairReceiptMessagePrioritizesLintPreviewFailuresOverQuality(t *testing.T) {
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed"}); got != "lint or preview failed" {
|
||||
t.Fatalf("message = %q, want lint or preview failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed", Reauthored: true}); got != "repair reauthored slides but lint or preview still failed" {
|
||||
t.Fatalf("reauthored message = %q, want reauthored lint/preview failure", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "failed", Semantic: "passed"}); got != "quality gate failed" {
|
||||
t.Fatalf("quality-only message = %q, want quality gate failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "passed", Creative: "failed", Semantic: "passed"}); got != "creative quality gate failed" {
|
||||
t.Fatalf("creative-only message = %q, want creative quality gate failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "passed", Creative: "passed", Semantic: "failed"}); got != "semantic gate failed" {
|
||||
t.Fatalf("semantic-only message = %q, want semantic gate failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunOnlyReauthorsFailedSlidePaths(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
custom := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540" fill="#fff"/><text x="48" y="80">KEEP-CUSTOM-01</text></svg>`
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", custom)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != custom {
|
||||
t.Fatalf("slides/01.svg was overwritten:\n%s", string(raw))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "02.svg")); err != nil {
|
||||
t.Fatalf("missing reauthored slides/02.svg: %v", err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunReauthorsBackgroundOnlySVG(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", backgroundOnlySVG())
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunDoesNotAuthorInvalidSlidePath(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/../01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.Reauthored {
|
||||
t.Fatalf("Reauthored = true, want false for invalid path: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("svg_author receipt exists or stat failed, want no authoring: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunTreatsValidationArtifactWriteErrorAsFatal(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
if err := os.Remove(filepath.Join("demo", "repair_queue.md")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join("demo", "repair_queue.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := RepairRun("demo"); err == nil {
|
||||
t.Fatal("expected repair to return validation artifact write error")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("final repair receipt exists or stat failed, want no misleading final receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readRepairReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "validate_preview_repair.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
182
internal/svglide/run.go
Normal file
182
internal/svglide/run.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
StageRequest = "request"
|
||||
StageRequestResolution = "request_resolution"
|
||||
StageResearch = "research"
|
||||
StageDesignBrief = "design_brief"
|
||||
StageOutline = "outline"
|
||||
StageSlideContent = "slide_content"
|
||||
StageAssets = "assets"
|
||||
StageSVGAuthor = "svg_author"
|
||||
StageValidatePreviewRepair = "validate_preview_repair"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusReady = "ready"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusDone = "done"
|
||||
StatusFailed = "failed"
|
||||
StatusBlocked = "blocked"
|
||||
StatusNeedsRepair = "needs_repair"
|
||||
)
|
||||
|
||||
const (
|
||||
RouteProfileLocalSVGDeck = "local_svg_deck"
|
||||
routeProfileImportedPPTX = "imported_pptx"
|
||||
routeProfileTemplateReference = "template_reference"
|
||||
routeProfileLegacyEditor = "legacy_editor"
|
||||
)
|
||||
|
||||
const (
|
||||
VisualQualityModeStrict = "strict"
|
||||
VisualQualityModeWarn = "warn"
|
||||
)
|
||||
|
||||
type Run struct {
|
||||
Version int `json:"version"`
|
||||
Runtime string `json:"runtime"`
|
||||
Command string `json:"command"`
|
||||
RouteProfile string `json:"route_profile"`
|
||||
Title string `json:"title"`
|
||||
Input string `json:"input,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
VisualQualityMode string `json:"visual_quality_mode,omitempty"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
Out string `json:"out"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Stages []Stage `json:"stages"`
|
||||
Artifacts ArtifactPaths `json:"artifacts"`
|
||||
Policy Policy `json:"policy"`
|
||||
Agent AgentSession `json:"agent"`
|
||||
Intent Intent `json:"intent"`
|
||||
}
|
||||
|
||||
type AgentSession struct {
|
||||
Runtime string `json:"runtime"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type Intent struct {
|
||||
SourceMode string `json:"source_mode"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
Input string `json:"input,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
type Stage struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
Receipt string `json:"receipt"`
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
Deck string `json:"deck"`
|
||||
SlidesDir string `json:"slides_dir"`
|
||||
Preview string `json:"preview"`
|
||||
RepairQueue string `json:"repair_queue"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
PublishEnabled bool `json:"publish_enabled"`
|
||||
NetworkByAgent bool `json:"network_by_agent"`
|
||||
ImageGenerationByAgent bool `json:"image_generation_by_agent"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type NewRunConfig struct {
|
||||
Title string
|
||||
Input string
|
||||
Topic string
|
||||
Language string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Out string
|
||||
Now time.Time
|
||||
AgentRuntime string
|
||||
AgentID string
|
||||
RouteProfile string
|
||||
}
|
||||
|
||||
func NewRun(cfg NewRunConfig) Run {
|
||||
now := cfg.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
ts := now.Format(time.RFC3339)
|
||||
agentRuntime := cfg.AgentRuntime
|
||||
if agentRuntime == "" {
|
||||
agentRuntime = "codex"
|
||||
}
|
||||
routeProfile := strings.TrimSpace(cfg.RouteProfile)
|
||||
if routeProfile == "" {
|
||||
routeProfile = RouteProfileLocalSVGDeck
|
||||
}
|
||||
sourceMode := "local_file"
|
||||
if cfg.Topic != "" {
|
||||
sourceMode = "topic"
|
||||
}
|
||||
return Run{
|
||||
Version: 1,
|
||||
Runtime: "agent",
|
||||
Command: "slides +create-svglide",
|
||||
RouteProfile: routeProfile,
|
||||
Title: cfg.Title,
|
||||
Input: cfg.Input,
|
||||
Audience: cfg.Audience,
|
||||
DeliveryMode: cfg.DeliveryMode,
|
||||
VisualQualityMode: VisualQualityModeStrict,
|
||||
Pages: cfg.Pages,
|
||||
Out: cfg.Out,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentStage: StageRequest,
|
||||
Stages: DefaultStages(),
|
||||
Artifacts: ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
},
|
||||
Policy: Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByAgent: true,
|
||||
ImageGenerationByAgent: true,
|
||||
Overwrite: false,
|
||||
},
|
||||
Agent: AgentSession{
|
||||
Runtime: agentRuntime,
|
||||
ID: cfg.AgentID,
|
||||
},
|
||||
Intent: Intent{
|
||||
SourceMode: sourceMode,
|
||||
Topic: cfg.Topic,
|
||||
Input: cfg.Input,
|
||||
Language: cfg.Language,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultStages() []Stage {
|
||||
return []Stage{
|
||||
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
|
||||
{Name: StageRequestResolution, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"request/entity_resolution.json"}, Receipt: "receipts/request_resolution.json"},
|
||||
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json", "research/research_coverage.json"}, Receipt: "receipts/research.json"},
|
||||
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, Receipt: "receipts/design_brief.json"},
|
||||
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
|
||||
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md", "research/sources.json"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json", "content/slide_copy_plan.json"}, Receipt: "receipts/slide_content.json"},
|
||||
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/image_candidates.json", "assets/assets_plan.json", "assets/assets_manifest.json", "assets/asset_inventory.json", "assets/charts/chart_briefs.json", "assets/charts/chart_manifest.json", "receipts/chart_render.json"}, Receipt: "receipts/assets.json"},
|
||||
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_manifest.json", "assets/charts/chart_briefs.json", "assets/charts/chart_manifest.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
|
||||
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "receipts/rendered_visual.json", "receipts/image_usage.json", "receipts/chart_usage.json", "quality_report.json", "anygen_semantic_report.json", "visual_receipts.json", "creative_quality_report.json", "receipts/chart_quality.json", "repair_queue.md", "preview.html", "receipts/delivery.json"}, Receipt: "receipts/validate_preview_repair.json"},
|
||||
}
|
||||
}
|
||||
204
internal/svglide/run_test.go
Normal file
204
internal/svglide/run_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultStagesAreOrdered(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
want := []string{
|
||||
StageRequest,
|
||||
StageRequestResolution,
|
||||
StageResearch,
|
||||
StageDesignBrief,
|
||||
StageOutline,
|
||||
StageSlideContent,
|
||||
StageAssets,
|
||||
StageSVGAuthor,
|
||||
StageValidatePreviewRepair,
|
||||
}
|
||||
if len(stages) != len(want) {
|
||||
t.Fatalf("stage count = %d, want %d", len(stages), len(want))
|
||||
}
|
||||
for i, stage := range stages {
|
||||
if stage.Name != want[i] {
|
||||
t.Fatalf("stage[%d] = %q, want %q", i, stage.Name, want[i])
|
||||
}
|
||||
if stage.Status != StatusPending {
|
||||
t.Fatalf("stage[%d].Status = %q, want %q", i, stage.Status, StatusPending)
|
||||
}
|
||||
if stage.Inputs == nil {
|
||||
t.Fatalf("stage[%d].Inputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Outputs == nil {
|
||||
t.Fatalf("stage[%d].Outputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Receipt == "" {
|
||||
t.Fatalf("stage[%d] missing receipt path", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
svgAuthor := mustStage(t, stages, StageSVGAuthor)
|
||||
if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
|
||||
}
|
||||
repair := mustStage(t, stages, StageValidatePreviewRepair)
|
||||
if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesFinalStageRequiresQualityReport(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
final := stages[len(stages)-1]
|
||||
if final.Name != StageValidatePreviewRepair {
|
||||
t.Fatalf("final stage = %q, want %q", final.Name, StageValidatePreviewRepair)
|
||||
}
|
||||
if !stringSliceContains(final.Outputs, "quality_report.json") {
|
||||
t.Fatalf("final outputs = %+v, want quality_report.json", final.Outputs)
|
||||
}
|
||||
if !stringSliceContains(final.Outputs, "receipts/chart_quality.json") {
|
||||
t.Fatalf("final outputs = %+v, want receipts/chart_quality.json", final.Outputs)
|
||||
}
|
||||
if !stringSliceContains(final.Outputs, imageUsageReportPath) {
|
||||
t.Fatalf("final outputs = %+v, want %s", final.Outputs, imageUsageReportPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
research := mustStage(t, stages, StageResearch)
|
||||
want := []string{"request/request.json", "request/source_manifest.json", "request/entity_resolution.json"}
|
||||
if !reflect.DeepEqual(research.Inputs, want) {
|
||||
t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesRequestResolutionIsGateBetweenRequestAndResearch(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
requestResolution := mustStage(t, stages, StageRequestResolution)
|
||||
if !reflect.DeepEqual(requestResolution.Inputs, []string{"request/request.json", "request/source_manifest.json"}) {
|
||||
t.Fatalf("request_resolution Inputs = %v", requestResolution.Inputs)
|
||||
}
|
||||
if !reflect.DeepEqual(requestResolution.Outputs, []string{"request/entity_resolution.json"}) {
|
||||
t.Fatalf("request_resolution Outputs = %v", requestResolution.Outputs)
|
||||
}
|
||||
if requestResolution.Receipt != "receipts/request_resolution.json" {
|
||||
t.Fatalf("request_resolution Receipt = %q", requestResolution.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
outline := mustStage(t, stages, StageOutline)
|
||||
want := []string{"brief/design_brief.json", "brief/visual_system.json", "brief/typography_contract.json"}
|
||||
if !reflect.DeepEqual(outline.Inputs, want) {
|
||||
t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunSeparatesProtocolRuntimeFromAgentRuntime(t *testing.T) {
|
||||
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品和工程负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Out: ".lark-slides/svglide-runs/demo",
|
||||
Now: now,
|
||||
})
|
||||
if run.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", run.Version)
|
||||
}
|
||||
if run.Runtime != "agent" {
|
||||
t.Fatalf("Runtime = %q, want agent", run.Runtime)
|
||||
}
|
||||
if run.RouteProfile != RouteProfileLocalSVGDeck {
|
||||
t.Fatalf("RouteProfile = %q, want %q", run.RouteProfile, RouteProfileLocalSVGDeck)
|
||||
}
|
||||
if run.Agent.Runtime != "codex" {
|
||||
t.Fatalf("Agent.Runtime = %q, want default codex", run.Agent.Runtime)
|
||||
}
|
||||
if run.Intent.SourceMode != "local_file" || run.Intent.Input != "source.md" {
|
||||
t.Fatalf("Intent = %+v, want local_file source.md", run.Intent)
|
||||
}
|
||||
if run.Command != "slides +create-svglide" {
|
||||
t.Fatalf("Command = %q, want slides +create-svglide", run.Command)
|
||||
}
|
||||
if run.Title != "Demo" {
|
||||
t.Fatalf("Title = %q, want Demo", run.Title)
|
||||
}
|
||||
if run.Input != "source.md" {
|
||||
t.Fatalf("Input = %q, want source.md", run.Input)
|
||||
}
|
||||
if run.Audience != "产品和工程负责人" {
|
||||
t.Fatalf("Audience = %q, want 产品和工程负责人", run.Audience)
|
||||
}
|
||||
if run.DeliveryMode != "self_read" {
|
||||
t.Fatalf("DeliveryMode = %q, want self_read", run.DeliveryMode)
|
||||
}
|
||||
if run.Pages != 8 {
|
||||
t.Fatalf("Pages = %d, want 8", run.Pages)
|
||||
}
|
||||
if run.Out != ".lark-slides/svglide-runs/demo" {
|
||||
t.Fatalf("Out = %q, want .lark-slides/svglide-runs/demo", run.Out)
|
||||
}
|
||||
wantTS := now.Format(time.RFC3339)
|
||||
if run.CreatedAt != wantTS {
|
||||
t.Fatalf("CreatedAt = %q, want %q", run.CreatedAt, wantTS)
|
||||
}
|
||||
if run.UpdatedAt != wantTS {
|
||||
t.Fatalf("UpdatedAt = %q, want %q", run.UpdatedAt, wantTS)
|
||||
}
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
wantArtifacts := ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
}
|
||||
if run.Artifacts != wantArtifacts {
|
||||
t.Fatalf("Artifacts = %+v, want %+v", run.Artifacts, wantArtifacts)
|
||||
}
|
||||
wantStages := DefaultStages()
|
||||
if !reflect.DeepEqual(run.Stages, wantStages) {
|
||||
t.Fatalf("Stages = %+v, want %+v", run.Stages, wantStages)
|
||||
}
|
||||
wantPolicy := Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByAgent: true,
|
||||
ImageGenerationByAgent: true,
|
||||
Overwrite: false,
|
||||
}
|
||||
if run.Policy != wantPolicy {
|
||||
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStage(t *testing.T, stages []Stage, name string) Stage {
|
||||
t.Helper()
|
||||
for _, stage := range stages {
|
||||
if stage.Name == name {
|
||||
return stage
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return Stage{}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
807
internal/svglide/schema.go
Normal file
807
internal/svglide/schema.go
Normal file
@@ -0,0 +1,807 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type liteJSONSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
AdditionalProperties *bool `json:"additionalProperties"`
|
||||
Properties map[string]liteJSONSchema `json:"properties"`
|
||||
Items *liteJSONSchema `json:"items"`
|
||||
MinItems *int `json:"minItems"`
|
||||
Enum []string `json:"enum"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
var stageOutputSchemaPaths = map[string]string{
|
||||
"request/request.json": "schemas/request.schema.json",
|
||||
"request/source_manifest.json": "schemas/source_manifest.schema.json",
|
||||
"request/entity_resolution.json": "schemas/entity_resolution.schema.json",
|
||||
"research/sources.json": "schemas/sources.schema.json",
|
||||
"research/research_coverage.json": "schemas/research_coverage.schema.json",
|
||||
"brief/design_brief.json": "schemas/design_brief.schema.json",
|
||||
"brief/visual_system.json": "schemas/visual_system.schema.json",
|
||||
"brief/typography_contract.json": "schemas/typography_contract.schema.json",
|
||||
"outline/deck.json": "schemas/deck.schema.json",
|
||||
"content/slide_content.json": "schemas/slide_content.schema.json",
|
||||
"content/slide_copy_plan.json": "schemas/slide_copy_plan.schema.json",
|
||||
"assets/assets_plan.json": "schemas/assets_plan.schema.json",
|
||||
"assets/image_candidates.json": "schemas/image_candidates.schema.json",
|
||||
"assets/assets_manifest.json": "schemas/assets_manifest.schema.json",
|
||||
"assets/asset_inventory.json": "schemas/asset_inventory.schema.json",
|
||||
"assets/charts/chart_briefs.json": "schemas/chart_briefs.schema.json",
|
||||
"assets/charts/chart_manifest.json": "schemas/chart_manifest.schema.json",
|
||||
"receipts/chart_render.json": "schemas/chart_render.schema.json",
|
||||
"quality_report.json": "schemas/quality.schema.json",
|
||||
"anygen_semantic_report.json": "schemas/anygen_semantic_report.schema.json",
|
||||
"visual_receipts.json": "schemas/visual_receipts.schema.json",
|
||||
"creative_quality_report.json": "schemas/creative_quality.schema.json",
|
||||
"receipts/lint.json": "schemas/lint.schema.json",
|
||||
"receipts/preview.json": "schemas/preview.schema.json",
|
||||
"receipts/rendered_visual.json": "schemas/rendered_visual.schema.json",
|
||||
"receipts/image_usage.json": "schemas/image_usage.schema.json",
|
||||
"receipts/chart_usage.json": "schemas/chart_usage.schema.json",
|
||||
"receipts/chart_quality.json": "schemas/chart_quality.schema.json",
|
||||
"receipts/delivery.json": "schemas/delivery.schema.json",
|
||||
}
|
||||
|
||||
const AnyGenSemanticReportSchema = `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "contract", "metrics", "findings"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"contract": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "role", "path", "sha256", "rules"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"sha256": {"type": "string"},
|
||||
"rules": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slide_count", "slides_with_slide_role", "image_count", "text_count", "note_count", "source_ref_count", "missing_asset_count", "slides_without_source_refs", "visible_leak_count", "font_token_count", "missing_font_token_count"],
|
||||
"properties": {
|
||||
"slide_count": {"type": "integer"},
|
||||
"slides_with_slide_role": {"type": "integer"},
|
||||
"image_count": {"type": "integer"},
|
||||
"text_count": {"type": "integer"},
|
||||
"note_count": {"type": "integer"},
|
||||
"source_ref_count": {"type": "integer"},
|
||||
"missing_asset_count": {"type": "integer"},
|
||||
"slides_without_source_refs": {"type": "integer"},
|
||||
"visible_leak_count": {"type": "integer"},
|
||||
"font_token_count": {"type": "integer"},
|
||||
"missing_font_token_count": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rule_id", "kind", "severity", "code", "message"],
|
||||
"properties": {
|
||||
"rule_id": {"type": "string"},
|
||||
"kind": {"type": "string"},
|
||||
"severity": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"artifact": {"type": "string"},
|
||||
"field": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DeliveryReceiptSchema = `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "route_profile", "orchestrator", "runtime_binding", "deck", "slides_dir", "slides", "preview", "quality_report", "anygen_semantic_report", "visual_receipts", "creative_quality_report", "semantic_metrics", "stage_status", "full_chain_evidence", "legacy_runtime_executed", "legacy_tool_ids", "legacy_artifact_matches", "core_prompt_ids", "observed_prompt_ids", "blocked_prompt_ids"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["ready", "needs_repair"]},
|
||||
"route_profile": {"type": "string"},
|
||||
"orchestrator": {"type": "string"},
|
||||
"runtime_binding": {"type": "string"},
|
||||
"deck": {"type": "string"},
|
||||
"slides_dir": {"type": "string"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"preview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "status", "missing_asset_count"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"missing_asset_count": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
"quality_report": {"type": "string"},
|
||||
"anygen_semantic_report": {"type": "string"},
|
||||
"visual_receipts": {"type": "string"},
|
||||
"creative_quality_report": {"type": "string"},
|
||||
"semantic_metrics": {"type": "object"},
|
||||
"stage_status": {"type": "object"},
|
||||
"full_chain_evidence": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["run_json", "request", "source_manifest", "entity_resolution", "research_notes", "sources", "research_coverage", "design_brief", "visual_system", "typography_contract", "outline", "slide_content", "asset_manifest", "rendered_visual", "quality_report", "creative_quality_report", "chart_render_report", "chart_usage_report", "chart_quality_report", "delivery", "stage_receipts", "screenshot_evidence", "manual_patch"],
|
||||
"properties": {
|
||||
"run_json": {"type": "string"},
|
||||
"request": {"type": "string"},
|
||||
"source_manifest": {"type": "string"},
|
||||
"entity_resolution": {"type": "string"},
|
||||
"research_notes": {"type": "string"},
|
||||
"sources": {"type": "string"},
|
||||
"research_coverage": {"type": "string"},
|
||||
"design_brief": {"type": "string"},
|
||||
"visual_system": {"type": "string"},
|
||||
"typography_contract": {"type": "string"},
|
||||
"outline": {"type": "string"},
|
||||
"slide_content": {"type": "string"},
|
||||
"asset_manifest": {"type": "string"},
|
||||
"rendered_visual": {"type": "string"},
|
||||
"quality_report": {"type": "string"},
|
||||
"creative_quality_report": {"type": "string"},
|
||||
"chart_render_report": {"type": "string"},
|
||||
"chart_usage_report": {"type": "string"},
|
||||
"chart_quality_report": {"type": "string"},
|
||||
"delivery": {"type": "string"},
|
||||
"stage_receipts": {"type": "object"},
|
||||
"screenshot_evidence": {"type": "array", "items": {"type": "string"}},
|
||||
"manual_patch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["applied", "files"],
|
||||
"properties": {
|
||||
"applied": {"type": "boolean"},
|
||||
"files": {"type": "array", "items": {"type": "string"}},
|
||||
"reason": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"legacy_runtime_executed": {"type": "boolean"},
|
||||
"legacy_tool_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"legacy_artifact_matches": {"type": "array", "items": {"type": "string"}},
|
||||
"core_prompt_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"observed_prompt_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"blocked_prompt_ids": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func ValidateStageOutputs(root string) error {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, output := range stage.Outputs {
|
||||
if hasGlobMeta(output) || strings.ToLower(filepath.Ext(output)) != ".json" {
|
||||
continue
|
||||
}
|
||||
if stage.Name == StageValidatePreviewRepair && output == deliveryReceiptPath {
|
||||
continue
|
||||
}
|
||||
schemaPath, ok := stageOutputSchemaPaths[output]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateStageOutputSchema(safeRoot, output, schemaPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if output == "outline/deck.json" {
|
||||
if err := validateDeckSlideOutputPaths(safeRoot, output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
switch stage.Name {
|
||||
case StageRequestResolution:
|
||||
if err := ValidateRequestResolutionGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
case StageResearch:
|
||||
if err := ValidateResearchCoverageGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
case StageSlideContent:
|
||||
if err := ValidateSlideContentSourceRefsGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateSlideCopyPlanGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
case StageAssets:
|
||||
if err := ValidateAssetInventoryGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateImageCandidatesGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateChartBriefsGate(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type entityResolutionArtifact struct {
|
||||
ResolvedEntity struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ConfidenceBP int `json:"confidence_bp"`
|
||||
ConfidenceBand string `json:"confidence_band"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"resolved_entity"`
|
||||
Ambiguity struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"ambiguity"`
|
||||
ResearchRequired bool `json:"research_required"`
|
||||
ClarificationQuestion string `json:"clarification_question"`
|
||||
}
|
||||
|
||||
func ValidateRequestResolutionGate(safeRoot string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "request/entity_resolution.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("request/entity_resolution.json: read artifact: %w", err)
|
||||
}
|
||||
var resolution entityResolutionArtifact
|
||||
if err := json.Unmarshal(raw, &resolution); err != nil {
|
||||
return fmt.Errorf("request/entity_resolution.json: invalid JSON: %w", err)
|
||||
}
|
||||
name := strings.TrimSpace(resolution.ResolvedEntity.Name)
|
||||
entityType := strings.TrimSpace(resolution.ResolvedEntity.Type)
|
||||
reason := strings.TrimSpace(resolution.ResolvedEntity.Reason)
|
||||
if name == "" {
|
||||
return fmt.Errorf("request_resolution_gate: resolved_entity.name is required")
|
||||
}
|
||||
if entityType == "" {
|
||||
return fmt.Errorf("request_resolution_gate: resolved_entity.type is required")
|
||||
}
|
||||
if reason == "" {
|
||||
return fmt.Errorf("request_resolution_gate: resolved_entity.reason is required")
|
||||
}
|
||||
if resolution.ResolvedEntity.ConfidenceBP < 0 || resolution.ResolvedEntity.ConfidenceBP > 10000 {
|
||||
return fmt.Errorf("request_resolution_gate: confidence_bp %d outside 0..10000", resolution.ResolvedEntity.ConfidenceBP)
|
||||
}
|
||||
ambiguityStatus := strings.TrimSpace(resolution.Ambiguity.Status)
|
||||
if ambiguityStatus != "resolved" {
|
||||
if ambiguityStatus == "needs_clarification" && strings.TrimSpace(resolution.ClarificationQuestion) == "" {
|
||||
return fmt.Errorf("request_resolution_gate: needs_clarification requires clarification_question")
|
||||
}
|
||||
return fmt.Errorf("request_resolution_gate: ambiguity status %q blocks research", ambiguityStatus)
|
||||
}
|
||||
if entityType == "topic" {
|
||||
if !resolution.ResearchRequired {
|
||||
return fmt.Errorf("request_resolution_gate: topic requests must set research_required=true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if resolution.ResolvedEntity.ConfidenceBP < 7000 {
|
||||
return fmt.Errorf("request_resolution_gate: confidence_bp %d below 7000 for real-world entity type %q", resolution.ResolvedEntity.ConfidenceBP, entityType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sourcesArtifactForGate struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
Usage string `json:"usage"`
|
||||
Retrieval string `json:"retrieval"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
|
||||
type researchCoverageArtifact struct {
|
||||
Entity struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"entity"`
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
Usage string `json:"usage"`
|
||||
Status string `json:"status"`
|
||||
} `json:"sources"`
|
||||
Coverage struct {
|
||||
SourceCount int `json:"source_count"`
|
||||
TopicOnlyRationale string `json:"topic_only_rationale"`
|
||||
} `json:"coverage"`
|
||||
}
|
||||
|
||||
func ValidateResearchCoverageGate(safeRoot string) error {
|
||||
requestType, requestName, err := readResolvedRequestEntity(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceIDs, err := readKnownSourceIDs(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "research/research_coverage.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("research/research_coverage.json: read artifact: %w", err)
|
||||
}
|
||||
var coverage researchCoverageArtifact
|
||||
if err := json.Unmarshal(raw, &coverage); err != nil {
|
||||
return fmt.Errorf("research/research_coverage.json: invalid JSON: %w", err)
|
||||
}
|
||||
retrievedCount := 0
|
||||
identityRetrieved := false
|
||||
for _, source := range coverage.Sources {
|
||||
id := strings.TrimSpace(source.ID)
|
||||
if !sourceIDs[id] {
|
||||
return fmt.Errorf("research_coverage_gate: research_coverage source id %q not found in research/sources.json", id)
|
||||
}
|
||||
if strings.TrimSpace(source.Status) == "retrieved" {
|
||||
retrievedCount++
|
||||
if strings.TrimSpace(source.Usage) == "identity" {
|
||||
identityRetrieved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if coverage.Coverage.SourceCount != retrievedCount {
|
||||
return fmt.Errorf("research_coverage_gate: source_count = %d, want %d retrieved coverage sources", coverage.Coverage.SourceCount, retrievedCount)
|
||||
}
|
||||
coverageType := strings.TrimSpace(coverage.Entity.Type)
|
||||
if coverageType != requestType {
|
||||
return fmt.Errorf("research_coverage_gate: entity type %q does not match request/entity_resolution.json type %q", coverageType, requestType)
|
||||
}
|
||||
if requestName != "" && strings.TrimSpace(coverage.Entity.Name) != requestName {
|
||||
return fmt.Errorf("research_coverage_gate: entity name %q does not match request/entity_resolution.json name %q", strings.TrimSpace(coverage.Entity.Name), requestName)
|
||||
}
|
||||
if requestType == "topic" {
|
||||
if strings.TrimSpace(coverage.Coverage.TopicOnlyRationale) == "" {
|
||||
return fmt.Errorf("research_coverage_gate: topic_only_rationale is required for topic research")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !identityRetrieved {
|
||||
return fmt.Errorf("research_coverage_gate: real-world entity requires a retrieved identity source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readResolvedRequestEntity(safeRoot string) (string, string, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "request/entity_resolution.json")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("request/entity_resolution.json: read artifact: %w", err)
|
||||
}
|
||||
var resolution entityResolutionArtifact
|
||||
if err := json.Unmarshal(raw, &resolution); err != nil {
|
||||
return "", "", fmt.Errorf("request/entity_resolution.json: invalid JSON: %w", err)
|
||||
}
|
||||
entityType := strings.TrimSpace(resolution.ResolvedEntity.Type)
|
||||
if entityType == "" {
|
||||
return "", "", fmt.Errorf("request/entity_resolution.json: resolved_entity.type is required")
|
||||
}
|
||||
return entityType, strings.TrimSpace(resolution.ResolvedEntity.Name), nil
|
||||
}
|
||||
|
||||
func ValidateSlideContentSourceRefsGate(safeRoot string) error {
|
||||
sourceIDs, err := readKnownSourceIDs(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("content/slide_content.json: read artifact: %w", err)
|
||||
}
|
||||
var content struct {
|
||||
Slides []struct {
|
||||
ID string `json:"id"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &content); err != nil {
|
||||
return fmt.Errorf("content/slide_content.json: invalid JSON: %w", err)
|
||||
}
|
||||
for _, slide := range content.Slides {
|
||||
if len(slide.SourceRefs) == 0 {
|
||||
return fmt.Errorf("slide_content_source_refs_gate: slide %q source_refs is empty", strings.TrimSpace(slide.ID))
|
||||
}
|
||||
for _, ref := range slide.SourceRefs {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if !sourceIDs[ref] {
|
||||
return fmt.Errorf("slide_content_source_refs_gate: slide %q source_refs contains unknown source id %q", slide.ID, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type slideCopyPlanArtifact struct {
|
||||
Slides []struct {
|
||||
ID string `json:"id"`
|
||||
AudienceCopy struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []string `json:"labels"`
|
||||
} `json:"audience_copy"`
|
||||
ProductionInstruction struct {
|
||||
Layout string `json:"layout"`
|
||||
AssetIDs []string `json:"asset_ids"`
|
||||
} `json:"production_instruction"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
|
||||
func ValidateSlideCopyPlanGate(safeRoot string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_copy_plan.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("content/slide_copy_plan.json: read artifact: %w", err)
|
||||
}
|
||||
var plan slideCopyPlanArtifact
|
||||
if err := json.Unmarshal(raw, &plan); err != nil {
|
||||
return fmt.Errorf("content/slide_copy_plan.json: invalid JSON: %w", err)
|
||||
}
|
||||
for i, slide := range plan.Slides {
|
||||
visible := strings.Join([]string{slide.AudienceCopy.Title, slide.AudienceCopy.Body, strings.Join(slide.AudienceCopy.Labels, " ")}, " ")
|
||||
if productionInstructionLeakVisible(visible) {
|
||||
return fmt.Errorf("slide_copy_plan_gate: slides[%d] audience_copy contains production_instruction language", i)
|
||||
}
|
||||
if strings.TrimSpace(slide.ProductionInstruction.Layout) == "" {
|
||||
return fmt.Errorf("slide_copy_plan_gate: slides[%d] production_instruction.layout is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func productionInstructionLeakVisible(text string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
for _, marker := range []string{
|
||||
"production_instruction",
|
||||
"图片要完整",
|
||||
"必须让眼镜完整出现",
|
||||
"不要裁切",
|
||||
"来源来自",
|
||||
"用这张图",
|
||||
"封面要全屏",
|
||||
"用于判断",
|
||||
"sources:",
|
||||
"source note",
|
||||
} {
|
||||
if strings.Contains(lower, strings.ToLower(marker)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ValidateAssetInventoryGate(safeRoot string) error {
|
||||
manifest, err := readAssetsManifest(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventory, err := readAssetInventory(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventoryByID := make(map[string]assetInventoryItem, len(inventory.Items))
|
||||
inventoryByPath := make(map[string]assetInventoryItem, len(inventory.Items))
|
||||
for _, item := range inventory.Items {
|
||||
if id := strings.TrimSpace(item.ID); id != "" {
|
||||
inventoryByID[id] = item
|
||||
}
|
||||
if path := strings.TrimSpace(item.Path); path != "" {
|
||||
inventoryByPath[path] = item
|
||||
}
|
||||
}
|
||||
for _, asset := range manifest.Assets {
|
||||
if assetStatus(asset) != "ready" {
|
||||
continue
|
||||
}
|
||||
id := assetID(asset)
|
||||
path := assetPath(asset)
|
||||
if _, ok := inventoryByID[id]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := inventoryByPath[path]; ok {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("asset_inventory_gate: ready asset %q path %q is missing from asset_inventory", id, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readKnownSourceIDs(safeRoot string) (map[string]bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "research/sources.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("research/sources.json: read artifact: %w", err)
|
||||
}
|
||||
var sources sourcesArtifactForGate
|
||||
if err := json.Unmarshal(raw, &sources); err != nil {
|
||||
return nil, fmt.Errorf("research/sources.json: invalid JSON: %w", err)
|
||||
}
|
||||
ids := make(map[string]bool, len(sources.Sources))
|
||||
for _, source := range sources.Sources {
|
||||
id := strings.TrimSpace(source.ID)
|
||||
if id != "" {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func validateDeckSlideOutputPaths(safeRoot string, artifactPath string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
var deck struct {
|
||||
Slides []struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
if _, err := previewSlideObjectPath(slide.Path); err != nil {
|
||||
return fmt.Errorf("%s: field slides[%d].path: %w", artifactPath, i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStageOutputSchema(safeRoot, artifactPath, schemaPath string) error {
|
||||
artifactRaw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
schemaRaw, err := readRunRegularArtifact(safeRoot, schemaPath)
|
||||
if artifactPath == deliveryReceiptPath && schemaPath == stageOutputSchemaPaths[deliveryReceiptPath] {
|
||||
schemaRaw = []byte(DeliveryReceiptSchema)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("%s: read schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
schema, err := decodeLiteJSONSchema(schemaRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
value, err := decodeJSONValue(artifactRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
if err := validateJSONValue(schema, value, ""); err != nil {
|
||||
return fmt.Errorf("%s: %w", artifactPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLiteJSONSchema(raw []byte) (liteJSONSchema, error) {
|
||||
var schema liteJSONSchema
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if err := decoder.Decode(&schema); err != nil {
|
||||
return liteJSONSchema{}, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return liteJSONSchema{}, err
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func decodeJSONValue(raw []byte) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func rejectTrailingJSON(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("contains trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONValue(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
switch schema.Type {
|
||||
case "":
|
||||
return nil
|
||||
case "object":
|
||||
return validateJSONObject(schema, value, fieldPath)
|
||||
case "array":
|
||||
return validateJSONArray(schema, value, fieldPath)
|
||||
case "string":
|
||||
return validateJSONString(schema, value, fieldPath)
|
||||
case "integer":
|
||||
if !isJSONInteger(value) {
|
||||
return fmt.Errorf("field %s expected integer, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
case "number":
|
||||
if !isJSONNumber(value) {
|
||||
return fmt.Errorf("field %s expected number, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("field %s expected boolean, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("field %s uses unsupported schema type %q", displayFieldPath(fieldPath), schema.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONObject(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected object, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
for _, required := range schema.Required {
|
||||
if _, ok := object[required]; !ok {
|
||||
return fmt.Errorf("field %s is required", joinFieldPath(fieldPath, required))
|
||||
}
|
||||
}
|
||||
if schema.AdditionalProperties != nil && !*schema.AdditionalProperties {
|
||||
for name := range object {
|
||||
if _, ok := schema.Properties[name]; !ok {
|
||||
return fmt.Errorf("field %s is not allowed by additionalProperties:false", joinFieldPath(fieldPath, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, propertySchema := range schema.Properties {
|
||||
child, ok := object[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateJSONValue(propertySchema, child, joinFieldPath(fieldPath, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONArray(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
array, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected array, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if schema.MinItems != nil && len(array) < *schema.MinItems {
|
||||
return fmt.Errorf("field %s has %d items, want minItems %d", displayFieldPath(fieldPath), len(array), *schema.MinItems)
|
||||
}
|
||||
if schema.Items == nil {
|
||||
return nil
|
||||
}
|
||||
for i, item := range array {
|
||||
if err := validateJSONValue(*schema.Items, item, joinArrayFieldPath(fieldPath, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONString(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected string, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if len(schema.Enum) > 0 {
|
||||
for _, allowed := range schema.Enum {
|
||||
if text == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("field %s value %q is not in enum %v", displayFieldPath(fieldPath), text, schema.Enum)
|
||||
}
|
||||
if schema.Pattern != "" {
|
||||
matched, err := regexp.MatchString(schema.Pattern, text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %s has invalid pattern %q: %w", displayFieldPath(fieldPath), schema.Pattern, err)
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("field %s value %q does not match pattern %q", displayFieldPath(fieldPath), text, schema.Pattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isJSONInteger(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return isCanonicalJSONInteger(typed.String())
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isJSONNumber(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
_, err := strconv.ParseFloat(typed.String(), 64)
|
||||
return err == nil
|
||||
case float64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isCanonicalJSONInteger(value string) bool {
|
||||
if value == "" || strings.ContainsAny(value, ".eE") {
|
||||
return false
|
||||
}
|
||||
var parsed big.Int
|
||||
_, ok := parsed.SetString(value, 10)
|
||||
return ok
|
||||
}
|
||||
|
||||
func jsonValueType(value any) string {
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
case []any:
|
||||
return "array"
|
||||
case string:
|
||||
return "string"
|
||||
case json.Number, float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return fmt.Sprintf("%T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func joinFieldPath(parent, name string) string {
|
||||
if parent == "" {
|
||||
return name
|
||||
}
|
||||
return parent + "." + name
|
||||
}
|
||||
|
||||
func joinArrayFieldPath(parent string, index int) string {
|
||||
if parent == "" {
|
||||
return fmt.Sprintf("[%d]", index)
|
||||
}
|
||||
return fmt.Sprintf("%s[%d]", parent, index)
|
||||
}
|
||||
|
||||
func displayFieldPath(path string) string {
|
||||
if path == "" {
|
||||
return "$"
|
||||
}
|
||||
return path
|
||||
}
|
||||
714
internal/svglide/schema_test.go
Normal file
714
internal/svglide/schema_test.go
Normal file
@@ -0,0 +1,714 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateStageOutputsRejectsMissingRequiredField(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "title") {
|
||||
t.Fatalf("error = %v, want path and missing field", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsCurrentRequestArtifacts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaAcceptsVisualQualityContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "介绍日本金子眼镜 https://www.kaneko-optical.co.jp/zh-CHS/",
|
||||
"resolved_entity": {
|
||||
"name": "金子眼鏡株式会社 / KANEKO OPTICAL",
|
||||
"type": "brand",
|
||||
"confidence_bp": 9600,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户给出官网 URL 和品牌视觉图,目标是真实品牌官网介绍。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"profile": "brand_official_site",
|
||||
"requires_real_images": true,
|
||||
"min_image_coverage_bp": 7000,
|
||||
"min_unique_images": 6,
|
||||
"min_official_images": 4,
|
||||
"allow_repeated_hero_only": false,
|
||||
"reason": "真实品牌官网主题需要官网图片资产支撑。"
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["KANEKO OPTICAL"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("entity_resolution schema rejected visual_quality_contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaAcceptsBenchmarkVisualQualityContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "生成品牌介绍 slides",
|
||||
"resolved_entity": {
|
||||
"name": "Demo Brand",
|
||||
"type": "brand",
|
||||
"confidence_bp": 9200,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户请求是真实品牌介绍。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"mode": "benchmark",
|
||||
"benchmark_available": true,
|
||||
"benchmark_usage": "quality_floor_only",
|
||||
"deck_type": "brand_factory",
|
||||
"must_have": {
|
||||
"strong_cover": true,
|
||||
"semantic_image_coverage_min_bp": 9000,
|
||||
"evidence_page_min_visuals": 8,
|
||||
"max_repeated_layout_ratio_bp": 5000,
|
||||
"visual_roles_required": ["hero_cover", "evidence_grid"],
|
||||
"total_image_refs_min": 12
|
||||
}
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["Demo Brand"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("entity_resolution schema rejected benchmark visual_quality_contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaAcceptsStrictVisualContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "Generate a financial report for Nvidia Q4 2023",
|
||||
"resolved_entity": {
|
||||
"name": "NVIDIA Corporation",
|
||||
"type": "public_company_financial_report",
|
||||
"confidence_bp": 9600,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户请求是真实上市公司财报。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"profile": "data_report",
|
||||
"requires_real_images": true,
|
||||
"min_image_coverage_bp": 3000,
|
||||
"min_unique_images": 2,
|
||||
"min_official_images": 1,
|
||||
"allow_repeated_hero_only": false,
|
||||
"cover_requires_real_hero_image": true,
|
||||
"required_chart_renderer": "vega-lite",
|
||||
"min_chart_svg_assets": 6,
|
||||
"min_vega_lite_specs": 6,
|
||||
"typography_contract_required": true,
|
||||
"forbid_preview_wrapper_images_as_real_images": true,
|
||||
"reason": "真实公司财报需要真实企业/产品/数据中心图片、Vega-Lite 图表和字体契约。"
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["NVIDIA"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatalf("entity_resolution schema rejected strict visual contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityResolutionSchemaRejectsCopySourceBenchmarkUsage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
raw := `{
|
||||
"prompt_contract": ` + promptContractJSON(StageRequestResolution) + `,
|
||||
"input_text": "生成品牌介绍 slides",
|
||||
"resolved_entity": {
|
||||
"name": "Demo Brand",
|
||||
"type": "brand",
|
||||
"confidence_bp": 9200,
|
||||
"confidence_band": "high",
|
||||
"reason": "用户请求是真实品牌介绍。"
|
||||
},
|
||||
"visual_quality_contract": {
|
||||
"mode": "benchmark",
|
||||
"benchmark_available": true,
|
||||
"benchmark_usage": "copy_source",
|
||||
"deck_type": "brand_factory",
|
||||
"must_have": {"strong_cover": true}
|
||||
},
|
||||
"ambiguity": {"status": "resolved", "candidates": ["Demo Brand"]},
|
||||
"research_required": true,
|
||||
"clarification_question": ""
|
||||
}`
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", raw)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "benchmark_usage") {
|
||||
t.Fatalf("error = %v, want benchmark_usage context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsDeckSlidePathsThatPreviewRejects(t *testing.T) {
|
||||
for _, path := range []string{"slides/a%20.svg", "slides/.hidden.svg", "slides/a..b.svg", "slides/a:b.svg"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON(path))
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outline/deck.json") || !strings.Contains(err.Error(), "slides[0].path") {
|
||||
t.Fatalf("error = %v, want deck path context", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidDeckSlidePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON("slides/a%20.svg"))
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageOutline {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageOutline)
|
||||
}
|
||||
if got := stageStatus(t, run, StageOutline); got == StatusDone {
|
||||
t.Fatalf("outline stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "outline.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("outline receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidValidatePreviewRepairReceipts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "lint",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"failed"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/lint.json",
|
||||
},
|
||||
{
|
||||
name: "preview",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":"yes"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/preview.json",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
tt.setup(t)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.path) {
|
||||
t.Fatalf("error = %v, want path %s", err, tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidQualityReportSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWritePassedImageUsageForTest(t)
|
||||
mustWritePassedChartUsageForTest(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(`{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected quality report schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") {
|
||||
t.Fatalf("error = %v, want path quality_report.json", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsVisualReceiptsMissingContainerContract(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"single_claim_poster","thumbnail_job":"readable title","visual_center":"title block","topic_fit_claim":"matches demo topic","information_density_plan":"one claim with support","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"quiet synthesis","data_visual_rationale":"","source_evidence":["web1 supports claim"],"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected visual receipts schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "visual_receipts.json") || !strings.Contains(err.Error(), "container_fit_plan") {
|
||||
t.Fatalf("error = %v, want visual_receipts.json and container_fit_plan", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSourcesMissingRetrieval(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
if err := os.WriteFile(filepath.Join("demo", "research", "sources.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"supporting evidence"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected retrieval schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "research/sources.json") || !strings.Contains(err.Error(), "retrieval") {
|
||||
t.Fatalf("error = %v, want research/sources.json and retrieval", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSlideContentMissingSourceRefsOrVisualIds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing source_refs",
|
||||
raw: `{"prompt_contract":` + promptContractJSON(StageSlideContent) + `,"slides":[{"id":"s1","content":"Plan","visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "source_refs",
|
||||
},
|
||||
{
|
||||
name: "missing visual id",
|
||||
raw: `{"prompt_contract":` + promptContractJSON(StageSlideContent) + `,"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[{"type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "visuals[0].id",
|
||||
},
|
||||
{
|
||||
name: "empty visuals",
|
||||
raw: `{"prompt_contract":` + promptContractJSON(StageSlideContent) + `,"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[]}]}`,
|
||||
want: "visuals",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
if err := os.WriteFile(filepath.Join("demo", "content", "slide_content.json"), []byte(tt.raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected slide content schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "content/slide_content.json") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want content/slide_content.json and %s", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsAssetsMissingStatus(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"https://example.com/a.png","usage":"hero image"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"no_image_reason":"schema failure fixture","candidates":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
|
||||
mustWriteNoChartAssetsForTest(t)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected asset schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "status") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json and status", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsExperimentAssetPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "outside images", path: "../a.png"},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"`+tt.path+`","usage":"hero image","status":"ready"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_manifest.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","kind":"image","local_path":"`+tt.path+`","usage":"hero image","status":"ready"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "image_candidates.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":false,"candidates":[{"id":"cand-a1","query":"hero image","source_url":"`+tt.path+`","source_class":"user_provided","format":"png","width":960,"height":540,"has_alpha":true,"asset_role":"hero_photo","fit_role":"split_panel","local_path":"`+tt.path+`","score_bp":9000,"selected":true,"selection_reason":"test fixture selected image","format_exception_reason":"","rejection_reason":""}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "asset_inventory.json"), []byte(`{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[{"id":"a1","path":"`+tt.path+`","source_url":"","width":960,"height":540,"semantic_type":"image","large_ok":true,"full_bleed_ok":true,"recommended_use":"hero image","avoid_reason":"","format":"png","has_alpha":true,"asset_role":"hero_photo","fit_role":"split_panel","candidate_id":"cand-a1","selection_reason":"test fixture selected image","format_exception_reason":""}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteNoChartAssetsForTest(t)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected experiment asset path to pass schema validation, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsMissingArtifactPromptContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[]}`)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected assets artifact without prompt_contract to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "prompt_contract") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json prompt_contract rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsWrongPromptContractOrchestrator(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{
|
||||
"prompt_contract": {
|
||||
"protocol": "anygen-svg-slides",
|
||||
"stage": "assets",
|
||||
"context_receipt": "receipts/prompt_context/assets.json",
|
||||
"orchestrator": "wrong_orchestrator",
|
||||
"protocol_reference": "svg_reference",
|
||||
"required_prompt_ids": ["mode_system_prompt_svg", "svg_reference"]
|
||||
},
|
||||
"mode": "experiment_unrestricted_assets",
|
||||
"assets": []
|
||||
}`)
|
||||
|
||||
err := ValidateArtifactPromptContractForStage("demo", StageAssets, []string{"assets/assets_plan.json"})
|
||||
if err == nil {
|
||||
t.Fatal("expected wrong prompt_contract.orchestrator to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "orchestrator") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json orchestrator rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSchemasIncludeAnyGenQualityContracts(t *testing.T) {
|
||||
schemas := DefaultSchemas()
|
||||
for _, name := range []string{
|
||||
"entity_resolution.schema.json",
|
||||
"sources.schema.json",
|
||||
"research_coverage.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"slide_copy_plan.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"assets_manifest.schema.json",
|
||||
"image_candidates.schema.json",
|
||||
"asset_inventory.schema.json",
|
||||
"chart_manifest.schema.json",
|
||||
"image_usage.schema.json",
|
||||
"chart_quality.schema.json",
|
||||
"typography_contract.schema.json",
|
||||
"quality.schema.json",
|
||||
} {
|
||||
if strings.TrimSpace(schemas[name]) == "" {
|
||||
t.Fatalf("schema %s is missing", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["sources.schema.json"], `"retrieval"`) {
|
||||
t.Fatalf("sources schema missing retrieval contract: %s", schemas["sources.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["research_coverage.schema.json"], `"source_count"`) || !strings.Contains(schemas["research_coverage.schema.json"], `"topic_only_rationale"`) {
|
||||
t.Fatalf("research coverage schema missing coverage fields: %s", schemas["research_coverage.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"source_refs"`) {
|
||||
t.Fatalf("slide content schema missing source_refs: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"minItems"`) {
|
||||
t.Fatalf("slide content schema missing non-empty source_refs contract: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"visuals"`) {
|
||||
t.Fatalf("slide content schema missing visuals: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["deck.schema.json"], `"visual_role"`) || !strings.Contains(schemas["deck.schema.json"], `"visual_intent"`) {
|
||||
t.Fatalf("deck schema missing visual role fields: %s", schemas["deck.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["deck.schema.json"], `"layout_family"`) || !strings.Contains(schemas["deck.schema.json"], `"fusion_candidate"`) {
|
||||
t.Fatalf("deck schema missing visual family fields: %s", schemas["deck.schema.json"])
|
||||
}
|
||||
if strings.TrimSpace(schemas["visual_receipts.schema.json"]) == "" || strings.TrimSpace(schemas["creative_quality.schema.json"]) == "" {
|
||||
t.Fatalf("visual quality schemas are missing: receipts=%q creative=%q", schemas["visual_receipts.schema.json"], schemas["creative_quality.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_copy_plan.schema.json"], `"audience_copy"`) || !strings.Contains(schemas["slide_copy_plan.schema.json"], `"production_instruction"`) {
|
||||
t.Fatalf("slide copy plan schema missing audience_copy/production_instruction: %s", schemas["slide_copy_plan.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], `"slide_id"`) {
|
||||
t.Fatalf("assets schema missing slide_id: %s", schemas["assets_plan.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["assets_manifest.schema.json"], `"local_path"`) || !strings.Contains(schemas["assets_manifest.schema.json"], `"source_url"`) {
|
||||
t.Fatalf("assets manifest schema missing local_path/source_url: %s", schemas["assets_manifest.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["image_candidates.schema.json"], `"query"`) || !strings.Contains(schemas["image_candidates.schema.json"], `"format_exception_reason"`) {
|
||||
t.Fatalf("image candidates schema missing query/format exception fields: %s", schemas["image_candidates.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["asset_inventory.schema.json"], `"large_ok"`) || !strings.Contains(schemas["asset_inventory.schema.json"], `"candidate_id"`) {
|
||||
t.Fatalf("asset inventory schema missing image suitability fields: %s", schemas["asset_inventory.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["image_usage.schema.json"], `"area_bp"`) || !strings.Contains(schemas["image_usage.schema.json"], `"usage_status"`) {
|
||||
t.Fatalf("image usage schema missing usage metrics: %s", schemas["image_usage.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["chart_manifest.schema.json"], `"vega-lite"`) || !strings.Contains(schemas["chart_manifest.schema.json"], `"spec_path"`) {
|
||||
t.Fatalf("chart manifest schema missing Vega-Lite fields: %s", schemas["chart_manifest.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["chart_quality.schema.json"], `"missing_unit_count"`) || !strings.Contains(schemas["chart_quality.schema.json"], `"decorative_chart_count"`) {
|
||||
t.Fatalf("chart quality schema missing core metrics: %s", schemas["chart_quality.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["typography_contract.schema.json"], `"display"`) || !strings.Contains(schemas["typography_contract.schema.json"], `"number"`) {
|
||||
t.Fatalf("typography contract schema missing font roles: %s", schemas["typography_contract.schema.json"])
|
||||
}
|
||||
for _, want := range []string{`"experiment_unrestricted_assets"`, `"chart"`, `"table"`, `"crop"`, `"deferred"`} {
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], want) {
|
||||
t.Fatalf("assets schema missing %s: %s", want, schemas["assets_plan.schema.json"])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["quality.schema.json"], `"metrics"`) {
|
||||
t.Fatalf("quality schema missing metrics: %s", schemas["quality.schema.json"])
|
||||
}
|
||||
for _, want := range []string{`"strong_cover"`, `"evidence_page_max_visuals"`, `"repeated_layout_ratio_bp"`, `"visual_role_coverage_bp"`, `"real_image_assets"`, `"vega_lite_spec_assets"`, `"typography_contract_present"`, `"image_role_format_issue_count"`, `"image_usage_issue_count"`} {
|
||||
if !strings.Contains(schemas["quality.schema.json"], want) {
|
||||
t.Fatalf("quality schema missing %s: %s", want, schemas["quality.schema.json"])
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"cover_requires_real_hero_image"`, `"required_chart_renderer"`, `"typography_contract_required"`} {
|
||||
if !strings.Contains(schemas["entity_resolution.schema.json"], want) {
|
||||
t.Fatalf("entity resolution schema missing %s: %s", want, schemas["entity_resolution.schema.json"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteResearchRejectsCoverageSourceIDsNotInSources(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"给阿嬷的情书","type":"film"},"queries":[{"query":"给阿嬷的情书 电影","purpose":"entity_disambiguation"}],"sources":[{"id":"missing","title":"Missing","url":"https://example.com/missing","retrieved_at":"2026-07-04T00:00:00Z","usage":"identity","status":"retrieved"}],"coverage":{"identity_confirmed":true,"has_reliable_source":true,"minimum_source_count_met":true,"source_count":1,"topic_only_rationale":""}}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageResearch)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown coverage source id to block research completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "research_coverage") || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("error = %v, want research_coverage missing source id", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteResearchRejectsCoverageEntityTypeDowngrade(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"context","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"给阿嬷的情书","type":"topic"},"queries":[{"query":"给阿嬷的情书","purpose":"context"}],"sources":[{"id":"s1","title":"Example","url":"https://example.com","retrieved_at":"2026-07-04T00:00:00Z","usage":"context","status":"retrieved"}],"coverage":{"identity_confirmed":false,"has_reliable_source":true,"minimum_source_count_met":true,"source_count":1,"topic_only_rationale":"伪装成开放主题以跳过 identity source。"}}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageResearch)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected research coverage entity type downgrade to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "entity type") || !strings.Contains(err.Error(), "request/entity_resolution.json") {
|
||||
t.Fatalf("error = %v, want entity type mismatch against request/entity_resolution.json", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSlideContentRejectsUnknownSourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# slides\n")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"Claim","body":"Body","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Plan","source_refs":["missing"],"visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageSlideContent)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown slide source_refs to block slide_content completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "source_refs") || !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("error = %v, want source_refs missing source id", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlideCopyPlanRejectsProductionInstructionInAudienceCopy(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# slides\n")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"产品页必须让眼镜完整出现","body":"Claim","labels":[]},"production_instruction":{"layout":"图片要完整,不要裁切","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageSlideContent)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected production instruction leakage in audience_copy to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "slide_copy_plan") || !strings.Contains(err.Error(), "production_instruction") {
|
||||
t.Fatalf("error = %v, want slide_copy_plan production_instruction rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSlideContentRejectsEmptySourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.md", "# slides\n")
|
||||
mustWriteTestFile(t, "demo/content/slide_copy_plan.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","audience_copy":{"title":"Claim","body":"Body","labels":[]},"production_instruction":{"layout":"Text-only","asset_ids":[]}}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Plan","source_refs":[],"visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`)
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageSlideContent)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected empty source_refs to block slide_content completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "source_refs") || (!strings.Contains(err.Error(), "empty") && !strings.Contains(err.Error(), "0 items")) {
|
||||
t.Fatalf("error = %v, want empty source_refs rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteAssetsRejectsManifestAssetMissingInventoryEntry(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
mustWriteTestFile(t, "demo/request/request.json", `{"title":"Demo","input":"source.md","pages":1}`)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("topic", 5000, "medium", "resolved", ""))
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"identity","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"system":{"name":"demo","theme":"editorial","palette":["#000000","#ffffff"],"type_scale":{"title":48},"layout_principles":["clear hierarchy"]}}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON("slides/01.svg"))
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"prompt_contract":`+promptContractJSON(StageSlideContent)+`,"slides":[{"id":"s1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"v1","type":"image","instruction":"Use hero"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"assets":[{"id":"hero","slide_id":"s1","kind":"image","local_path":"assets/images/hero.png","usage":"Hero","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/image_candidates.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"requires_real_images":true,"candidates":[{"id":"cand-hero","query":"hero photo","source_url":"https://example.com/hero.png","source_class":"user_provided","format":"png","width":1200,"height":800,"has_alpha":false,"asset_role":"hero_photo","fit_role":"full_bleed","local_path":"assets/images/hero.png","score_bp":9000,"selected":true,"selection_reason":"user-provided hero image","format_exception_reason":"","rejection_reason":""}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/asset_inventory.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"items":[]}`)
|
||||
writePromptContextReceiptForTest(t, StageAssets, map[string]string{})
|
||||
writeToolCallReceiptForTest(t, StageAssets, "resolve_image_assets")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected manifest asset without inventory entry to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "asset_inventory") || !strings.Contains(err.Error(), "hero") {
|
||||
t.Fatalf("error = %v, want asset_inventory missing hero rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validSchemaDeckJSON(path string) string {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageOutline) + `,"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","layout_family":"full_bleed_hero","layout_archetype":"full_bleed_photo_title","layout_signature":"full_bleed_poster","story_function":"hook","primary_asset_role":"topic anchor","fusion_candidate":false,"path":"` + path + `"}]}`
|
||||
}
|
||||
|
||||
func validQualityReportJSON() string {
|
||||
return `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":1,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsNonCanonicalIntegers(t *testing.T) {
|
||||
for _, pages := range []string{"8.0", "8e0", "0.99999999999999999"} {
|
||||
t.Run(pages, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
raw := `{"title":"Demo","input":"source.md","pages":` + pages + `}`
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "pages") {
|
||||
t.Fatalf("error = %v, want path and pages field", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidCurrentStageOutputSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "source_manifest.json"), []byte(`{"sources":[{"path":"source.md","type":"remote"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "request.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
228
internal/svglide/semantic_contract_test.go
Normal file
228
internal/svglide/semantic_contract_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPromptManifestIncludesSemanticContract(t *testing.T) {
|
||||
manifest := DefaultPromptManifest()
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Name == "anygen_semantic_contract" {
|
||||
if entry.Path != "skills/lark-slides/references/anygen-svg/semantic_contract.md" {
|
||||
t.Fatalf("semantic contract path = %q, want semantic_contract.md", entry.Path)
|
||||
}
|
||||
if !entry.Always {
|
||||
t.Fatalf("semantic contract entry = %+v, want always in prompt context", entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("prompt manifest missing anygen_semantic_contract: %+v", manifest.Entries)
|
||||
}
|
||||
|
||||
func TestDefaultPromptManifestIncludesVisualQualityOverlay(t *testing.T) {
|
||||
manifest := DefaultPromptManifest()
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Name != "svglide_visual_quality_overlay" {
|
||||
continue
|
||||
}
|
||||
if entry.Path != "skills/lark-slides/references/anygen-svg/svglide_visual_quality_overlay.md" {
|
||||
t.Fatalf("visual overlay path = %q, want svglide_visual_quality_overlay.md", entry.Path)
|
||||
}
|
||||
if !entry.Always || entry.Role != "runtime_binding" {
|
||||
t.Fatalf("visual overlay entry = %+v, want always runtime_binding", entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("prompt manifest missing svglide_visual_quality_overlay: %+v", manifest.Entries)
|
||||
}
|
||||
|
||||
func TestSemanticContractRejectsUnknownRuleField(t *testing.T) {
|
||||
path := writeSemanticContractFixture(t, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
rules:
|
||||
- id: bad_rule
|
||||
kind: artifact_exists
|
||||
artifact: outline/deck.json
|
||||
severity: error
|
||||
unknown_field: should_fail
|
||||
---
|
||||
# bad
|
||||
`)
|
||||
_, err := LoadSemanticContractFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown rule field to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown_field") {
|
||||
t.Fatalf("error = %v, want unknown_field", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticContractRejectsRuleMissingIDKindOrSeverity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rule string
|
||||
want string
|
||||
}{
|
||||
{name: "id", rule: "kind: artifact_exists\n artifact: outline/deck.json\n severity: error", want: "missing id"},
|
||||
{name: "kind", rule: "id: missing_kind\n artifact: outline/deck.json\n severity: error", want: "missing kind"},
|
||||
{name: "severity", rule: "id: missing_severity\n kind: artifact_exists\n artifact: outline/deck.json", want: "missing severity"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := writeSemanticContractFixture(t, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
rules:
|
||||
- `+tc.rule+`
|
||||
---
|
||||
# bad
|
||||
`)
|
||||
_, err := LoadSemanticContractFile(path)
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s to be rejected", tc.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticContractRejectsUnsupportedSeverity(t *testing.T) {
|
||||
path := writeSemanticContractFixture(t, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
rules:
|
||||
- id: bad_severity
|
||||
kind: artifact_exists
|
||||
artifact: outline/deck.json
|
||||
severity: errror
|
||||
---
|
||||
# bad
|
||||
`)
|
||||
_, err := LoadSemanticContractFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported severity to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported severity") {
|
||||
t.Fatalf("error = %v, want unsupported severity", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportIncludesMetrics(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","notes":"Speaker note","source_refs":["s1"],"visuals":[{"id":"hero","type":"image","instruction":"Use hero"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[{"id":"hero","slide_id":"slide-1","kind":"image","local_path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "images", "hero.png"), "png")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<slide:note>Speaker note</slide:note><image slide:role="image" href="../assets/images/hero.png"/><text x="1" y="1">Claim</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Metrics.SlideCount != 1 || report.Metrics.ImageCount != 1 || report.Metrics.NoteCount != 1 || report.Metrics.SourceRefCount != 1 {
|
||||
t.Fatalf("metrics = %+v, want slide/image/note/source counts", report.Metrics)
|
||||
}
|
||||
if report.Metrics.MissingAssetCount != 0 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 0", report.Metrics.MissingAssetCount)
|
||||
}
|
||||
if report.Metrics.VisibleLeakCount != 0 || report.Metrics.FontTokenCount != 4 || report.Metrics.MissingFontTokenCount != 0 {
|
||||
t.Fatalf("metrics = %+v, want no visible leaks and all font tokens", report.Metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsRemoteReadyImageForLocalProfile(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"hero","type":"image","instruction":"Use hero"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[{"id":"hero","slide_id":"slide-1","kind":"image","local_path":"https://example.com/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<image slide:role="image" href="https://example.com/hero.png"/><text x="1" y="1">Claim</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if !semanticFindingsContain(report.Findings, "local_svg_deck ready image asset path") {
|
||||
t.Fatalf("findings = %+v, want local_svg_deck remote ready asset rejection", report.Findings)
|
||||
}
|
||||
if report.Metrics.MissingAssetCount != 1 {
|
||||
t.Fatalf("MissingAssetCount = %d, want 1 for remote ready asset in local profile", report.Metrics.MissingAssetCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsVisibleInstructionLeak(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"none","type":"none","instruction":"Text only"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[],"no_image_reason":"Text-only deck"}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">`+fontTokenStyleForTest()+`<text x="1" y="1">Sources: https://example.com</text><text x="1" y="40">产品页必须让眼镜完整出现</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if !semanticFindingsContain(report.Findings, "visible leak") {
|
||||
t.Fatalf("findings = %+v, want visible leak finding", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyGenSemanticReportRejectsMissingFontTokens(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "content", "slide_content.json"), `{"slides":[{"id":"slide-1","content":"Claim","source_refs":["s1"],"visuals":[{"id":"none","type":"none","instruction":"Text only"}]}]}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "assets", "assets_manifest.json"), `{"assets":[],"no_image_reason":"Text-only deck"}`)
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><text x="1" y="1">Claim</text></svg>`)
|
||||
|
||||
report, err := EvaluateAnyGenSemantics("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if !semanticFindingsContain(report.Findings, "font token") {
|
||||
t.Fatalf("findings = %+v, want font token finding", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func semanticFindingsContain(findings []SemanticFinding, needle string) bool {
|
||||
for _, finding := range findings {
|
||||
if strings.Contains(finding.Message, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func promptManifestHasSemanticContract() bool {
|
||||
for _, entry := range DefaultPromptManifest().Entries {
|
||||
if entry.Name == "anygen_semantic_contract" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeSemanticContractFixture(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "semantic_contract.md")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
184
internal/svglide/stage.go
Normal file
184
internal/svglide/stage.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StageReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
func CompleteCurrentStage(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
index, stage, err := currentStageWithIndex(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if stage.Name == StageAssets {
|
||||
if err := ensureEmptyChartBriefsForNoChartDeck(safeRoot); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
chartRender, err := RenderVegaLiteCharts(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if chartRender.Status != "passed" {
|
||||
return StatusReport{}, fmt.Errorf("chart_render_failed: %s status is %q, want passed", chartRenderReceiptPath, chartRender.Status)
|
||||
}
|
||||
}
|
||||
outputsForMissing := stage.Outputs
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
outputsForMissing = outputsWithoutDeliveryReceipt(stage.Outputs)
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, outputsForMissing)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if len(missingOutputs) > 0 {
|
||||
return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", "))
|
||||
}
|
||||
|
||||
promptReceipt, err := ValidatePromptContextForStage(safeRoot, stage.Name, run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := ValidateToolCallReceiptsForStage(safeRoot, stage.Name, run, promptReceipt); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := ValidateArtifactPromptContractForStage(safeRoot, stage.Name, stage.Outputs); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := ValidateStageOutputs(root); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
stageReceiptWritten := false
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
semantic, err := EvaluateAnyGenSemantics(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if semantic.Status != "passed" {
|
||||
return StatusReport{}, fmt.Errorf("semantic_gate_failed: %s status is %q, want passed", anyGenSemanticReportPath, semantic.Status)
|
||||
}
|
||||
if err := validateFinalStageReceiptsPassed(safeRoot); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
deliveryRun := run
|
||||
deliveryRun.Stages[index].Status = StatusDone
|
||||
deliveryRun.CurrentStage = stage.Name
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: stage.Name,
|
||||
Status: StatusDone,
|
||||
Artifacts: stage.Outputs,
|
||||
}); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
stageReceiptWritten = true
|
||||
if _, err := writeDeliveryReceipt(safeRoot, deliveryRun); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if err := validateStageOutputSchema(safeRoot, deliveryReceiptPath, stageOutputSchemaPaths[deliveryReceiptPath]); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if !stageReceiptWritten {
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: stage.Name,
|
||||
Status: StatusDone,
|
||||
Artifacts: stage.Outputs,
|
||||
}); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
run.Stages[index].Status = StatusDone
|
||||
if index < len(run.Stages)-1 {
|
||||
nextStage := &run.Stages[index+1]
|
||||
run.CurrentStage = nextStage.Name
|
||||
if nextStage.Status == "" {
|
||||
nextStage.Status = StatusPending
|
||||
}
|
||||
} else {
|
||||
run.CurrentStage = stage.Name
|
||||
}
|
||||
run.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
if err := writeRunFile(safeRoot, run); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return InspectStatus(root)
|
||||
}
|
||||
|
||||
func outputsWithoutDeliveryReceipt(outputs []string) []string {
|
||||
filtered := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
if output == deliveryReceiptPath {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, output)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
type stageStatusReceipt struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func validateFinalStageReceiptsPassed(safeRoot string) error {
|
||||
for _, path := range []string{"receipts/lint.json", "receipts/preview.json", renderedVisualReceiptPath, imageUsageReportPath, chartRenderReceiptPath, chartUsageReceiptPath, "quality_report.json", "anygen_semantic_report.json", creativeQualityReportPath, chartQualityReportPath} {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read receipt: %w", path, err)
|
||||
}
|
||||
var receipt stageStatusReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", path, err)
|
||||
}
|
||||
if receipt.Status != "passed" {
|
||||
return fmt.Errorf("%s: status is %q, want passed", path, receipt.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentStageWithIndex(run Run) (int, Stage, error) {
|
||||
for i, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return i, stage, nil
|
||||
}
|
||||
}
|
||||
return -1, Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func writeRunFile(safeRoot string, run Run) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, "run.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, run)
|
||||
}
|
||||
|
||||
func writeStageReceipt(safeRoot string, receipt StageReceipt) error {
|
||||
if strings.TrimSpace(receipt.Stage) == "" {
|
||||
return fmt.Errorf("stage receipt stage must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(receipt.Stage, `/\`) || receipt.Stage == "." || receipt.Stage == ".." {
|
||||
return fmt.Errorf("stage receipt stage %q must be a file name", receipt.Stage)
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, filepath.Join("receipts", receipt.Stage+".json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, receipt)
|
||||
}
|
||||
927
internal/svglide/stage_test.go
Normal file
927
internal/svglide/stage_test.go
Normal file
@@ -0,0 +1,927 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompleteCurrentStageAdvancesToNextStage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got != StatusDone {
|
||||
t.Fatalf("request stage status = %q, want %q", got, StatusDone)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequestResolution); got != StatusPending {
|
||||
t.Fatalf("request_resolution stage status = %q, want %q", got, StatusPending)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt StageReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt.Stage != StageRequest || receipt.Status != StatusDone {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionAdvancesToResearch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageResearch {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if got := stageStatus(t, run, StageRequestResolution); got != StatusDone {
|
||||
t.Fatalf("request_resolution stage status = %q, want %q", got, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionRejectsLowConfidenceRealEntity(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 6900, "medium", "resolved", ""))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected low confidence real entity to block before research")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "confidence_bp") {
|
||||
t.Fatalf("error = %v, want confidence_bp", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionRejectsAmbiguityEvenWithQuestion(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "needs_clarification", "你指的是哪一部电影?"))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguity to block before research")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "needs_clarification") {
|
||||
t.Fatalf("error = %v, want needs_clarification", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequestResolution {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequestResolution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRequestResolutionAcceptsTopicOnlyWithResearchRequired(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageRequestResolution)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("topic", 5000, "medium", "resolved", ""))
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageRequestResolution)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageResearch {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsMissingOutput(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing output error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageDoesNotAdvanceRunWhenReceiptWriteFails(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Mkdir(filepath.Join("demo", "receipts", "request.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected receipt write error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedValidatePreviewRepairReceipts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"failed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"failed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":false}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWritePassedImageUsageForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":1,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWritePassedChartQualityForTest(t)
|
||||
mustWriteDeliveryReceiptForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed lint/preview receipts to block completion")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedQualityReport(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWritePassedImageUsageForTest(t)
|
||||
mustWritePassedChartRenderForTest(t)
|
||||
mustWritePassedChartUsageForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"failed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWritePassedChartQualityForTest(t)
|
||||
mustWriteDeliveryReceiptForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed quality report to block completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") && !strings.Contains(err.Error(), "status is \"failed\"") {
|
||||
t.Fatalf("error = %v, want quality report failure", err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteFinalStageRecomputesSemanticReport(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Image Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Image hook","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening","source_refs":[],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"file:///tmp/secret.png","usage":"Hero image","status":"ready"}]}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><image slide:role="image" href="file:///tmp/secret.png" x="40" y="40" width="320" height="180"/><text x="48" y="260">Claim</text></svg>`)
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWritePassedImageUsageForTest(t)
|
||||
mustWritePassedChartRenderForTest(t)
|
||||
mustWritePassedChartUsageForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":1,"slides_with_source_refs":1,"slides_with_visuals":1,"slides_with_image_assets":1,"image_coverage_bp":10000,"unique_image_assets":1,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWritePassedChartQualityForTest(t)
|
||||
mustWriteDeliveryReceiptForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected final complete to recompute semantic report and reject forged passed report")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "semantic_gate_failed") {
|
||||
t.Fatalf("error = %v, want semantic_gate_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteFinalStageRegeneratesDeliveryReceipt(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteTestFile(t, "demo/receipts/delivery.json", `{"status":"ready","route_profile":"stale","orchestrator":"stale","runtime_binding":"stale","deck":"outline/deck.json","slides_dir":"slides","slides":["slides/01.svg"],"preview":{"path":"stale.html","status":"passed","missing_asset_count":0},"quality_report":"quality_report.json","anygen_semantic_report":"anygen_semantic_report.json","visual_receipts":"visual_receipts.json","creative_quality_report":"creative_quality_report.json","semantic_metrics":{"slide_count":1,"slides_with_slide_role":1,"image_count":0,"text_count":1,"note_count":0,"source_ref_count":0,"missing_asset_count":0,"slides_without_source_refs":0,"visible_leak_count":0,"font_token_count":4,"missing_font_token_count":0},"stage_status":{},"legacy_runtime_executed":false,"legacy_tool_ids":[],"legacy_artifact_matches":[],"core_prompt_ids":[],"observed_prompt_ids":[],"blocked_prompt_ids":[]}`)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("final completion should regenerate delivery receipt: %v", err)
|
||||
}
|
||||
if status.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("CurrentStage = %q, want final stage", status.CurrentStage)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivery DeliveryReceipt
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.RouteProfile != RouteProfileLocalSVGDeck || delivery.RuntimeBinding != "svglide_local_runtime_binding" || delivery.Preview.Path != "preview.html" {
|
||||
t.Fatalf("delivery was not regenerated from current run: %+v", delivery)
|
||||
}
|
||||
if delivery.StageStatus[StageValidatePreviewRepair] != StatusDone {
|
||||
t.Fatalf("delivery stage_status = %+v, want final stage done", delivery.StageStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsMissingFullChainEvidence(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("final completion should write needs_repair delivery for incomplete chain: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivery DeliveryReceipt
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Status != StatusNeedsRepair {
|
||||
t.Fatalf("delivery status = %q, want %q for missing full-chain receipts: %+v", delivery.Status, StatusNeedsRepair, delivery.FullChainEvidence)
|
||||
}
|
||||
if delivery.FullChainEvidence.RunJSON != "run.json" || delivery.FullChainEvidence.QualityReport != "quality_report.json" || delivery.FullChainEvidence.RenderedVisual != renderedVisualReceiptPath {
|
||||
t.Fatalf("delivery full_chain_evidence missing core artifact paths: %+v", delivery.FullChainEvidence)
|
||||
}
|
||||
if delivery.FullChainEvidence.StageReceipts[StageResearch] != "" {
|
||||
t.Fatalf("research receipt evidence = %q, want empty missing receipt marker", delivery.FullChainEvidence.StageReceipts[StageResearch])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryMarksManualPatchEvidence(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteFullChainStageReceiptsForTest(t)
|
||||
mustWriteFullChainEvidenceArtifactsForTest(t)
|
||||
mustWriteTestFile(t, "demo/receipts/manual_patch.json", `{"applied":true,"files":["slides/01.svg","assets/assets_manifest.json"],"reason":"manual final polish"}`)
|
||||
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("final completion should accept explicitly marked manual patch evidence: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivery DeliveryReceipt
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Status != StatusReady {
|
||||
t.Fatalf("delivery status = %q, want %q with complete chain and marked manual patch", delivery.Status, StatusReady)
|
||||
}
|
||||
manual := delivery.FullChainEvidence.ManualPatch
|
||||
if !manual.Applied || len(manual.Files) != 2 || manual.Files[0] != "assets/assets_manifest.json" || manual.Files[1] != "slides/01.svg" || manual.Reason != "manual final polish" {
|
||||
t.Fatalf("manual_patch evidence = %+v, want applied files and reason", manual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsMissingScreenshotEvidence(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteFullChainStageReceiptsForTest(t)
|
||||
mustWriteFullChainEvidenceArtifactsForTest(t)
|
||||
if err := os.Remove(filepath.Join("demo", "contact-sheet.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("final completion should write needs_repair delivery for missing screenshot evidence: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivery DeliveryReceipt
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Status != StatusNeedsRepair {
|
||||
t.Fatalf("delivery status = %q, want %q without screenshot evidence: %+v", delivery.Status, StatusNeedsRepair, delivery.FullChainEvidence)
|
||||
}
|
||||
if len(delivery.FullChainEvidence.ScreenshotEvidence) != 0 {
|
||||
t.Fatalf("screenshot evidence = %+v, want empty", delivery.FullChainEvidence.ScreenshotEvidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryRejectsInvalidStageReceiptEvidence(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteFullChainStageReceiptsForTest(t)
|
||||
mustWriteFullChainEvidenceArtifactsForTest(t)
|
||||
mustWriteTestFile(t, "demo/receipts/research.json", `{"stage":"wrong_stage","status":"done"}`)
|
||||
|
||||
if _, err := CompleteCurrentStage("demo"); err != nil {
|
||||
t.Fatalf("final completion should write needs_repair delivery for invalid stage receipt evidence: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "delivery.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivery DeliveryReceipt
|
||||
if err := json.Unmarshal(raw, &delivery); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Status != StatusNeedsRepair {
|
||||
t.Fatalf("delivery status = %q, want %q for invalid stage receipt evidence: %+v", delivery.Status, StatusNeedsRepair, delivery.FullChainEvidence)
|
||||
}
|
||||
if delivery.FullChainEvidence.StageReceipts[StageResearch] != "receipts/research.json" {
|
||||
t.Fatalf("research receipt path = %q, want recorded but invalid evidence path", delivery.FullChainEvidence.StageReceipts[StageResearch])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteFinalStageRejectsLegacyRunArtifactEvidence(t *testing.T) {
|
||||
writePassingFinalStageArtifactsForTest(t)
|
||||
mustWriteTestFile(t, "demo/legacy/project.slides", "legacy project marker")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected legacy run artifact evidence to block final completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "legacy runtime") || !strings.Contains(err.Error(), "project.slides") {
|
||||
t.Fatalf("error = %v, want legacy runtime evidence for project.slides", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsMissingPromptContext(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing_prompt_context to reject completing design_brief before next")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_prompt_context") && !strings.Contains(err.Error(), "prompt context") {
|
||||
t.Fatalf("error = %v, want missing_prompt_context", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageDesignBrief {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageDesignBrief)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsStalePromptHash(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
writePromptContextReceiptForTest(t, StageDesignBrief, map[string]string{
|
||||
"mode_system_prompt_svg": "sha256:stale",
|
||||
"svg_reference": "sha256:stale",
|
||||
"resolve_design_brief": "sha256:stale",
|
||||
})
|
||||
writeToolCallReceiptForTest(t, StageDesignBrief, "resolve_design_brief")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected stale_prompt_context to reject changed prompt hashes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stale_prompt_context") && !strings.Contains(err.Error(), "prompt hash") {
|
||||
t.Fatalf("error = %v, want stale_prompt_context", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageDesignBrief {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageDesignBrief)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsMissingRequiredToolCallReceipt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
writePromptContextReceiptForTest(t, StageDesignBrief, map[string]string{
|
||||
"mode_system_prompt_svg": "",
|
||||
"svg_reference": "",
|
||||
"resolve_design_brief": "",
|
||||
})
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing_tool_call to reject design_brief without resolve_design_brief receipt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_tool_call") && !strings.Contains(err.Error(), "resolve_design_brief") {
|
||||
t.Fatalf("error = %v, want missing_tool_call for resolve_design_brief", err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageDesignBrief {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageDesignBrief)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsWrongToolCallContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
writePromptContextReceiptForTest(t, StageDesignBrief, map[string]string{})
|
||||
writeToolCallReceiptForTest(t, StageDesignBrief, "resolve_design_brief")
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "tool_calls", StageDesignBrief, "resolve_design_brief.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt["condition"] = "wrong_condition"
|
||||
receipt["cardinality"] = "zero_or_more"
|
||||
receipt["consumed"] = []string{"request/request.json"}
|
||||
updated, err := json.MarshalIndent(receipt, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "tool_calls", StageDesignBrief, "resolve_design_brief.json"), string(append(updated, '\n')))
|
||||
|
||||
_, err = CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected wrong tool call receipt contract to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "receipt contract mismatch") && !strings.Contains(err.Error(), "consumed artifacts") {
|
||||
t.Fatalf("error = %v, want tool receipt contract rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsForgedEmptyPromptContext(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
writeValidDesignBriefOutputs(t)
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"stage": StageDesignBrief,
|
||||
"protocol": "anygen-svg-slides",
|
||||
"agent_task": map[string]any{"stage": StageDesignBrief},
|
||||
"prompt_contract": map[string]any{"stage": StageDesignBrief},
|
||||
"tool_invocation_contract": map[string]any{"required_calls": []any{}},
|
||||
"asset_hashes": map[string]string{},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", StageDesignBrief+".json"), string(append(raw, '\n')))
|
||||
|
||||
_, err = CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected forged empty prompt context to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_prompt_context_asset") {
|
||||
t.Fatalf("error = %v, want missing_prompt_context_asset", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRecomputesConditionalCustomShapeBBox(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"Custom path","summary":"Custom summary","role":"cover","key_message":"Custom key","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><path slide:role="shape" slide:shape-type="custom" d="M 10 10 L 120 10 L 120 80 Z"/><text x="48" y="160">Custom</text></svg>`)
|
||||
writePromptContextReceiptForTest(t, StageSVGAuthor, map[string]string{})
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "activate_slides_edit")
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "slides_edit")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected custom path SVG to require compute_custom_shape_bbox receipt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing_tool_call") || !strings.Contains(err.Error(), "compute_custom_shape_bbox") {
|
||||
t.Fatalf("error = %v, want missing compute_custom_shape_bbox tool call", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteDoesNotRequireCustomShapeBBoxForPlainSVG(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"Plain","summary":"Plain summary","role":"cover","key_message":"Plain key","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
writePromptContextReceiptForTest(t, StageSVGAuthor, map[string]string{})
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "activate_slides_edit")
|
||||
writeToolCallReceiptForTest(t, StageSVGAuthor, "slides_edit")
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("plain SVG should not require compute_custom_shape_bbox: %v", err)
|
||||
}
|
||||
if status.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSVGDeckDoesNotTriggerLegacyPPTXTools(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Input = "source.pptx"
|
||||
run.Intent.Input = "source.pptx"
|
||||
run.RouteProfile = RouteProfileLocalSVGDeck
|
||||
|
||||
calls, err := TriggeredConditionalToolCalls(StageResearch, run, "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, call := range calls {
|
||||
if call.ID == "slides_convert" || call.ID == "slides_parse_template" {
|
||||
t.Fatalf("local SVG deck triggered legacy call %+v", call)
|
||||
}
|
||||
}
|
||||
|
||||
assets, err := PromptAssetsForProfileStage(RouteProfileLocalSVGDeck, StageResearch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if asset.ID == "slides_convert" || asset.ID == "slides_parse_template" {
|
||||
t.Fatalf("local SVG deck exposed legacy prompt %+v", asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePromptContextRejectsProfileDisallowedPrompt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.RouteProfile = RouteProfileLocalSVGDeck
|
||||
writeStatusTestRunFile(t, run)
|
||||
writePromptContextReceiptForTest(t, StageResearch, map[string]string{})
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "prompt_context", StageResearch+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt PromptContextReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyID := "slides_convert"
|
||||
receipt.AgentTask.PromptContext.Assets = append(receipt.AgentTask.PromptContext.Assets, PromptContextAsset{
|
||||
ID: legacyID,
|
||||
Role: "tool_prompt",
|
||||
Path: promptPathByID(legacyID),
|
||||
SHA256: promptAssetSHA(promptPathByID(legacyID)),
|
||||
Required: false,
|
||||
})
|
||||
updated, err := json.MarshalIndent(receipt, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", StageResearch+".json"), string(append(updated, '\n')))
|
||||
|
||||
safeRoot, run, err := readRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ValidatePromptContextForStage(safeRoot, StageResearch, run)
|
||||
if err == nil {
|
||||
t.Fatal("expected local prompt context to reject profile-disallowed legacy prompt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), legacyID) || !strings.Contains(err.Error(), "not allowed") {
|
||||
t.Fatalf("error = %v, want disallowed legacy prompt %q", err, legacyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedPPTXProfileTriggersSlidesConvert(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Input = "source.pptx"
|
||||
run.Intent.Input = "source.pptx"
|
||||
run.RouteProfile = routeProfileImportedPPTX
|
||||
|
||||
calls, err := TriggeredConditionalToolCalls(StageResearch, run, "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !toolCallsContain(calls, "slides_convert") {
|
||||
t.Fatalf("imported_pptx calls = %+v, want slides_convert", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func stageStatus(t *testing.T, run Run, name string) string {
|
||||
t.Helper()
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == name {
|
||||
return stage.Status
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeValidDesignBriefOutputs(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"narrative_spine":{},"depth":{},"tone":"clear","visual_system":{"color_system":{},"typography":{},"layout_language":{}},"deck_visual_system":{"visual_keywords":["editorial"],"palette":{},"fonts":{"font_display":"Noto Serif CJK SC","font_body":"Noto Sans CJK SC","font_number":"Roboto Mono","font_label":"PingFang SC"},"page_family_budget":{},"asset_strategy":{}}}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"color_system":{},"typography":{},"layout_language":{}}`)
|
||||
mustWriteTestFile(t, "demo/brief/typography_contract.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"profile":"editorial_report","roles":{"display":{"family":"Noto Serif CJK SC","weight":"700","size":"42","usage":"cover and section titles"},"body":{"family":"Noto Sans CJK SC","weight":"400","size":"18","usage":"body copy"},"number":{"family":"Roboto Mono","weight":"700","size":"34","usage":"financial figures"},"label":{"family":"PingFang SC","weight":"600","size":"13","usage":"labels and captions"}},"rules":["Use concrete font roles; do not fall back to generic browser stacks."]}`)
|
||||
}
|
||||
|
||||
func validEntityResolutionJSON(entityType string, confidenceBP int, confidenceBand string, ambiguityStatus string, clarificationQuestion string) string {
|
||||
return `{"prompt_contract":` + promptContractJSON(StageRequestResolution) + `,"input_text":"给阿嬷的情书","resolved_entity":{"name":"给阿嬷的情书","type":"` + entityType + `","confidence_bp":` + strconv.Itoa(confidenceBP) + `,"confidence_band":"` + confidenceBand + `","reason":"从用户请求识别出的生成对象"},"ambiguity":{"status":"` + ambiguityStatus + `","candidates":[]},"research_required":true,"clarification_question":"` + clarificationQuestion + `"}`
|
||||
}
|
||||
|
||||
func promptContractJSON(stage string) string {
|
||||
return `{"protocol":"anygen-svg-slides","stage":"` + stage + `","context_receipt":"receipts/prompt_context/` + stage + `.json","orchestrator":"mode_system_prompt_svg","protocol_reference":"svg_reference","required_prompt_ids":["mode_system_prompt_svg","svg_reference"]}`
|
||||
}
|
||||
|
||||
func writePromptContextReceiptForTest(t *testing.T, stage string, hashes map[string]string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
contract, err := RequiredPromptContractForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context, err := promptContextForPromptContract(contract)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assetHashes := map[string]string{}
|
||||
for _, asset := range context.Assets {
|
||||
if asset.Required {
|
||||
assetHashes[asset.ID] = asset.SHA256
|
||||
}
|
||||
}
|
||||
for id, hash := range hashes {
|
||||
if hash == "" {
|
||||
if asset, ok := promptContextAssetForTest(context, id); ok {
|
||||
hash = asset.SHA256
|
||||
} else {
|
||||
hash = promptAssetSHA(promptPathByID(id))
|
||||
}
|
||||
}
|
||||
assetHashes[id] = hash
|
||||
}
|
||||
requiredCalls, err := RequiredToolCallsForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"stage": stage,
|
||||
"protocol": "anygen-svg-slides",
|
||||
"agent_task": map[string]any{"stage": stage, "prompt_context": context},
|
||||
"prompt_contract": contract,
|
||||
"asset_hashes": assetHashes,
|
||||
"tool_invocation_contract": map[string]any{
|
||||
"protocol": "anygen-svg-slides",
|
||||
"required_calls": requiredCalls,
|
||||
},
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", stage+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func writeToolCallReceiptForTest(t *testing.T, stage string, callID string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
calls, err := RequiredToolCallsForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var call ToolCallRequirement
|
||||
found := false
|
||||
for _, candidate := range calls {
|
||||
if candidate.ID == callID {
|
||||
call = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing required call %q for stage %q", callID, stage)
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"protocol": "anygen-svg-slides",
|
||||
"stage": stage,
|
||||
"call_id": callID,
|
||||
"prompt_id": call.PromptID,
|
||||
"invocation": call.Invocation,
|
||||
"condition": call.Condition,
|
||||
"condition_matched": true,
|
||||
"order": call.Order,
|
||||
"cardinality": call.Cardinality,
|
||||
"consumed": call.Consumes,
|
||||
"produced": call.Produces,
|
||||
"status": "done",
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "tool_calls", stage, callID+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func writePromptContextReceiptWithoutToolCallsForTest(t *testing.T, stage string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
contract, err := RequiredPromptContractForStage(stage, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context, err := promptContextForPromptContract(contract)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assetHashes := map[string]string{}
|
||||
for _, asset := range context.Assets {
|
||||
if asset.Required {
|
||||
assetHashes[asset.ID] = asset.SHA256
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"stage": stage,
|
||||
"protocol": "anygen-svg-slides",
|
||||
"agent_task": map[string]any{"stage": stage, "prompt_context": context},
|
||||
"prompt_contract": contract,
|
||||
"tool_invocation_contract": map[string]any{"required_calls": []any{}, "conditional_calls": []any{}},
|
||||
"asset_hashes": assetHashes,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", "receipts", "prompt_context", stage+".json"), string(append(raw, '\n')))
|
||||
}
|
||||
|
||||
func promptContextAssetForTest(context PromptContext, id string) (PromptContextAsset, bool) {
|
||||
for _, asset := range context.Assets {
|
||||
if asset.ID == id {
|
||||
return asset, true
|
||||
}
|
||||
}
|
||||
return PromptContextAsset{}, false
|
||||
}
|
||||
|
||||
func toolCallsContain(calls []ToolCallRequirement, id string) bool {
|
||||
for _, call := range calls {
|
||||
if call.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustWritePassedSemanticReportForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/anygen_semantic_report.json", `{"status":"passed","contract":{"id":"anygen_semantic_contract","role":"semantic_contract","path":"skills/lark-slides/references/anygen-svg/semantic_contract.md","sha256":"test","rules":1},"metrics":{"slide_count":1,"slides_with_slide_role":1,"image_count":0,"text_count":1,"note_count":0,"source_ref_count":0,"missing_asset_count":0,"slides_without_source_refs":0,"visible_leak_count":0,"font_token_count":4,"missing_font_token_count":0},"findings":[]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedRenderedVisualForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/receipts/rendered_visual.json", `{"status":"passed","metrics":{"slides":1,"issue_count":0,"out_of_canvas_count":0,"text_overflow_count":0,"text_collision_count":0,"unsafe_edge_count":0,"container_text_overflow_count":0,"container_padding_risk_count":0,"foreign_object_overlap_count":0,"tight_line_height_count":0,"bold_overuse_count":0,"small_text_padding_risk_count":0},"issues":[],"slides":[{"path":"slides/01.svg","status":"passed","issue_count":0}]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedImageUsageForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, filepath.Join("demo", imageUsageReportPath), `{"status":"passed","slides":[{"slide_id":"s1","assets":[]}],"issues":[]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedChartQualityForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/receipts/chart_quality.json", `{"status":"passed","metrics":{"charts":0,"vega_lite_charts":0,"missing_axis_count":0,"missing_unit_count":0,"missing_source_count":0,"missing_direct_label_count":0,"decorative_chart_count":0},"issues":[],"charts":[]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedChartRenderForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/receipts/chart_render.json", `{"status":"passed","renderer":"node-vega-lite","charts":[],"issues":[]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedChartUsageForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/receipts/chart_usage.json", `{"status":"passed","charts":[],"issues":[]}`)
|
||||
}
|
||||
|
||||
func mustWriteNoChartAssetsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(filepath.Join("demo", "content", "slide_content.json")); os.IsNotExist(err) {
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"No chart fixture","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"No chart"}]}]}`)
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_briefs.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"charts":[]}`)
|
||||
mustWriteTestFile(t, "demo/assets/charts/chart_manifest.json", `{"prompt_contract":`+promptContractJSON(StageAssets)+`,"renderer":"none","charts":[]}`)
|
||||
mustWritePassedChartRenderForTest(t)
|
||||
}
|
||||
|
||||
func mustWriteBasicVisualReceiptsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/visual_receipts.json", `{"slides":[{"slide_id":"s1","story_job":"hook","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"single_claim_poster","thumbnail_job":"readable title","visual_center":"title block","topic_fit_claim":"matches demo topic","information_density_plan":"one claim with support","page_difference_from_previous":"opening page","primary_asset":"","asset_role":"none","font_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"composition_intent":"quiet synthesis","data_visual_rationale":"","source_evidence":["web1 supports claim"],"container_fit_plan":"open grid text with no forced card","container_decision":"no card needed for simple claim","text_carrier":"open_grid","typography_role_usage":{"display":"Noto Serif CJK SC","body":"Noto Sans CJK SC","number":"Roboto Mono","label":"PingFang SC"},"shape_language":"minimal","card_budget":{"card_count":0,"why_cards_are_needed":"none"},"chart_receipt":{"chart_id":"","renderer":"none","unit":"","source":"","why_chart_is_needed":""},"fusion_spec":{"enabled":false},"qa_expectations":["no process text"]}]}`)
|
||||
}
|
||||
|
||||
func mustWritePassedCreativeReportForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/creative_quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"visual_receipts":1,"missing_visual_receipts":0,"process_leak_count":0,"generic_font_slide_count":0,"distinct_layout_family_count":1,"distinct_layout_archetype_count":1,"layout_archetype_max_ratio_bp":10000,"adjacent_layout_archetype_count":0,"left_right_chart_archetype_count":0,"layout_signature_max_ratio_bp":10000,"adjacent_layout_repetition_count":0,"fusion_slide_count":0,"fusion_adjacent_count":0,"weak_slide_count":0,"chart_without_evidence_count":0,"warning_count":0}}`)
|
||||
}
|
||||
|
||||
func mustWriteDeliveryReceiptForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/receipts/delivery.json", `{"status":"ready","route_profile":"local_svg_deck","orchestrator":"mode_system_prompt_svg","runtime_binding":"svglide_local_runtime_binding","deck":"outline/deck.json","slides_dir":"slides","slides":["slides/01.svg"],"preview":{"path":"preview.html","status":"passed","missing_asset_count":0},"quality_report":"quality_report.json","anygen_semantic_report":"anygen_semantic_report.json","visual_receipts":"visual_receipts.json","creative_quality_report":"creative_quality_report.json","semantic_metrics":{"slide_count":1,"slides_with_slide_role":1,"image_count":0,"text_count":1,"note_count":0,"source_ref_count":0,"missing_asset_count":0,"slides_without_source_refs":0,"visible_leak_count":0,"font_token_count":4,"missing_font_token_count":0},"stage_status":{"validate_preview_repair":"pending"},"full_chain_evidence":{"run_json":"run.json","request":"request/request.json","source_manifest":"request/source_manifest.json","entity_resolution":"request/entity_resolution.json","research_notes":"research/research_notes.md","sources":"research/sources.json","research_coverage":"research/research_coverage.json","design_brief":"brief/design_brief.json","visual_system":"brief/visual_system.json","typography_contract":"brief/typography_contract.json","outline":"outline/deck.json","slide_content":"content/slide_content.json","asset_manifest":"assets/assets_manifest.json","rendered_visual":"receipts/rendered_visual.json","quality_report":"quality_report.json","creative_quality_report":"creative_quality_report.json","chart_render_report":"receipts/chart_render.json","chart_usage_report":"receipts/chart_usage.json","chart_quality_report":"receipts/chart_quality.json","delivery":"receipts/delivery.json","stage_receipts":{},"screenshot_evidence":["contact-sheet.png"],"manual_patch":{"applied":false,"files":[]}},"legacy_runtime_executed":false,"legacy_tool_ids":[],"legacy_artifact_matches":[],"core_prompt_ids":["mode_system_prompt_svg","svg_reference","svglide_local_runtime_binding"],"observed_prompt_ids":[],"blocked_prompt_ids":["slides_convert","slides_parse_template"]}`)
|
||||
}
|
||||
|
||||
func mustWriteFullChainStageReceiptsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, stage := range DefaultStages() {
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
continue
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join("demo", stage.Receipt), `{"stage":"`+stage.Name+`","status":"done"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFullChainEvidenceArtifactsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("topic", 5000, "medium", "resolved", ""))
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/research_coverage.json", `{"prompt_contract":`+promptContractJSON(StageResearch)+`,"entity":{"name":"给阿嬷的情书","type":"topic"},"queries":[{"query":"给阿嬷的情书","purpose":"context"}],"sources":[{"id":"web1","title":"Web Source","url":"https://example.com/page","retrieved_at":"2026-07-04T00:00:00Z","usage":"context","status":"retrieved"}],"coverage":{"identity_confirmed":false,"has_reliable_source":true,"minimum_source_count_met":true,"source_count":1,"topic_only_rationale":"开放主题测试链路需要研究材料确定内容边界。"}}`)
|
||||
if _, err := os.Stat(filepath.Join("demo", "brief", "design_brief.json")); os.IsNotExist(err) {
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "brief", "visual_system.json")); os.IsNotExist(err) {
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"editorial report"}`)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/brief/typography_contract.json", `{"prompt_contract":`+promptContractJSON(StageDesignBrief)+`,"profile":"editorial_report","roles":{"display":{"family":"Noto Serif CJK SC","weight":"700","size":"42","usage":"cover and section titles"},"body":{"family":"Noto Sans CJK SC","weight":"400","size":"18","usage":"body copy"},"number":{"family":"Roboto Mono","weight":"700","size":"34","usage":"figures"},"label":{"family":"PingFang SC","weight":"600","size":"13","usage":"labels and captions"}},"rules":["Use concrete font roles; do not fall back to generic browser stacks."]}`)
|
||||
mustWritePassedChartRenderForTest(t)
|
||||
mustWritePassedChartUsageForTest(t)
|
||||
mustWritePassedChartQualityForTest(t)
|
||||
mustWriteTestFile(t, "demo/contact-sheet.png", "png")
|
||||
}
|
||||
|
||||
func writePassingFinalStageArtifactsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Final Deck","slides":[{"id":"s1","title":"Opening","summary":"Opening summary","role":"cover","key_message":"Opening key","layout_family":"quiet_synthesis","layout_archetype":"poster_stat_lockup","layout_signature":"single_claim_poster","story_function":"hook","primary_asset_role":"none","fusion_candidate":false,"path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Opening point","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[],"no_image_reason":"Text-only deck; no image assets required"}`)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","missing_asset_count":0,"slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWritePassedRenderedVisualForTest(t)
|
||||
mustWritePassedImageUsageForTest(t)
|
||||
mustWritePassedChartUsageForTest(t)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":1,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0,"slides_with_image_assets":0,"image_coverage_bp":0,"unique_image_assets":0,"official_image_assets":0}}`)
|
||||
mustWritePassedSemanticReportForTest(t)
|
||||
mustWriteBasicVisualReceiptsForTest(t)
|
||||
mustWritePassedCreativeReportForTest(t)
|
||||
mustWritePassedChartRenderForTest(t)
|
||||
mustWritePassedChartQualityForTest(t)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
writePromptContextReceiptWithoutToolCallsForTest(t, StageValidatePreviewRepair)
|
||||
writeToolCallReceiptForTest(t, StageValidatePreviewRepair, "finish_slides_edit")
|
||||
}
|
||||
402
internal/svglide/status.go
Normal file
402
internal/svglide/status.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type StatusReport struct {
|
||||
CurrentStage string `json:"current_stage"`
|
||||
MissingInputs []string `json:"missing_inputs"`
|
||||
MissingOutputs []string `json:"missing_outputs"`
|
||||
NextCommand string `json:"next_command"`
|
||||
}
|
||||
|
||||
type NextTaskReport struct {
|
||||
Stage string `json:"stage"`
|
||||
Mode string `json:"mode"`
|
||||
Protocol string `json:"protocol"`
|
||||
ApprovalRequired bool `json:"approval_required"`
|
||||
BlockingOwner string `json:"blocking_owner"`
|
||||
BlockingReason string `json:"blocking_reason,omitempty"`
|
||||
PromptPath string `json:"prompt_path,omitempty"`
|
||||
PromptPaths []string `json:"prompt_paths,omitempty"`
|
||||
AdapterPaths []string `json:"adapter_paths"`
|
||||
PromptManifest string `json:"prompt_manifest"`
|
||||
PromptContext string `json:"prompt_context"`
|
||||
PromptContract StagePromptContract `json:"prompt_contract"`
|
||||
ToolInvocationContract ToolInvocationContract `json:"tool_invocation_contract"`
|
||||
AgentTask AgentTask `json:"agent_task"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
}
|
||||
|
||||
const (
|
||||
createSVGlideAdapterPath = "skills/lark-slides/references/lark-slides-create-svglide.md"
|
||||
svglideExecutionMode = "execution"
|
||||
svglideBlockingOwner = "svglide-runtime"
|
||||
)
|
||||
|
||||
func ReadRun(root string) (Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return readRunFile(safeRoot)
|
||||
}
|
||||
|
||||
func InspectStatus(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
nextAction := "next"
|
||||
if len(missingOutputs) == 0 {
|
||||
nextAction = "complete"
|
||||
}
|
||||
return StatusReport{
|
||||
CurrentStage: stage.Name,
|
||||
MissingInputs: missingInputs,
|
||||
MissingOutputs: missingOutputs,
|
||||
NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action %s --run %s", nextAction, shellQuote(root)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NextTask(root string) (NextTaskReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
if len(missingInputs) > 0 {
|
||||
return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", "))
|
||||
}
|
||||
inputs, err := validateRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
outputs, err := validateRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
agentTask, promptContract, toolContract, err := BuildAgentTask(stage, run, safeRoot, inputs, outputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
if err := WritePromptContextReceipt(safeRoot, stage.Name, agentTask, promptContract, toolContract); err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
return NextTaskReport{
|
||||
Stage: stage.Name,
|
||||
Mode: svglideExecutionMode,
|
||||
Protocol: ProtocolAnyGenSVGSlides,
|
||||
ApprovalRequired: false,
|
||||
BlockingOwner: svglideBlockingOwner,
|
||||
AdapterPaths: []string{createSVGlideAdapterPath},
|
||||
PromptManifest: "prompt_manifest.json",
|
||||
PromptContext: promptContextReceiptPath(stage.Name),
|
||||
PromptContract: promptContract,
|
||||
ToolInvocationContract: toolContract,
|
||||
AgentTask: agentTask,
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readRun(root string) (string, Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
return safeRoot, run, nil
|
||||
}
|
||||
|
||||
func readRunFile(safeRoot string) (Run, error) {
|
||||
raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json"))
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
return Run{}, fmt.Errorf("read run.json: %w", err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func currentStage(run Run) (Stage, error) {
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return stage, nil
|
||||
}
|
||||
}
|
||||
return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func missingRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
var missing []string
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
exists, err := runGlobExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
continue
|
||||
}
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lstat run path %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
func validateRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
paths := make([]string, 0, len(rels))
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err := safeRunPath(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func runGlobExists(safeRoot, rel string) (bool, error) {
|
||||
dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
dirPath, exists, err := runDirectoryExists(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
entries, err := vfs.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read glob directory for %q: %w", rel, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := filepath.Match(pattern, entry.Name())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
matchRel := filepath.Join(dirRel, entry.Name())
|
||||
exists, err := runRegularFileExists(safeRoot, matchRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err)
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", "", "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
dirRel, pattern := filepath.Split(cleanRel)
|
||||
dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator))
|
||||
if pattern == "" {
|
||||
return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel)
|
||||
}
|
||||
if _, err := filepath.Match(pattern, ""); err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if dirRel == "" {
|
||||
dirRel = "."
|
||||
}
|
||||
if hasGlobMeta(dirRel) {
|
||||
return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel)
|
||||
}
|
||||
dirPath, err := safeRunPath(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return dirRel, pattern, dirPath, nil
|
||||
}
|
||||
|
||||
func runDirectoryExists(safeRoot, rel string) (string, bool, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return path, false, err
|
||||
}
|
||||
if !exists {
|
||||
return path, false, nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return path, false, fmt.Errorf("run path %q is not a directory", rel)
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func runRegularFileExists(safeRoot, rel string) (bool, error) {
|
||||
info, _, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
return info.Mode().IsRegular(), nil
|
||||
}
|
||||
|
||||
func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
var info fs.FileInfo
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
if info == nil {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
|
||||
func hasGlobMeta(path string) bool {
|
||||
return strings.ContainsAny(path, "*?[")
|
||||
}
|
||||
|
||||
func safeRunPath(safeRoot, rel string) (string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
path := filepath.Clean(filepath.Join(safeRoot, cleanRel))
|
||||
rootRel, err := filepath.Rel(safeRoot, path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err)
|
||||
}
|
||||
if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) {
|
||||
return "", fmt.Errorf("run path %q escapes run root", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isAbsoluteRunPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||
drive := path[0]
|
||||
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
if value == "" {
|
||||
return "''"
|
||||
}
|
||||
if isShellBareword(value) {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func isShellBareword(value string) bool {
|
||||
for _, r := range value {
|
||||
if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune("_@%+=:,./-", r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
720
internal/svglide/status_test.go
Normal file
720
internal/svglide/status_test.go
Normal file
@@ -0,0 +1,720 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatusReportsMissingOutputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs)
|
||||
}
|
||||
if len(status.MissingInputs) != 0 {
|
||||
t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs)
|
||||
}
|
||||
if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" {
|
||||
t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusQuotesNextCommandRunPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
root string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
root: "demo dir",
|
||||
want: "lark-cli slides +create-svglide --action complete --run 'demo dir'",
|
||||
},
|
||||
{
|
||||
root: "demo' dir",
|
||||
want: "lark-cli slides +create-svglide --action complete --run 'demo'\\'' dir'",
|
||||
},
|
||||
{
|
||||
root: "demo trail ",
|
||||
want: "lark-cli slides +create-svglide --action complete --run 'demo trail '",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.root, func(t *testing.T) {
|
||||
cwd := initStatusTestRunAt(t, tt.root)
|
||||
|
||||
status, err := InspectStatus(tt.root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.NextCommand != tt.want {
|
||||
t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want)
|
||||
}
|
||||
if strings.Contains(status.NextCommand, cwd) {
|
||||
t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskReturnsAnyGenPromptContextAssets(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if next.Stage != StageRequest {
|
||||
t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest)
|
||||
}
|
||||
if next.PromptManifest != "prompt_manifest.json" {
|
||||
t.Fatalf("PromptManifest = %q, want prompt_manifest.json", next.PromptManifest)
|
||||
}
|
||||
if next.PromptPath != "" {
|
||||
t.Fatalf("PromptPath = %q, want empty deprecated field", next.PromptPath)
|
||||
}
|
||||
if len(next.PromptPaths) != 0 {
|
||||
t.Fatalf("PromptPaths = %v, want omitted legacy top-level field", next.PromptPaths)
|
||||
}
|
||||
got := promptContextAssetPaths(next.AgentTask.PromptContext.Assets)
|
||||
for _, want := range []string{
|
||||
"skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md",
|
||||
"skills/lark-slides/references/anygen-svg/svg_reference.md",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("prompt_context assets missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "docs/vendor/anygen-svg/source.full.md") {
|
||||
t.Fatalf("prompt_context assets should not include source snapshot:\n%s", got)
|
||||
}
|
||||
if len(next.Inputs) != 0 {
|
||||
t.Fatalf("Inputs = %v, want empty", next.Inputs)
|
||||
}
|
||||
if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) {
|
||||
t.Fatalf("Outputs = %v, want request outputs", next.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskSeparatesAnyGenPromptsFromRuntimeAdapter(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
if len(next.PromptPaths) != 0 {
|
||||
t.Fatalf("PromptPaths = %v, want omitted legacy top-level field", next.PromptPaths)
|
||||
}
|
||||
gotPrompts := promptContextAssetPaths(next.AgentTask.PromptContext.Assets)
|
||||
if strings.Contains(gotPrompts, "lark-slides-create-svglide.md") {
|
||||
t.Fatalf("prompt_context assets should contain AnyGen assets only, got:\n%s", gotPrompts)
|
||||
}
|
||||
if !strings.Contains(gotPrompts, "skills/lark-slides/references/anygen-svg/README.md") {
|
||||
t.Fatalf("prompt_context assets missing AnyGen README:\n%s", gotPrompts)
|
||||
}
|
||||
if len(next.AdapterPaths) != 1 || next.AdapterPaths[0] != "skills/lark-slides/references/lark-slides-create-svglide.md" {
|
||||
t.Fatalf("AdapterPaths = %#v, want create-svglide adapter", next.AdapterPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptContextIncludesLocalRuntimeVisualFloor(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[]}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"visual_system":{}}`)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
got := promptContextAssetPaths(next.AgentTask.PromptContext.Assets)
|
||||
if !strings.Contains(got, "skills/lark-slides/references/anygen-svg/svglide_local_runtime_binding.md") {
|
||||
t.Fatalf("assets stage prompt context missing runtime binding:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "skills/lark-slides/references/anygen-svg/svglide_visual_quality_overlay.md") {
|
||||
t.Fatalf("assets stage prompt context missing visual quality overlay:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func promptContextAssetPaths(assets []PromptContextAsset) string {
|
||||
paths := make([]string, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
paths = append(paths, asset.Path)
|
||||
}
|
||||
return strings.Join(paths, "\n")
|
||||
}
|
||||
|
||||
func TestNextTaskDeclaresExecutionModeWithoutApprovalGate(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
if next.Mode != "execution" {
|
||||
t.Fatalf("Mode = %q, want execution", next.Mode)
|
||||
}
|
||||
if next.ApprovalRequired {
|
||||
t.Fatalf("ApprovalRequired = true, want false")
|
||||
}
|
||||
if next.BlockingOwner != "svglide-runtime" {
|
||||
t.Fatalf("BlockingOwner = %q, want svglide-runtime", next.BlockingOwner)
|
||||
}
|
||||
if next.BlockingReason != "" {
|
||||
t.Fatalf("BlockingReason = %q, want empty", next.BlockingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskReturnsAgentRuntimeProtocolContract(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["protocol"] != "anygen-svg-slides" {
|
||||
t.Fatalf("protocol = %v, want anygen-svg-slides in next payload: %+v", payload["protocol"], payload)
|
||||
}
|
||||
agentTask, ok := payload["agent_task"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("next.agent_task missing or invalid: %+v", payload)
|
||||
}
|
||||
if agentTask["stage"] != StageDesignBrief {
|
||||
t.Fatalf("agent_task.stage = %v, want %q", agentTask["stage"], StageDesignBrief)
|
||||
}
|
||||
if agentTask["prompt_context"] == nil {
|
||||
t.Fatalf("agent_task.prompt_context missing: %+v", agentTask)
|
||||
}
|
||||
if payload["prompt_context"] == nil {
|
||||
t.Fatalf("next.prompt_context receipt path missing: %+v", payload)
|
||||
}
|
||||
if _, ok := payload["prompt_contract"].(map[string]any); !ok {
|
||||
t.Fatalf("next.prompt_contract missing or invalid: %+v", payload)
|
||||
}
|
||||
toolContract, ok := payload["tool_invocation_contract"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("next.tool_invocation_contract missing or invalid: %+v", payload)
|
||||
}
|
||||
if !jsonArrayContainsObjectField(toolContract["required_calls"], "id", "resolve_design_brief") {
|
||||
t.Fatalf("required_calls missing resolve_design_brief: %+v", toolContract["required_calls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskWritesPromptContextReceipt(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/research_notes.md", "# research\n")
|
||||
setCurrentStageForStatusTest(t, StageDesignBrief)
|
||||
|
||||
if _, err := NextTask("demo"); err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "prompt_context", StageDesignBrief+".json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing prompt context receipt for %s: %v", StageDesignBrief, err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid prompt context receipt: %v", err)
|
||||
}
|
||||
if receipt["stage"] != StageDesignBrief || receipt["protocol"] != "anygen-svg-slides" {
|
||||
t.Fatalf("prompt context receipt = %+v, want design_brief anygen protocol", receipt)
|
||||
}
|
||||
if _, ok := receipt["asset_hashes"].(map[string]any); !ok {
|
||||
t.Fatalf("prompt context receipt missing asset_hashes: %+v", receipt)
|
||||
}
|
||||
if receipt["agent_task"] == nil || receipt["tool_invocation_contract"] == nil {
|
||||
t.Fatalf("prompt context receipt missing agent_task/tool_invocation_contract: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskResearchIncludesPPTXConditionalCall(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Input = "source.pptx"
|
||||
run.Intent.Input = "source.pptx"
|
||||
run.RouteProfile = routeProfileImportedPPTX
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
if !toolCallsContainID(next.ToolInvocationContract.ConditionalCalls, "slides_convert") {
|
||||
t.Fatalf("conditional_calls = %+v, want slides_convert for pptx input", next.ToolInvocationContract.ConditionalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskResearchIncludesTemplateConditionalCall(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/request/entity_resolution.json", validEntityResolutionJSON("film", 8500, "high", "resolved", ""))
|
||||
run := readStatusTestRunFile(t)
|
||||
run.RouteProfile = routeProfileTemplateReference
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
mustWriteTestFile(t, filepath.Join("demo", "request", "request.json"), `{"title":"Demo","input":"source.md","template":true}`)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
if !toolCallsContainID(next.ToolInvocationContract.ConditionalCalls, "slides_parse_template") {
|
||||
t.Fatalf("conditional_calls = %+v, want slides_parse_template for template request", next.ToolInvocationContract.ConditionalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func toolCallsContainID(calls []ToolCallRequirement, id string) bool {
|
||||
for _, call := range calls {
|
||||
if call.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNextTaskCreatesPromptContextReceiptDirectory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts", "prompt_context")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := NextTask("demo"); err != nil {
|
||||
t.Fatalf("NextTask should create receipts/prompt_context as needed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "prompt_context", StageRequest+".json")); err != nil {
|
||||
t.Fatalf("missing request prompt context receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if _, err := InspectStatus("../escape"); err == nil {
|
||||
t.Fatal("expected unsafe run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func jsonArrayContainsObjectField(value any, field string, want string) bool {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if ok && object[field] == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
|
||||
run, err := ReadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
|
||||
if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil {
|
||||
t.Fatal("expected absolute run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected stat error when output parent is a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
path := filepath.Join("demo", "request", "source_manifest.json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageDesignBrief
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing current stage inputs to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing research source manifest to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageOutline
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing outline visual system to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/request.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides")
|
||||
if err := os.MkdirAll(outsideSlides, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside.svg")
|
||||
if err := os.WriteFile(outside, []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func initStatusTestRun(t *testing.T) string {
|
||||
return initStatusTestRunAt(t, "demo")
|
||||
}
|
||||
|
||||
func initStatusTestRunAt(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
writeDefaultSemanticContractForTest(t)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initRoot := root
|
||||
if trimmed := strings.TrimSpace(root); trimmed != root {
|
||||
initRoot = trimmed
|
||||
}
|
||||
if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if initRoot != root {
|
||||
if err := os.Rename(initRoot, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "receipts", "prompt_context"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func readStatusTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeStatusTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) {
|
||||
t.Helper()
|
||||
for i := range run.Stages {
|
||||
if run.Stages[i].Name == stageName {
|
||||
run.Stages[i].Outputs = outputs
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", stageName)
|
||||
}
|
||||
|
||||
func setCurrentStageForStatusTest(t *testing.T, stageName string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = stageName
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
|
||||
func writeDefaultSemanticContractForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, defaultSemanticContractPath, `---
|
||||
id: anygen_semantic_contract
|
||||
role: semantic_contract
|
||||
invocation: reference
|
||||
rules:
|
||||
- id: no_silent_all_diagram_fallback
|
||||
kind: explicit_reason_required
|
||||
when: deck_has_zero_image_assets
|
||||
artifact: assets/assets_manifest.json
|
||||
field: no_image_reason
|
||||
severity: error
|
||||
- id: image_visual_requires_image_asset
|
||||
kind: visual_asset_type_match
|
||||
visual_type: image
|
||||
asset_type: image
|
||||
severity: error
|
||||
- id: ready_image_and_active_asset_refs_must_render
|
||||
kind: svg_contains_asset_href
|
||||
asset_type: image
|
||||
asset_status: ready
|
||||
svg_selector: '<image slide:role="image"'
|
||||
severity: error
|
||||
---
|
||||
|
||||
# Test Semantic Contract
|
||||
`)
|
||||
}
|
||||
|
||||
func testPromptContractField(stage string) string {
|
||||
return `"prompt_contract":{"protocol":"anygen-svg-slides","stage":"` + stage + `","orchestrator":"mode_system_prompt_svg","protocol_reference":"svg_reference","required_prompt_ids":["mode_system_prompt_svg","svg_reference"]}`
|
||||
}
|
||||
13
internal/svglide/testdata/chart_quality/weak_financial_chart/assets/charts/chart_manifest.json
vendored
Normal file
13
internal/svglide/testdata/chart_quality/weak_financial_chart/assets/charts/chart_manifest.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prompt_contract": {},
|
||||
"renderer": "vega-lite",
|
||||
"charts": [
|
||||
{
|
||||
"id": "revenue",
|
||||
"slide_id": "s1",
|
||||
"renderer": "vega-lite",
|
||||
"spec_path": "assets/charts/specs/revenue.vl.json",
|
||||
"svg_path": "assets/charts/revenue.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
internal/svglide/testdata/chart_quality/weak_financial_chart/assets/charts/revenue.svg
vendored
Normal file
4
internal/svglide/testdata/chart_quality/weak_financial_chart/assets/charts/revenue.svg
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180">
|
||||
<rect x="40" y="60" width="64" height="80"/>
|
||||
<rect x="128" y="40" width="64" height="100"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 166 B |
22
internal/svglide/testdata/chart_quality/weak_financial_chart/assets/charts/specs/revenue.vl.json
vendored
Normal file
22
internal/svglide/testdata/chart_quality/weak_financial_chart/assets/charts/specs/revenue.vl.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"mark": "bar",
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "quarter",
|
||||
"type": "nominal",
|
||||
"axis": null
|
||||
},
|
||||
"y": {
|
||||
"field": "revenue",
|
||||
"type": "quantitative",
|
||||
"axis": null
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"values": [
|
||||
{"quarter": "Q1", "revenue": 12},
|
||||
{"quarter": "Q2", "revenue": 14}
|
||||
]
|
||||
}
|
||||
}
|
||||
153
internal/svglide/typography_contract.go
Normal file
153
internal/svglide/typography_contract.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const typographyContractPath = "brief/typography_contract.json"
|
||||
|
||||
type typographyContractFile struct {
|
||||
PromptContract json.RawMessage `json:"prompt_contract,omitempty"`
|
||||
Profile string `json:"profile"`
|
||||
Roles map[string]typographyFontRole `json:"roles"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
|
||||
type typographyFontRole struct {
|
||||
Family string `json:"family"`
|
||||
Weight string `json:"weight"`
|
||||
Size string `json:"size"`
|
||||
Usage string `json:"usage"`
|
||||
}
|
||||
|
||||
type typographyIdentityResult struct {
|
||||
ConcreteFamilyCount int
|
||||
RolePairingCount int
|
||||
GenericFallbackOnly bool
|
||||
RepeatedDefaultStack bool
|
||||
ProfileMismatch bool
|
||||
}
|
||||
|
||||
func readTypographyContract(safeRoot string) (typographyContractFile, bool, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, typographyContractPath)
|
||||
if err != nil {
|
||||
return typographyContractFile{}, false, err
|
||||
}
|
||||
var file typographyContractFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return typographyContractFile{}, true, fmt.Errorf("%s: invalid JSON: %w", typographyContractPath, err)
|
||||
}
|
||||
return file, true, nil
|
||||
}
|
||||
|
||||
func typographyContractHasRequiredRoles(file typographyContractFile) bool {
|
||||
for _, role := range []string{"display", "body", "number", "label"} {
|
||||
font, ok := file.Roles[role]
|
||||
if !ok || strings.TrimSpace(font.Family) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func evaluateTypographyIdentity(contract typographyContractFile, deckType string) typographyIdentityResult {
|
||||
result := typographyIdentityResult{}
|
||||
roleFamilies := make(map[string]string)
|
||||
concreteFamilies := make(map[string]bool)
|
||||
for role, font := range contract.Roles {
|
||||
normalizedRole := strings.ToLower(strings.TrimSpace(role))
|
||||
family := normalizedFontFamilyStack(font.Family)
|
||||
if family == "" {
|
||||
continue
|
||||
}
|
||||
roleFamilies[normalizedRole] = family
|
||||
if !isGenericOrBrowserFontStack(font.Family) {
|
||||
concreteFamilies[family] = true
|
||||
}
|
||||
}
|
||||
result.ConcreteFamilyCount = len(concreteFamilies)
|
||||
result.RolePairingCount = len(concreteFamilies)
|
||||
result.GenericFallbackOnly = len(roleFamilies) > 0 && result.ConcreteFamilyCount == 0
|
||||
display := roleFamilies["display"]
|
||||
body := roleFamilies["body"]
|
||||
_, hasNumber := roleFamilies["number"]
|
||||
_, hasLabel := roleFamilies["label"]
|
||||
if !hasNumber {
|
||||
_, hasNumber = roleFamilies["numeric"]
|
||||
}
|
||||
if !hasNumber {
|
||||
_, hasNumber = roleFamilies["numeric_or_label"]
|
||||
}
|
||||
result.RepeatedDefaultStack = display == "" || body == "" || (!hasNumber && !hasLabel) || result.ConcreteFamilyCount < 2 || (display != "" && display == body)
|
||||
result.ProfileMismatch = typographyProfileMismatch(contract, deckType)
|
||||
return result
|
||||
}
|
||||
|
||||
func typographyProfileMismatch(contract typographyContractFile, deckType string) bool {
|
||||
profile := strings.ToLower(strings.Join([]string{contract.Profile, deckType}, " "))
|
||||
switch {
|
||||
case containsAny(profile, []string{"finance", "financial", "earnings", "investor", "revenue", "金融", "财报", "财务"}):
|
||||
return !hasFinancialNumericRole(contract)
|
||||
case containsAny(profile, []string{"sports", "sport", "athlete", "league", "score", "match", "体育", "运动", "赛事", "球员"}):
|
||||
return !hasSportsTypographyIdentity(contract)
|
||||
case containsAny(profile, []string{"luxury", "premium", "brand", "fashion", "高端", "奢侈", "品牌"}):
|
||||
return !hasPremiumDisplayIdentity(contract)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasFinancialNumericRole(contract typographyContractFile) bool {
|
||||
for role, font := range contract.Roles {
|
||||
if !containsAny(strings.ToLower(role+" "+font.Usage), []string{"number", "numeric", "data", "table", "financial", "数字", "表格", "数据"}) {
|
||||
continue
|
||||
}
|
||||
family := strings.ToLower(font.Family)
|
||||
if containsAny(family, []string{"mono", "din", "tabular", "roboto mono", "ibm plex mono", "source code"}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSportsTypographyIdentity(contract typographyContractFile) bool {
|
||||
for role, font := range contract.Roles {
|
||||
haystack := strings.ToLower(strings.Join([]string{role, font.Family}, " "))
|
||||
if containsAny(haystack, []string{"condensed", "jersey", "scoreboard", "varsity", "athletic", "bebas", "anton", "oswald", "teko", "din", "impact"}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasPremiumDisplayIdentity(contract typographyContractFile) bool {
|
||||
display, ok := contract.Roles["display"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
family := strings.ToLower(display.Family)
|
||||
if family == "" || containsAny(family, []string{"arial", "helvetica", "aptos", "inter", "system-ui"}) {
|
||||
return false
|
||||
}
|
||||
return containsAny(family, []string{"serif", "didot", "bodoni", "garamond", "caslon", "editorial", "songti", "宋", "明朝"})
|
||||
}
|
||||
|
||||
func normalizedFontFamilyStack(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.Trim(value, `"'`)
|
||||
value = strings.ReplaceAll(value, `"`, "")
|
||||
value = strings.ReplaceAll(value, `'`, "")
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
return value
|
||||
}
|
||||
|
||||
func containsAny(value string, needles []string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, strings.ToLower(needle)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
524
internal/svglide/validate.go
Normal file
524
internal/svglide/validate.go
Normal file
@@ -0,0 +1,524 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const slideNamespace = "https://slides.bytedance.com/ns"
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
const xlinkNamespace = "http://www.w3.org/1999/xlink"
|
||||
|
||||
type ValidationReport struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ValidationIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
type validationDeck struct {
|
||||
Slides []validationDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type validationDeckSlide struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type svgViewBox struct {
|
||||
Width float64
|
||||
Height float64
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type svgLintElement struct {
|
||||
Excluded bool
|
||||
TextCandidate bool
|
||||
}
|
||||
|
||||
func ValidateRun(root string) (ValidationReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ValidationReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return failValidation(safeRoot, ValidationIssue{Code: "svglide.deck", Message: "deck artifact path is empty"}, fmt.Errorf("deck artifact path is empty"))
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
var deck validationDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains no slides", deckPath)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := ValidationReport{Issues: []ValidationIssue{}}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath := strings.TrimSpace(slide.Path)
|
||||
if slidePath == "" {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Code: "svglide.path", Message: "slide path must not be empty"})
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Path: slidePath, Code: "svglide.path", Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
report.Issues = append(report.Issues, lintSVG(slidePath, raw)...)
|
||||
}
|
||||
report = normalizeValidationReport(report)
|
||||
|
||||
if err := writeValidationArtifacts(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func failValidation(safeRoot string, issue ValidationIssue, err error) (ValidationReport, error) {
|
||||
report := ValidationReport{Issues: []ValidationIssue{issue}}
|
||||
report = normalizeValidationReport(report)
|
||||
if writeErr := writeValidationArtifacts(safeRoot, report); writeErr != nil {
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%w; write validation artifacts: %v", err, writeErr)
|
||||
}
|
||||
return report, writeErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readRunRegularArtifact(safeRoot string, rel string) ([]byte, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("run path %q is missing or not a regular file inside run root", rel)
|
||||
}
|
||||
raw, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read run path %q: %w", rel, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func lintSVG(path string, raw []byte) []ValidationIssue {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var issues []ValidationIssue
|
||||
var rootSeen bool
|
||||
var rootIsSVG bool
|
||||
var hasSlideRole bool
|
||||
var hasViewBox bool
|
||||
var hasVisibleContent bool
|
||||
var viewBox svgViewBox
|
||||
var stack []svgLintElement
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: fmt.Sprintf("invalid XML: %v", err)}}
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded
|
||||
excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed)
|
||||
ctx := svgLintElement{
|
||||
Excluded: excluded,
|
||||
TextCandidate: elementIsTextCandidate(typed),
|
||||
}
|
||||
if !rootSeen {
|
||||
rootSeen = true
|
||||
rootIsSVG = typed.Name.Local == "svg" && typed.Name.Space == svgNamespace
|
||||
hasSlideRole = hasRootSlideRole(typed)
|
||||
viewBox, hasViewBox = rootViewBox(typed)
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
stack = append(stack, ctx)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
if elementCountsAsVisibleContent(typed, viewBox, excluded) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
stack = append(stack, ctx)
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(typed)) != "" && activeVisibleTextCandidate(stack) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !rootSeen {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: "invalid XML: missing root element"}}
|
||||
}
|
||||
if !rootIsSVG {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.root", Message: "root element must be <svg>"})
|
||||
}
|
||||
if !hasSlideRole {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.slide_role", Message: `root element must include slide:role="slide"`})
|
||||
}
|
||||
if !hasViewBox {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include viewBox"})
|
||||
} else if !viewBox.Valid {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include valid viewBox"})
|
||||
}
|
||||
if rootIsSVG && !hasVisibleContent {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.visible_content", Message: "slide contains only background/placeholder content"})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func hasRootSlideRole(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if strings.TrimSpace(attr.Value) != "slide" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Local == "role" && attr.Name.Space == slideNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rootViewBox(start xml.StartElement) (svgViewBox, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" || attr.Name.Local != "viewBox" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
return parseViewBox(attr.Value), true
|
||||
}
|
||||
return svgViewBox{}, false
|
||||
}
|
||||
|
||||
func parseViewBox(value string) svgViewBox {
|
||||
fields := strings.Fields(strings.ReplaceAll(value, ",", " "))
|
||||
if len(fields) != 4 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values := make([]float64, 4)
|
||||
for i, field := range fields {
|
||||
parsed, err := strconv.ParseFloat(field, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values[i] = parsed
|
||||
}
|
||||
width := values[2]
|
||||
height := values[3]
|
||||
if width <= 0 || height <= 0 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
return svgViewBox{Width: width, Height: height, Valid: true}
|
||||
}
|
||||
|
||||
func lintSVGElementProtocol(path string, start xml.StartElement, excluded bool) []ValidationIssue {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return nil
|
||||
}
|
||||
|
||||
var issues []ValidationIssue
|
||||
if excluded {
|
||||
return issues
|
||||
}
|
||||
if elementHasNonPositiveDimension(start) {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.geometry",
|
||||
Message: fmt.Sprintf("<%s> has non-positive width or height", start.Name.Local),
|
||||
})
|
||||
}
|
||||
if start.Name.Local == "image" {
|
||||
if !hasSlideAttr(start, "role", "image") {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.image_role",
|
||||
Message: `image must include slide:role="image"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func elementHasNonPositiveDimension(start xml.StartElement) bool {
|
||||
for _, name := range []string{"width", "height"} {
|
||||
value, ok := plainAttr(start, name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parsed, ok := parseSVGDimension(value)
|
||||
if ok && parsed <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSlideAttr(start xml.StartElement, local string, value string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == slideNamespace && attr.Name.Local == local && strings.TrimSpace(attr.Value) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func plainAttr(start xml.StartElement, local string) (string, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == local {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseSVGDimension(value string) (float64, bool) {
|
||||
s := strings.TrimSpace(value)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
for _, suffix := range []string{"vmax", "vmin", "rem", "px", "%", "em", "pt", "pc", "in", "cm", "mm", "qh", "q", "ex", "ch", "vw", "vh"} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
s = strings.TrimSpace(s[:len(s)-len(suffix)])
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func elementCountsAsVisibleContent(start xml.StartElement, viewBox svgViewBox, excluded bool) bool {
|
||||
if excluded {
|
||||
return false
|
||||
}
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "text", "tspan":
|
||||
return false
|
||||
case "foreignObject", "chart":
|
||||
return true
|
||||
case "image", "use":
|
||||
return elementHasHref(start)
|
||||
case "g":
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
case "path", "circle", "ellipse", "line", "polyline", "polygon":
|
||||
return true
|
||||
case "rect":
|
||||
return !isBackgroundRect(start, viewBox)
|
||||
default:
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
}
|
||||
}
|
||||
|
||||
func activeVisibleTextCandidate(stack []svgLintElement) bool {
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
if stack[i].Excluded {
|
||||
return false
|
||||
}
|
||||
if stack[i].TextCandidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func elementIsTextCandidate(start xml.StartElement) bool {
|
||||
return start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan")
|
||||
}
|
||||
|
||||
func elementIsHidden(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
switch attr.Name.Local {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(attr.Value) {
|
||||
return true
|
||||
}
|
||||
case "style":
|
||||
if styleHidesElement(attr.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func styleHidesElement(style string) bool {
|
||||
for _, declaration := range strings.Split(style, ";") {
|
||||
name, value, ok := strings.Cut(declaration, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func opacityIsZero(value string) bool {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return false
|
||||
}
|
||||
return floatEqual(parsed, 0)
|
||||
}
|
||||
|
||||
func elementIsNonRendering(start xml.StartElement) bool {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "defs", "symbol", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "marker", "metadata", "title", "desc", "style", "script":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elementHasHref(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local != "href" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Space == "" || attr.Name.Space == xlinkNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSemanticMarker(start xml.StartElement, terms ...string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(attr.Name.Local)
|
||||
if name != "role" && name != "class" && name != "id" && !strings.HasPrefix(name, "data-") {
|
||||
continue
|
||||
}
|
||||
value := strings.ToLower(attr.Value)
|
||||
for _, term := range terms {
|
||||
if strings.Contains(value, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBackgroundRect(start xml.StartElement, viewBox svgViewBox) bool {
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return true
|
||||
}
|
||||
width := attrValue(start, "width")
|
||||
height := attrValue(start, "height")
|
||||
if width == "100%" && height == "100%" {
|
||||
return true
|
||||
}
|
||||
if !viewBox.Valid {
|
||||
return false
|
||||
}
|
||||
x := attrFloatDefault(start, "x", 0)
|
||||
y := attrFloatDefault(start, "y", 0)
|
||||
w, okW := parseAttrFloat(width)
|
||||
h, okH := parseAttrFloat(height)
|
||||
if !okW || !okH {
|
||||
return false
|
||||
}
|
||||
return floatEqual(x, 0) && floatEqual(y, 0) && floatEqual(w, viewBox.Width) && floatEqual(h, viewBox.Height)
|
||||
}
|
||||
|
||||
func attrValue(start xml.StartElement, name string) string {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == name {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func attrFloatDefault(start xml.StartElement, name string, fallback float64) float64 {
|
||||
value := attrValue(start, name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, ok := parseAttrFloat(value)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseAttrFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func floatEqual(a float64, b float64) bool {
|
||||
return math.Abs(a-b) < 0.001
|
||||
}
|
||||
997
internal/svglide/validate_test.go
Normal file
997
internal/svglide/validate_test.go
Normal file
@@ -0,0 +1,997 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRunRejectsBackgroundOnlySVGAndWritesRepairArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), backgroundOnlySVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected background-only SVG issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") {
|
||||
t.Fatalf("Issues = %+v, want background/placeholder issue", report.Issues)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || len(receipt.Issues) == 0 {
|
||||
t.Fatalf("lint receipt = %+v, want failing issues", receipt)
|
||||
}
|
||||
var lintReceipt validationLintReceipt
|
||||
if err := json.Unmarshal(raw, &lintReceipt); err != nil {
|
||||
t.Fatalf("lint receipt is not schema-compatible JSON: %v", err)
|
||||
}
|
||||
if lintReceipt.Status != "failed" {
|
||||
t.Fatalf("lint receipt status = %q, want failed", lintReceipt.Status)
|
||||
}
|
||||
if lintReceipt.Issues[0].Code == "" || lintReceipt.Issues[0].Severity == "" {
|
||||
t.Fatalf("lint receipt issue = %+v, want code and severity", lintReceipt.Issues[0])
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), "slides/01.svg") {
|
||||
t.Fatalf("repair queue = %q, want slide path", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunPassesVisibleTextSVG(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("Issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(queue)) != "No repair needed." {
|
||||
t.Fatalf("repair queue = %q, want no repair text", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEscapingSlidePath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "../outside.svg")
|
||||
writeValidateTestFile(t, "outside.svg", visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with escaping slide path: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsSlideSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: "slides/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(filepath.Join(outside, "01.svg"), filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: "slides/link/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.deckPath)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "01.svg"), []byte(visibleTextSVG()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked slide path: %+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDeckSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: filepath.Join("demo", "outline", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(outside, "deck.json"), filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: filepath.Join("demo", "outline_link", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "outline_link/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "outline_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeMinimalDeckAt(t, filepath.Join(outside, "deck.json"), "slides/01.svg")
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked deck path %q: %+v", tt.deckPath, report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEmptyDeck(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo")
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, "no slides")
|
||||
}
|
||||
|
||||
func TestValidateRunWritesRepairArtifactsForDeckReadFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing deck",
|
||||
setup: func(t *testing.T) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
{
|
||||
name: "invalid deck json",
|
||||
setup: func(t *testing.T) {
|
||||
writeValidateTestFile(t, filepath.Join("demo", "outline", "deck.json"), `{`)
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
tt.setup(t)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReadsDeckFromRunArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/bad.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReportsInvalidXML(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg><text>broken`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "XML") && !validationIssuesContain(report.Issues, "xml") {
|
||||
t.Fatalf("Issues = %+v, want XML parse issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRequiresSVGRootSlideRoleAndViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non svg root",
|
||||
svg: `<html><body>not svg</body></html>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "wrong svg namespace",
|
||||
svg: `<svg xmlns="https://wrong.example/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "missing slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "missing viewBox",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text>hello</text></svg>`,
|
||||
want: `viewBox`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespaced slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:foo="https://wrong.example" foo:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "unbound slide prefix role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, tt.want) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsInvalidViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
}{
|
||||
{
|
||||
name: "bad viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox origin fields",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad bad 960 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nan viewBox width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 NaN 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "zero viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 0 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox with full page rect",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><rect width="960" height="540" fill="#fff"/></svg>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresNonVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "text in defs",
|
||||
body: `<defs><text>hidden template</text></defs>`,
|
||||
},
|
||||
{
|
||||
name: "display none text",
|
||||
body: `<text display="none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden text",
|
||||
body: `<text visibility="hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style display none text",
|
||||
body: `<text style="display:none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style visibility hidden text",
|
||||
body: `<text style="visibility:hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "opacity zero text",
|
||||
body: `<text opacity="0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style opacity zero text",
|
||||
body: `<text style="opacity:0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
body: `<text> </text>`,
|
||||
},
|
||||
{
|
||||
name: "image without href",
|
||||
body: `<image slide:role="image" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "use without href",
|
||||
body: `<use x="10" y="10"/>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsWrongNamespaceVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "wrong namespace path",
|
||||
body: `<bad:path xmlns:bad="https://wrong.example/svg" d="M10 10h20v20z"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace text",
|
||||
body: `<bad:text xmlns:bad="https://wrong.example/svg">hidden by namespace</bad:text>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace image href",
|
||||
body: `<image xmlns:bad="https://wrong.example/svg" bad:href="asset.png" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace viewBox",
|
||||
body: `<text>hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
viewBox := `viewBox="0 0 960 540"`
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
viewBox = `bad:viewBox="0 0 960 540" xmlns:bad="https://wrong.example/svg"`
|
||||
}
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" ` + viewBox + `>` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsNamespacedXLinkHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" xlink:href="assets/images/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsNegativeElementDimensions(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefCaseInsensitive(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="HTTPS://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsImageWithoutImageRole(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image href="assets/images/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want image role issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresGeometryAndImageRoleInsideExcludedContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "defs image",
|
||||
body: `<defs><image href="assets/images/defs.png" width="-4px" height="120"/></defs><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "pattern image",
|
||||
body: `<pattern id="p"><image href="assets/images/pattern.png" width="120" height="0%"/></pattern><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "mask image",
|
||||
body: `<mask id="m"><image href="assets/images/mask.png" width="auto" height="-4px"/></mask><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "display none image",
|
||||
body: `<g display="none"><image href="assets/images/hidden.png" width="-4px" height="120"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden image",
|
||||
body: `<g visibility="hidden"><image href="assets/images/hidden.png" width="120" height="-4px"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "marker image role",
|
||||
body: `<marker id="mk"><image href="assets/images/marker.png" width="120" height="80"/></marker><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, want no remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want no image role issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefWithXLink(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" xlink:href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
}{
|
||||
{name: "parent directory", href: "../outside.png"},
|
||||
{name: "absolute path", href: "/Users/example/secret.png"},
|
||||
{name: "file url", href: "file:///tmp/secret.png"},
|
||||
{name: "protocol relative", href: "//example.com/hero.png"},
|
||||
{name: "data url", href: "data:image/png;base64,AAAA"},
|
||||
{name: "percent encoding", href: "assets/images/hero%2epng"},
|
||||
{name: "nested asset path", href: "assets/images/nested/hero.png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="`+tt.href+`" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true for %s: %+v", tt.href, report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefInsideExcludedContent(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<defs><image href="file:///tmp/secret.png" width="-4px" height="120"/></defs>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue inside excluded content", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDimensionUnits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "negative px",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4px" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero percent",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="0%" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auto width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="auto" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Fine</div>
|
||||
</foreignObject>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.want {
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsPlainHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" href="assets/images/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected receipt symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "lint.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lint receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsLintReceiptFileSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "lint.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-lint.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected lint receipt file symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside file was overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initValidateTestRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitRun("demo", InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func writeMinimalDeck(t *testing.T, root string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
writeMinimalDeckAt(t, filepath.Join(root, "outline", "deck.json"), slidePaths...)
|
||||
}
|
||||
|
||||
func writeMinimalDeckAt(t *testing.T, path string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
slides := make([]map[string]string, 0, len(slidePaths))
|
||||
for i, path := range slidePaths {
|
||||
slides = append(slides, map[string]string{
|
||||
"id": "slide-" + string(rune('1'+i)),
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "content",
|
||||
"key_message": "Message",
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": slides,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
|
||||
func readValidateTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeValidateTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidateTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundOnlySVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540" fill="#fff"/></svg>`
|
||||
}
|
||||
|
||||
func visibleTextSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + fontTokenStyleForTest() + `<rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func fontTokenStyleForTest() string {
|
||||
return `<style>:root{--font-display:"Noto Serif CJK SC",serif;--font-body:"Noto Sans CJK SC",sans-serif;--font-number:"Roboto Mono",monospace;--font-label:"PingFang SC",sans-serif;}</style>`
|
||||
}
|
||||
|
||||
func validationIssuesContain(issues []ValidationIssue, needle string) bool {
|
||||
for _, issue := range issues {
|
||||
if strings.Contains(issue.Path, needle) || strings.Contains(issue.Message, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validationIssuesContainCode(issues []ValidationIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertValidationFailureArtifacts(t *testing.T, root string, report ValidationReport, needle string) {
|
||||
t.Helper()
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected validation issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, needle) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, needle)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(root, "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || !validationIssuesContain(receipt.Issues, needle) {
|
||||
t.Fatalf("lint receipt = %+v, want failing issue containing %q", receipt, needle)
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join(root, "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), needle) {
|
||||
t.Fatalf("repair queue = %q, want %q", string(queue), needle)
|
||||
}
|
||||
}
|
||||
168
internal/svglide/visual_asset_gate.go
Normal file
168
internal/svglide/visual_asset_gate.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VisualAssetGateInput struct {
|
||||
RequestText string
|
||||
EntityKind string
|
||||
Slides int
|
||||
RealImageAssets int
|
||||
OfficialImageAssets int
|
||||
SlidesWithRealImages int
|
||||
CoverRealHeroImage bool
|
||||
NoImageReason string
|
||||
ExplicitChartOnly bool
|
||||
}
|
||||
|
||||
type VisualAssetGateResult struct {
|
||||
Status string `json:"status"`
|
||||
Required bool `json:"required"`
|
||||
CoverRealHeroRequired bool `json:"cover_real_hero_required"`
|
||||
CoverRealHeroPresent bool `json:"cover_real_hero_present"`
|
||||
IssueCount int `json:"issue_count"`
|
||||
Issues []VisualAssetIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type VisualAssetIssue struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func EvaluateVisualAssetGate(input VisualAssetGateInput) VisualAssetGateResult {
|
||||
required := visualAssetRequired(input)
|
||||
result := VisualAssetGateResult{
|
||||
Status: "passed",
|
||||
Required: required,
|
||||
CoverRealHeroRequired: required,
|
||||
CoverRealHeroPresent: input.CoverRealHeroImage,
|
||||
Issues: []VisualAssetIssue{},
|
||||
}
|
||||
if !required {
|
||||
return result
|
||||
}
|
||||
if input.RealImageAssets == 0 || input.SlidesWithRealImages == 0 {
|
||||
result.Issues = append(result.Issues, VisualAssetIssue{
|
||||
Code: "svglide.visual_asset.real_image_missing",
|
||||
Message: "entity-driven deck requires at least one real image asset; charts and typography are not enough",
|
||||
})
|
||||
}
|
||||
if !input.CoverRealHeroImage {
|
||||
result.Issues = append(result.Issues, VisualAssetIssue{
|
||||
Code: "svglide.visual_asset.cover_real_hero_missing",
|
||||
Message: "entity-driven deck requires a real cover hero image or strong subject visual",
|
||||
})
|
||||
}
|
||||
if len(result.Issues) > 0 {
|
||||
result.Status = "failed"
|
||||
result.IssueCount = len(result.Issues)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func visualAssetRequired(input VisualAssetGateInput) bool {
|
||||
if input.ExplicitChartOnly {
|
||||
return false
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(input.EntityKind))
|
||||
switch kind {
|
||||
case "company", "brand", "product", "person", "place", "location", "team", "event", "film", "book":
|
||||
return true
|
||||
}
|
||||
text := strings.ToLower(input.RequestText)
|
||||
for _, hint := range []string{
|
||||
"financial report for ", "nvidia", "apple", "leica", "kaneko", "world cup", "olympic",
|
||||
"公司", "品牌", "产品", "球队", "队", "球员", "城市", "地点", "电影",
|
||||
"company", "brand", "product", "team", "player", "city", "museum", "restaurant",
|
||||
} {
|
||||
if strings.Contains(text, hint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func qualityRequestText(root string) string {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Input string `json:"input"`
|
||||
Topic string `json:"topic"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
_ = readJSONIfExists(filepath.Join(root, "request", "request.json"), &req)
|
||||
text := strings.TrimSpace(strings.Join([]string{req.Title, req.Input, req.Topic, req.Prompt}, " "))
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
_, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{run.Title, run.Input, run.Intent.Topic, run.Intent.Input}, " "))
|
||||
}
|
||||
|
||||
func qualityEntityKind(root string) string {
|
||||
entity := readQualityEntityResolution(root)
|
||||
if value := strings.TrimSpace(entity.ResolvedEntity.Type); value != "" {
|
||||
return value
|
||||
}
|
||||
var raw struct {
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = readJSONIfExists(filepath.Join(root, "request", "entity_resolution.json"), &raw)
|
||||
if raw.Kind != "" {
|
||||
return raw.Kind
|
||||
}
|
||||
return raw.Type
|
||||
}
|
||||
|
||||
func qualityNoImageReason(root string) string {
|
||||
var plan struct {
|
||||
NoImageReason string `json:"no_image_reason"`
|
||||
}
|
||||
_ = readJSONIfExists(filepath.Join(root, assetsPlanPath), &plan)
|
||||
if strings.TrimSpace(plan.NoImageReason) != "" {
|
||||
return plan.NoImageReason
|
||||
}
|
||||
assets, err := readDeckAssetsArtifact(root, assetsManifestPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return assets.NoImageReason
|
||||
}
|
||||
|
||||
func qualityRequestExplicitChartOnly(root string) bool {
|
||||
text := strings.ToLower(strings.Join([]string{qualityRequestText(root), qualityNoImageReason(root)}, " "))
|
||||
return strings.Contains(text, "chart-only") ||
|
||||
strings.Contains(text, "vector-only") ||
|
||||
strings.Contains(text, "no photos") ||
|
||||
strings.Contains(text, "no raster") ||
|
||||
strings.Contains(text, "仅图表") ||
|
||||
strings.Contains(text, "不要图片")
|
||||
}
|
||||
|
||||
func readJSONIfExists(path string, dst any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dst)
|
||||
}
|
||||
|
||||
func visualAssetIssuesContain(issues []VisualAssetIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
57
internal/svglide/visual_asset_gate_test.go
Normal file
57
internal/svglide/visual_asset_gate_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVisualAssetGateFailsEntityFinancialReportWithoutRealImages(t *testing.T) {
|
||||
input := VisualAssetGateInput{
|
||||
RequestText: "Generate a comprehensive financial report for Q4 2023 for Nvidia.",
|
||||
EntityKind: "company",
|
||||
Slides: 8,
|
||||
RealImageAssets: 0,
|
||||
OfficialImageAssets: 0,
|
||||
SlidesWithRealImages: 0,
|
||||
CoverRealHeroImage: false,
|
||||
NoImageReason: "This data-report deck does not require raster images; visual evidence is carried by charts.",
|
||||
}
|
||||
result := EvaluateVisualAssetGate(input)
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", result.Status)
|
||||
}
|
||||
if !visualAssetIssuesContain(result.Issues, "svglide.visual_asset.cover_real_hero_missing") {
|
||||
t.Fatalf("issues = %+v, want cover real hero missing", result.Issues)
|
||||
}
|
||||
if !visualAssetIssuesContain(result.Issues, "svglide.visual_asset.real_image_missing") {
|
||||
t.Fatalf("issues = %+v, want real image missing", result.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualAssetGatePassesAbstractChartOnlyDeck(t *testing.T) {
|
||||
input := VisualAssetGateInput{
|
||||
RequestText: "Explain quarterly revenue trend as a chart-only internal analytics deck.",
|
||||
EntityKind: "abstract_data",
|
||||
Slides: 6,
|
||||
RealImageAssets: 0,
|
||||
CoverRealHeroImage: false,
|
||||
ExplicitChartOnly: true,
|
||||
}
|
||||
result := EvaluateVisualAssetGate(input)
|
||||
if result.Status != "passed" {
|
||||
t.Fatalf("status = %q issues = %+v, want passed", result.Status, result.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisualAssetGatePassesEntityDeckWithCoverHero(t *testing.T) {
|
||||
input := VisualAssetGateInput{
|
||||
RequestText: "Introduce Leica M cameras.",
|
||||
EntityKind: "product",
|
||||
Slides: 8,
|
||||
RealImageAssets: 4,
|
||||
OfficialImageAssets: 2,
|
||||
SlidesWithRealImages: 5,
|
||||
CoverRealHeroImage: true,
|
||||
}
|
||||
result := EvaluateVisualAssetGate(input)
|
||||
if result.Status != "passed" {
|
||||
t.Fatalf("status = %q issues = %+v, want passed", result.Status, result.Issues)
|
||||
}
|
||||
}
|
||||
27
internal/svglide/visual_contract.go
Normal file
27
internal/svglide/visual_contract.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package svglide
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
requiredChartRendererNone = "none"
|
||||
requiredChartRendererVegaLite = "vega-lite"
|
||||
)
|
||||
|
||||
func normalizedRequiredChartRenderer(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case requiredChartRendererVegaLite:
|
||||
return requiredChartRendererVegaLite
|
||||
default:
|
||||
return requiredChartRendererNone
|
||||
}
|
||||
}
|
||||
|
||||
func visualContractRequiresChartManifest(contract qualityVisualContract) bool {
|
||||
return normalizedRequiredChartRenderer(contract.RequiredChartRenderer) == requiredChartRendererVegaLite ||
|
||||
contract.MinChartSVGAssets > 0 ||
|
||||
contract.MinVegaLiteSpecs > 0
|
||||
}
|
||||
|
||||
func visualContractRequiresTypography(contract qualityVisualContract) bool {
|
||||
return contract.TypographyContractRequired
|
||||
}
|
||||
181
internal/svglide/visual_fusion.go
Normal file
181
internal/svglide/visual_fusion.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RGBColor struct {
|
||||
R uint8
|
||||
G uint8
|
||||
B uint8
|
||||
}
|
||||
|
||||
type EdgePalette struct {
|
||||
Side string `json:"side"`
|
||||
Hex string `json:"hex"`
|
||||
Luminance int `json:"luminance"`
|
||||
Texture int `json:"texture"`
|
||||
}
|
||||
|
||||
func AnalyzeEdgePalette(path string, side string) (EdgePalette, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return EdgePalette{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return EdgePalette{}, err
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() == 0 || bounds.Dy() == 0 {
|
||||
return EdgePalette{}, fmt.Errorf("image %q has empty bounds", path)
|
||||
}
|
||||
side = normalizedEdgeSide(side)
|
||||
minX, maxX, minY, maxY := edgeSampleRect(bounds, side)
|
||||
var count int
|
||||
var totalR, totalG, totalB, totalLum int
|
||||
var previousLum int
|
||||
var texture int
|
||||
for y := minY; y < maxY; y++ {
|
||||
for x := minX; x < maxX; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
color := RGBColor{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8)}
|
||||
lum := colorLuminance(color)
|
||||
totalR += int(color.R)
|
||||
totalG += int(color.G)
|
||||
totalB += int(color.B)
|
||||
totalLum += lum
|
||||
if count > 0 {
|
||||
texture += absInt(lum - previousLum)
|
||||
}
|
||||
previousLum = lum
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return EdgePalette{}, fmt.Errorf("image %q side %q has no sample pixels", path, side)
|
||||
}
|
||||
color := RGBColor{
|
||||
R: uint8(totalR / count),
|
||||
G: uint8(totalG / count),
|
||||
B: uint8(totalB / count),
|
||||
}
|
||||
return EdgePalette{
|
||||
Side: side,
|
||||
Hex: colorToHex(color),
|
||||
Luminance: totalLum / count,
|
||||
Texture: texture / count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ScoreFusionCandidate(palette EdgePalette) int {
|
||||
score := 100
|
||||
if palette.Luminance > 110 {
|
||||
score -= (palette.Luminance - 110) / 2
|
||||
}
|
||||
if palette.Texture > 20 {
|
||||
score -= (palette.Texture - 20) * 2
|
||||
}
|
||||
if score < 0 {
|
||||
return 0
|
||||
}
|
||||
if score > 100 {
|
||||
return 100
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func CheckSeamDelta(sampledHex string, panelHex string) (bool, int) {
|
||||
a, errA := parseHexColor(sampledHex)
|
||||
b, errB := parseHexColor(panelHex)
|
||||
if errA != nil || errB != nil {
|
||||
return false, 255
|
||||
}
|
||||
delta := colorDelta(a, b)
|
||||
return delta <= 45, delta
|
||||
}
|
||||
|
||||
func normalizedEdgeSide(side string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(side)) {
|
||||
case "right", "top", "bottom":
|
||||
return strings.ToLower(strings.TrimSpace(side))
|
||||
default:
|
||||
return "left"
|
||||
}
|
||||
}
|
||||
|
||||
func edgeSampleRect(bounds image.Rectangle, side string) (int, int, int, int) {
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
band := 80
|
||||
if width < band*4 {
|
||||
band = maxInt(1, width/5)
|
||||
}
|
||||
if height < band*4 {
|
||||
band = maxInt(1, height/5)
|
||||
}
|
||||
switch side {
|
||||
case "right":
|
||||
return bounds.Max.X - band, bounds.Max.X, bounds.Min.Y, bounds.Max.Y
|
||||
case "top":
|
||||
return bounds.Min.X, bounds.Max.X, bounds.Min.Y, bounds.Min.Y + band
|
||||
case "bottom":
|
||||
return bounds.Min.X, bounds.Max.X, bounds.Max.Y - band, bounds.Max.Y
|
||||
default:
|
||||
return bounds.Min.X, bounds.Min.X + band, bounds.Min.Y, bounds.Max.Y
|
||||
}
|
||||
}
|
||||
|
||||
func parseHexColor(value string) (RGBColor, error) {
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
|
||||
if len(value) == 3 {
|
||||
value = string([]byte{value[0], value[0], value[1], value[1], value[2], value[2]})
|
||||
}
|
||||
if len(value) != 6 {
|
||||
return RGBColor{}, fmt.Errorf("invalid hex color %q", value)
|
||||
}
|
||||
r, err := strconv.ParseUint(value[0:2], 16, 8)
|
||||
if err != nil {
|
||||
return RGBColor{}, err
|
||||
}
|
||||
g, err := strconv.ParseUint(value[2:4], 16, 8)
|
||||
if err != nil {
|
||||
return RGBColor{}, err
|
||||
}
|
||||
b, err := strconv.ParseUint(value[4:6], 16, 8)
|
||||
if err != nil {
|
||||
return RGBColor{}, err
|
||||
}
|
||||
return RGBColor{R: uint8(r), G: uint8(g), B: uint8(b)}, nil
|
||||
}
|
||||
|
||||
func colorToHex(color RGBColor) string {
|
||||
return fmt.Sprintf("#%02X%02X%02X", color.R, color.G, color.B)
|
||||
}
|
||||
|
||||
func colorLuminance(color RGBColor) int {
|
||||
return int(0.2126*float64(color.R) + 0.7152*float64(color.G) + 0.0722*float64(color.B))
|
||||
}
|
||||
|
||||
func colorDelta(a RGBColor, b RGBColor) int {
|
||||
dr := int(a.R) - int(b.R)
|
||||
dg := int(a.G) - int(b.G)
|
||||
db := int(a.B) - int(b.B)
|
||||
return int(math.Sqrt(float64(dr*dr + dg*dg + db*db)))
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
58
internal/svglide/visual_fusion_test.go
Normal file
58
internal/svglide/visual_fusion_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnalyzeEdgePaletteSamplesRequestedEdge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "edge.png")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 60))
|
||||
for y := 0; y < 60; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
if x < 20 {
|
||||
img.Set(x, y, color.RGBA{R: 8, G: 12, B: 18, A: 255})
|
||||
} else {
|
||||
img.Set(x, y, color.RGBA{R: 230, G: 230, B: 230, A: 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
writePNGForFusionTest(t, path, img)
|
||||
|
||||
palette, err := AnalyzeEdgePalette(path, "left")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if palette.Side != "left" || palette.Luminance > 40 {
|
||||
t.Fatalf("palette = %+v, want dark left edge", palette)
|
||||
}
|
||||
if ScoreFusionCandidate(palette) < 80 {
|
||||
t.Fatalf("ScoreFusionCandidate(%+v) too low", palette)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSeamDelta(t *testing.T) {
|
||||
if ok, delta := CheckSeamDelta("#101820", "#111922"); !ok || delta > 45 {
|
||||
t.Fatalf("similar colors ok=%v delta=%d, want ok", ok, delta)
|
||||
}
|
||||
if ok, delta := CheckSeamDelta("#101820", "#F4F4F4"); ok || delta <= 45 {
|
||||
t.Fatalf("distant colors ok=%v delta=%d, want rejection", ok, delta)
|
||||
}
|
||||
}
|
||||
|
||||
func writePNGForFusionTest(t *testing.T, path string, img image.Image) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err := png.Encode(file, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
|
||||
return DefaultFS.OpenFile(name, flag, perm)
|
||||
}
|
||||
func CreateTemp(dir, pattern string) (*os.File, error) { return DefaultFS.CreateTemp(dir, pattern) }
|
||||
func Mkdir(path string, perm fs.FileMode) error { return DefaultFS.Mkdir(path, perm) }
|
||||
func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirAll(path, perm) }
|
||||
func MkdirTemp(dir, pattern string) (string, error) { return DefaultFS.MkdirTemp(dir, pattern) }
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
|
||||
@@ -25,6 +25,7 @@ type FS interface {
|
||||
CreateTemp(dir, pattern string) (*os.File, error)
|
||||
|
||||
// Directory/File management
|
||||
Mkdir(path string, perm fs.FileMode) error
|
||||
MkdirAll(path string, perm fs.FileMode) error
|
||||
MkdirTemp(dir, pattern string) (string, error)
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
|
||||
@@ -30,6 +30,7 @@ func (OsFs) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)
|
||||
func (OsFs) CreateTemp(dir, pattern string) (*os.File, error) { return os.CreateTemp(dir, pattern) }
|
||||
|
||||
// Directory/File management
|
||||
func (OsFs) Mkdir(path string, perm fs.FileMode) error { return os.Mkdir(path, perm) }
|
||||
func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
func (OsFs) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) }
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
|
||||
@@ -23,6 +23,15 @@ func TestOsFsBasicOperations(t *testing.T) {
|
||||
fs := OsFs{}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Mkdir
|
||||
one := filepath.Join(dir, "one")
|
||||
if err := fs.Mkdir(one, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
if err := Mkdir(filepath.Join(dir, "two"), 0o755); err != nil {
|
||||
t.Fatalf("package Mkdir: %v", err)
|
||||
}
|
||||
|
||||
// MkdirAll
|
||||
sub := filepath.Join(dir, "a", "b")
|
||||
if err := fs.MkdirAll(sub, 0o755); err != nil {
|
||||
|
||||
@@ -927,6 +927,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
if s.LocalOnly {
|
||||
return runLocalShortcut(cmd, f, s, botOnly)
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -948,6 +952,23 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
return err
|
||||
}
|
||||
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runLocalShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
as := core.AsBot
|
||||
if asFlag, _ := cmd.Flags().GetString("as"); asFlag != "" && core.Identity(asFlag) != core.AsAuto {
|
||||
as = core.Identity(asFlag)
|
||||
}
|
||||
if err := f.CheckIdentity(as, s.AuthTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
config := &core.CliConfig{Brand: core.BrandFeishu}
|
||||
rctx := newLocalRuntimeContext(cmd, f, s, config, as, botOnly)
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runShortcutWithContext(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1031,6 +1052,21 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
func newLocalRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
|
||||
ctx := cmd.Context()
|
||||
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
|
||||
rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f}
|
||||
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and cannot call OpenAPI", s.Service, s.Command)
|
||||
})
|
||||
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and has no bot identity", s.Service, s.Command)
|
||||
})
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
@@ -1253,5 +1289,12 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
if s.LocalOnly {
|
||||
cmd.Flags().String("as", "", "identity type: "+strings.Join(s.AuthTypes, " | "))
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
return
|
||||
}
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type Shortcut struct {
|
||||
HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
LocalOnly bool // pure local command: no identity, config, scope, SDK, or OpenAPI bootstrap
|
||||
|
||||
// Business logic hooks.
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
|
||||
@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
|
||||
want := []string{
|
||||
"+chat-create",
|
||||
"+chat-list",
|
||||
"+chat-members-list",
|
||||
"+chat-messages-list",
|
||||
"+chat-search",
|
||||
"+chat-update",
|
||||
|
||||
420
shortcuts/im/im_chat_members_list.go
Normal file
420
shortcuts/im/im_chat_members_list.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
|
||||
chatMembersListDefaultPageSize = 20
|
||||
chatMembersListMaxPageSize = 100
|
||||
// chatMembersListDefaultPageDelay throttles --page-all the same way the
|
||||
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
|
||||
// server-side member cap, where a large group drains many pages back to
|
||||
// back and could otherwise trip rate limits.
|
||||
chatMembersListDefaultPageDelay = 200
|
||||
)
|
||||
|
||||
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
|
||||
// returning users and bots in separate buckets (users[]/bots[]). It owns its
|
||||
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
|
||||
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
|
||||
// the response is multi-bucket — the generic --page-all merger is built for
|
||||
// single-array responses and would drop the bots[] bucket and the final-page
|
||||
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
|
||||
var ImChatMembersList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-members-list",
|
||||
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
|
||||
Risk: "read",
|
||||
// Declare the narrowest scope the API accepts so tokens carrying only
|
||||
// im:chat.members:read are honored (same rationale as +chat-list).
|
||||
Scopes: []string{"im:chat.members:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
|
||||
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
|
||||
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
||||
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
||||
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Default fetches a single page; pass --page-all to walk every page.",
|
||||
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
|
||||
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
if chatID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||
}
|
||||
if !strings.HasPrefix(chatID, "oc_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
}
|
||||
if n := runtime.Int("page-delay"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
|
||||
}
|
||||
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
dry := common.NewDryRunAPI()
|
||||
if chatMembersShouldAutoPaginate(runtime) {
|
||||
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||
}
|
||||
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
return dry.
|
||||
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
res, err := fetchChatMembers(ctx, runtime, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The truncation signal is the whole reason this is a dedicated shortcut:
|
||||
// surface it loudly so an agent never mistakes a capped list for a
|
||||
// complete one.
|
||||
if len(res.truncations) > 0 {
|
||||
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"users": res.users,
|
||||
"bots": res.bots,
|
||||
"truncations": res.truncations,
|
||||
"has_more": res.hasMore,
|
||||
"page_token": res.pageToken,
|
||||
}
|
||||
if res.userTotal != nil {
|
||||
outData["user_total"] = res.userTotal
|
||||
}
|
||||
if res.botTotal != nil {
|
||||
outData["bot_total"] = res.botTotal
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
|
||||
renderChatMembersPretty(w, chatID, res)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// chatMembersResult is the aggregated view across one or more pages.
|
||||
type chatMembersResult struct {
|
||||
users []interface{}
|
||||
bots []interface{}
|
||||
truncations []interface{}
|
||||
userTotal interface{}
|
||||
botTotal interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
}
|
||||
|
||||
// effectiveChatMembersPageSize resolves the page_size to request. When draining
|
||||
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
|
||||
// uses the maximum so a full walk takes the fewest round-trips. An explicit
|
||||
// --page-size is always honored; without --page-all the smaller default is kept
|
||||
// as a sensible single-page preview size.
|
||||
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
|
||||
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
|
||||
return chatMembersListMaxPageSize
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
return n
|
||||
}
|
||||
return chatMembersListDefaultPageSize
|
||||
}
|
||||
|
||||
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
|
||||
// every page. An explicit --page-token disables the auto loop because the
|
||||
// caller supplied a specific cursor (single-page fetch).
|
||||
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" {
|
||||
return false
|
||||
}
|
||||
return runtime.Bool("page-all")
|
||||
}
|
||||
|
||||
// buildChatMembersParams builds the query params for one page request. The
|
||||
// startToken (when non-empty) seeds the page_token; the loop overrides it per
|
||||
// page. Returns the params and the normalized member-types CSV (already
|
||||
// validated by Validate, so the error is only a defensive guard).
|
||||
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
|
||||
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"member_id_type": runtime.Str("member-id-type"),
|
||||
"page_size": effectiveChatMembersPageSize(runtime),
|
||||
}
|
||||
if memberTypes != "" {
|
||||
params["member_types"] = memberTypes
|
||||
}
|
||||
if startToken != "" {
|
||||
params["page_token"] = startToken
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// fetchChatMembers walks the list_members endpoint, honoring the four
|
||||
// pagination flags the same way the generic --page-all path does. It merges
|
||||
// each page into the aggregate as it arrives (rather than buffering every raw
|
||||
// page), so peak memory is just the aggregated members plus the single most
|
||||
// recent page — important for large groups under --page-limit 0.
|
||||
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
|
||||
auto := chatMembersShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
pageDelay := runtime.Int("page-delay")
|
||||
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
|
||||
|
||||
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := newChatMembersResult()
|
||||
var lastData map[string]interface{}
|
||||
pageToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
for page := 0; ; page++ {
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addMemberBuckets(res, data)
|
||||
lastData = data
|
||||
|
||||
hasMore, nextToken := common.PaginationMeta(data)
|
||||
if !auto {
|
||||
break
|
||||
}
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
if nextToken == pageToken {
|
||||
// Guard against a buggy server echoing the same cursor with
|
||||
// has_more=true: without --page-limit we would loop forever.
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
|
||||
break
|
||||
}
|
||||
if pageLimit > 0 && page+1 >= pageLimit {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
// Throttle between pages (only reached when another page follows), so
|
||||
// draining a large untruncated list doesn't hammer the API.
|
||||
if pageDelay > 0 {
|
||||
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if lastData != nil {
|
||||
applyLastPageSignals(res, lastData)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
|
||||
// JSON output always carries arrays (never null).
|
||||
func newChatMembersResult() *chatMembersResult {
|
||||
return &chatMembersResult{
|
||||
users: []interface{}{},
|
||||
bots: []interface{}{},
|
||||
truncations: []interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
|
||||
// Concatenating every bucket is what avoids dropping bots[] — the bug the
|
||||
// generic single-array --page-all merger would hit on this multi-bucket shape.
|
||||
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
|
||||
if u, ok := data["users"].([]interface{}); ok {
|
||||
res.users = append(res.users, u...)
|
||||
}
|
||||
if b, ok := data["bots"].([]interface{}); ok {
|
||||
res.bots = append(res.bots, b...)
|
||||
}
|
||||
}
|
||||
|
||||
// applyLastPageSignals copies the per-request signals from the FINAL page:
|
||||
// has_more / page_token / truncations / totals. These must come from the last
|
||||
// page, not page 1: truncations[] is emitted only on the final page (empty
|
||||
// earlier), so reading it sooner would hide a server-side cap; user_total /
|
||||
// bot_total are server-wide counts, and taking the final page's value keeps a
|
||||
// single, consistent source rather than a possibly-stale earlier count.
|
||||
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
|
||||
res.hasMore, res.pageToken = common.PaginationMeta(data)
|
||||
if t, ok := data["truncations"].([]interface{}); ok {
|
||||
res.truncations = t
|
||||
}
|
||||
res.userTotal = data["user_total"]
|
||||
res.botTotal = data["bot_total"]
|
||||
}
|
||||
|
||||
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
|
||||
// the same logic fetchChatMembers applies incrementally, kept as a pure
|
||||
// function so the multi-bucket merge + last-page-signal semantics are unit
|
||||
// tested in one place.
|
||||
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
|
||||
res := newChatMembersResult()
|
||||
if len(pages) == 0 {
|
||||
return res
|
||||
}
|
||||
for _, data := range pages {
|
||||
addMemberBuckets(res, data)
|
||||
}
|
||||
applyLastPageSignals(res, pages[len(pages)-1])
|
||||
return res
|
||||
}
|
||||
|
||||
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
|
||||
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
|
||||
// the API's default of all types). Any element outside {user, bot} is rejected.
|
||||
func normalizeMemberTypes(raw []string) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p != "user" && p != "bot" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, ","), nil
|
||||
}
|
||||
|
||||
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
|
||||
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
|
||||
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
|
||||
}
|
||||
}
|
||||
|
||||
// writeChatMembersTruncationWarning emits a stderr warning for every
|
||||
// server-side bucket cap reported in truncations[]. It uses the repo's plain
|
||||
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
|
||||
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
|
||||
// pipes regardless of terminal encoding.
|
||||
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
|
||||
for _, t := range truncations {
|
||||
tm, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
memberType := valueOrAll(tm["member_type"])
|
||||
limit := tm["limit"]
|
||||
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrAll(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
|
||||
fmt.Fprintf(w, "Chat: %s\n", chatID)
|
||||
// Show the server-wide total next to the fetched count: when truncated or
|
||||
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
|
||||
// what tells the reader how incomplete the list is.
|
||||
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
|
||||
for i, u := range res.users {
|
||||
m, _ := u.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
|
||||
for i, b := range res.bots {
|
||||
m, _ := b.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
if len(res.truncations) > 0 {
|
||||
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
|
||||
}
|
||||
if res.hasMore {
|
||||
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
|
||||
if res.pageToken != "" {
|
||||
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDash(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// totalSuffix renders " of <total>" when the server-reported total exceeds the
|
||||
// number actually fetched (so a truncated/partial bucket is obvious), and ""
|
||||
// when the total is absent or already matches the fetched count.
|
||||
func totalSuffix(total interface{}, fetched int) string {
|
||||
n, ok := toInt(total)
|
||||
if !ok || n <= fetched {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" of %d", n)
|
||||
}
|
||||
|
||||
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
325
shortcuts/im/im_chat_members_list_test.go
Normal file
325
shortcuts/im/im_chat_members_list_test.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// page builds one list_members page payload shaped like the data object the
|
||||
// server returns (users[]/bots[]/truncations[] plus paging + totals).
|
||||
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"users": users,
|
||||
"bots": bots,
|
||||
"truncations": truncations,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
"user_total": 324,
|
||||
"bot_total": 2,
|
||||
}
|
||||
}
|
||||
|
||||
func us(ids ...string) []interface{} {
|
||||
out := make([]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]interface{}{"member_id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
|
||||
// (users AND bots) must be concatenated across pages, not just one of them.
|
||||
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 merged, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 3 {
|
||||
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
|
||||
// is emitted only on the final page, so the merged view must take it from the
|
||||
// last page rather than inherit page 1's empty slice.
|
||||
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
|
||||
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u2"), nil, limit, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.truncations) != 1 {
|
||||
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
|
||||
// signals come from the final page (so a --page-limit cutoff is visible).
|
||||
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), nil, nil, true, "p2"),
|
||||
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true from last page")
|
||||
}
|
||||
if res.pageToken != "p3" {
|
||||
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
|
||||
// are taken from the final page (not an earlier, possibly-different value).
|
||||
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
|
||||
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
|
||||
}
|
||||
res := mergeChatMemberPages(pages)
|
||||
if n, _ := toInt(res.userTotal); n != 324 {
|
||||
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
|
||||
}
|
||||
if n, _ := toInt(res.botTotal); n != 2 {
|
||||
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
|
||||
func TestChatMembersValidate(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
chatID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid oc_", "oc_abc", false},
|
||||
{"empty", "", true},
|
||||
{"missing oc_ prefix", "abc123", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
|
||||
err := ImChatMembersList.Validate(context.Background(), rt)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, c.name, err, "--chat-id")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationError checks err satisfies the repo's typed-error contract for
|
||||
// a validation failure: a *errs.ValidationError carrying the expected Param, and
|
||||
// problem metadata of category validation / subtype invalid_argument.
|
||||
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
|
||||
return
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMemberTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{nil, "", false},
|
||||
{[]string{"user", "bot"}, "user,bot", false},
|
||||
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
|
||||
{[]string{"admin"}, "", true},
|
||||
{[]string{""}, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := normalizeMemberTypes(c.in)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
|
||||
// drain with no explicit size → max; explicit size → honored; single page → default.
|
||||
func TestEffectiveChatMembersPageSize(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
b map[string]bool
|
||||
ints map[string]int
|
||||
want int
|
||||
}{
|
||||
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
|
||||
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
|
||||
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
|
||||
if got := effectiveChatMembersPageSize(rt); got != c.want {
|
||||
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newChatMembersTestRuntime registers the shortcut's flags and returns a
|
||||
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
|
||||
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
runtime := newUserShortcutRuntime(t, rt)
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("chat-id", "", "")
|
||||
cmd.Flags().String("member-id-type", "open_id", "")
|
||||
cmd.Flags().StringSlice("member-types", nil, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
for k, v := range str {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range b {
|
||||
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range ints {
|
||||
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
return runtime
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
|
||||
// fetch loop over mocked pages: users/bots merge across pages and the final
|
||||
// page's truncations[] survives.
|
||||
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
|
||||
calls := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
|
||||
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
|
||||
}
|
||||
calls++
|
||||
token := req.URL.Query().Get("page_token")
|
||||
if token == "" {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("want 2 page calls, got %d", calls)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 2 {
|
||||
t.Errorf("bots: want 2, got %d", len(res.bots))
|
||||
}
|
||||
if len(res.truncations) != 1 {
|
||||
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
|
||||
}
|
||||
if res.hasMore {
|
||||
t.Error("has_more: want false after draining all pages")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
|
||||
// leaves has_more=true so the caller knows the result is incomplete.
|
||||
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
|
||||
seq := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// Every page reports more pages available, with an advancing token so the
|
||||
// loop is stopped by --page-limit, not the non-advancing-token guard.
|
||||
seq++
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
|
||||
}
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true (loop cut short by page-limit)")
|
||||
}
|
||||
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "reached page limit (3)") {
|
||||
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMembersList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// buildContentBlock converts text and mentions to a ContentBlock.
|
||||
func buildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
|
||||
|
||||
// Add text element
|
||||
textElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
}
|
||||
elements = append(elements, textElem)
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range mentions {
|
||||
mentionElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &mention,
|
||||
},
|
||||
}
|
||||
elements = append(elements, mentionElem)
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
content := buildContentBlock(obj.Text, obj.Mention)
|
||||
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
|
||||
|
||||
// createKR calls the API to create a key result.
|
||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
||||
content := buildContentBlock(kr.Text, kr.Mention)
|
||||
content := BuildContentBlock(kr.Text, kr.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Objective creation
|
||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
||||
objContent := BuildContentBlock(obj.Text, obj.Mention)
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
// KR creations
|
||||
for j, kr := range obj.KRs {
|
||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
||||
krContent := BuildContentBlock(kr.Text, kr.Mention)
|
||||
krBody := map[string]interface{}{
|
||||
"content": krContent,
|
||||
}
|
||||
|
||||
@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
|
||||
|
||||
func TestBuildContentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
|
||||
@@ -29,15 +29,10 @@ type RespCategory struct {
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
ID string `json:"id"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
@@ -152,3 +147,145 @@ type RespProgress struct {
|
||||
Content *string `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ========== Simple-style response types (semi-plain text format) ==========
|
||||
|
||||
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespKeyResultSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespObjectiveSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *SemiPlainContent `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespProgressSimple struct {
|
||||
ID string `json:"progress_id"`
|
||||
ModifyTime string `json:"modify_time"`
|
||||
CreateTime *string `json:"create_time,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ToSimple converts KeyResult to RespKeyResultSimple.
|
||||
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResultSimple{
|
||||
ID: k.ID,
|
||||
CreateTime: formatTimestamp(k.CreateTime),
|
||||
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||
Owner: *k.Owner.ToResp(),
|
||||
ObjectiveID: k.ObjectiveID,
|
||||
Position: k.Position,
|
||||
Score: k.Score,
|
||||
Weight: k.Weight,
|
||||
}
|
||||
if k.Deadline != nil {
|
||||
d := formatTimestamp(*k.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = k.Content.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts Objective to RespObjectiveSimple.
|
||||
func (o *Objective) ToSimple() *RespObjectiveSimple {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjectiveSimple{
|
||||
ID: o.ID,
|
||||
CreateTime: formatTimestamp(o.CreateTime),
|
||||
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||
Owner: *o.Owner.ToResp(),
|
||||
CycleID: o.CycleID,
|
||||
Position: o.Position,
|
||||
Score: o.Score,
|
||||
Weight: o.Weight,
|
||||
CategoryID: o.CategoryID,
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
d := formatTimestamp(*o.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = o.Content.ToSemiPlain()
|
||||
result.Notes = o.Notes.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts ProgressV1 to RespProgressSimple.
|
||||
func (p *ProgressV1) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.ModifyTime),
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.Percent,
|
||||
}
|
||||
if p.ProgressRate.Status != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.Status).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Content != nil {
|
||||
resp.Content = p.Content.ToV2().ToSemiPlain()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToSimple converts Progress to RespProgressSimple.
|
||||
func (p *Progress) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
createTime := formatTimestamp(p.CreateTime)
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.UpdateTime),
|
||||
CreateTime: &createTime,
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.ProgressPercent,
|
||||
}
|
||||
if p.ProgressRate.ProgressStatus != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Content = p.Content.ToSemiPlain()
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
@@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
style := runtime.Str("style")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
@@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := map[string]interface{}{"page_size": "100"}
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if style == "simple" {
|
||||
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
obj := &objectives[i]
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
respObj := obj.ToSimple()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToSimple(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
contentText := ""
|
||||
if o.Content != nil {
|
||||
contentText = o.Content.Text
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
notesText := ""
|
||||
if o.Notes != nil {
|
||||
notesText = o.Notes.Text
|
||||
}
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
krText := ""
|
||||
if kr.Content != nil {
|
||||
krText = kr.Content.Text
|
||||
}
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// richtext mode
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
krQuery["page_token"] = pageToken
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
// isCurrentActiveCycle checks whether a cycle is currently active:
|
||||
// - current time is within the cycle's start and end time
|
||||
// - cycle status is default (0) or normal (1)
|
||||
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
nowUTC := now.UTC()
|
||||
|
||||
// Check time range: now must be >= start and <= end
|
||||
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check status: must be default or normal
|
||||
if cycle.CycleStatus == nil {
|
||||
return false
|
||||
}
|
||||
status := *cycle.CycleStatus
|
||||
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
// Filter current active cycles
|
||||
now := time.Now()
|
||||
currentActiveCycles := make([]*RespCycle, 0)
|
||||
for i := range filtered {
|
||||
if isCurrentActiveCycle(&filtered[i], now) {
|
||||
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"current_active_cycles": currentActiveCycles,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
if len(currentActiveCycles) > 0 {
|
||||
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
|
||||
for _, c := range currentActiveCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -5,8 +5,10 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
// Assert current_active_cycles field exists and is a slice
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 0 {
|
||||
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
|
||||
}
|
||||
}
|
||||
|
||||
// --- isCurrentActiveCycle unit tests ---
|
||||
|
||||
func TestIsCurrentActiveCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cycle *Cycle
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "active cycle with normal status",
|
||||
cycle: &Cycle{
|
||||
ID: "c1",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31 23:59:59
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "active cycle with default status",
|
||||
cycle: &Cycle{
|
||||
ID: "c2",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusDefault.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "cycle with invalid status",
|
||||
cycle: &Cycle{
|
||||
ID: "c3",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusInvalid.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cycle with hidden status",
|
||||
cycle: &Cycle{
|
||||
ID: "c4",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusHidden.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "past cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c5",
|
||||
StartTime: "1704067200000", // 2024-01-01
|
||||
EndTime: "1719791999999", // 2024-06-30
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "future cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c6",
|
||||
StartTime: "1830297600000", // 2028-01-01
|
||||
EndTime: "1861833599999", // 2028-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil cycle status",
|
||||
cycle: &Cycle{
|
||||
ID: "c7",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: nil,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid start time",
|
||||
cycle: &Cycle{
|
||||
ID: "c8",
|
||||
StartTime: "invalid",
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "exact start time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c9",
|
||||
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact end time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c10",
|
||||
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
|
||||
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isCurrentActiveCycle(tt.cycle, now)
|
||||
if result != tt.expected {
|
||||
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Calculate timestamps relative to now to avoid test expiration
|
||||
now := time.Now().UTC()
|
||||
// Active cycle: 6 months before to 6 months after now
|
||||
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
|
||||
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
|
||||
// Past cycle: 2 years before to 1.5 years before now
|
||||
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
|
||||
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"id": "cycle-active",
|
||||
"start_time": strconv.FormatInt(activeStartMs, 10),
|
||||
"end_time": strconv.FormatInt(activeEndMs, 10),
|
||||
"cycle_status": 1, // normal
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"id": "cycle-past",
|
||||
"start_time": strconv.FormatInt(pastStartMs, 10),
|
||||
"end_time": strconv.FormatInt(pastEndMs, 10),
|
||||
"cycle_status": 2, // invalid
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
|
||||
// Check current_active_cycles - should only contain cycle-active
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 1 {
|
||||
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
|
||||
}
|
||||
activeCycle, ok := currentActive[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
|
||||
}
|
||||
if activeCycle["id"] != "cycle-active" {
|
||||
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
|
||||
}
|
||||
|
||||
// Verify removed fields are not present in the response
|
||||
for _, c := range cycles {
|
||||
cycleMap, _ := c.(map[string]interface{})
|
||||
if _, ok := cycleMap["create_time"]; ok {
|
||||
t.Fatal("create_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["update_time"]; ok {
|
||||
t.Fatal("update_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["tenant_cycle_id"]; ok {
|
||||
t.Fatal("tenant_cycle_id should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["owner"]; ok {
|
||||
t.Fatal("owner should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["score"]; ok {
|
||||
t.Fatal("score should not be present in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,9 @@ package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
ID: c.ID,
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
@@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SemiPlainContent (半纯文本格式) ==========
|
||||
|
||||
// Regex patterns for semi-plain text processing (pre-compiled for performance).
|
||||
var (
|
||||
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
|
||||
multiSpaceRE = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// SemiPlainDoc represents a document link in semi-plain content.
|
||||
type SemiPlainDoc struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
|
||||
// It contains plain text, mentions, docs, and images without rich formatting or position info.
|
||||
type SemiPlainContent struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
Docs []SemiPlainDoc `json:"docs,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
|
||||
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
|
||||
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
result := &SemiPlainContent{}
|
||||
var textParts []string
|
||||
|
||||
for _, block := range c.Blocks {
|
||||
if block.Paragraph != nil {
|
||||
for _, elem := range block.Paragraph.Elements {
|
||||
switch {
|
||||
case elem.TextRun != nil && elem.TextRun.Text != nil:
|
||||
textParts = append(textParts, *elem.TextRun.Text)
|
||||
case elem.Mention != nil && elem.Mention.UserID != nil:
|
||||
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
|
||||
result.Mention = append(result.Mention, *elem.Mention.UserID)
|
||||
case elem.DocsLink != nil:
|
||||
doc := SemiPlainDoc{}
|
||||
if elem.DocsLink.Title != nil {
|
||||
doc.Title = *elem.DocsLink.Title
|
||||
}
|
||||
if elem.DocsLink.URL != nil {
|
||||
doc.URL = *elem.DocsLink.URL
|
||||
}
|
||||
result.Docs = append(result.Docs, doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if block.Gallery != nil {
|
||||
for _, img := range block.Gallery.Images {
|
||||
if img.Src != nil {
|
||||
result.Images = append(result.Images, *img.Src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Text = strings.Join(textParts, "")
|
||||
return result
|
||||
}
|
||||
|
||||
// ToContentBlock converts SemiPlainContent to ContentBlock.
|
||||
// Text and mentions are placed in a single paragraph (text first, then mentions).
|
||||
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
|
||||
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
|
||||
|
||||
// Strip @{userID} placeholders from text to avoid duplicate mentions
|
||||
// (these placeholders are only for readability in the output format)
|
||||
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
|
||||
// Collapse multiple spaces and trim
|
||||
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
|
||||
strippedText = strings.TrimSpace(strippedText)
|
||||
|
||||
// Add text element if stripped text is not empty
|
||||
if strippedText != "" {
|
||||
text := strippedText
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range s.Mention {
|
||||
m := mention
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &m,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContentBlock converts text and mentions to a ContentBlock.
|
||||
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
|
||||
func BuildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
return (&SemiPlainContent{
|
||||
Text: text,
|
||||
Mention: mentions,
|
||||
}).ToContentBlock()
|
||||
}
|
||||
|
||||
// ProgressRateV1 进度率
|
||||
type ProgressRateV1 struct {
|
||||
Percent *float64 `json:"percent,omitempty"`
|
||||
|
||||
@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
// Verify removed fields are not present in RespCycle
|
||||
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
|
||||
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
|
||||
// boolPtr returns a pointer to the given bool value.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// ========== SemiPlainContent Conversion Tests ==========
|
||||
|
||||
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello world"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Hello world" {
|
||||
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 0 {
|
||||
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
|
||||
}
|
||||
if len(sp.Docs) != 0 {
|
||||
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
|
||||
}
|
||||
if len(sp.Images) != 0 {
|
||||
t.Fatalf("expected 0 images, got %d", len(sp.Images))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: strPtr("ou_123"),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr(", how are you?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
// Text includes @{userID} placeholder to preserve positional context
|
||||
if sp.Text != "Hello @{ou_123} , how are you?" {
|
||||
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
|
||||
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Check out this doc: "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
|
||||
DocsLink: &ContentDocsLink{
|
||||
Title: strPtr("Design Doc"),
|
||||
URL: strPtr("https://example.feishu.cn/docx/xxx"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
BlockElementType: BlockElementTypeGallery.Ptr(),
|
||||
Gallery: &ContentGallery{
|
||||
Images: []ContentImageItem{
|
||||
{
|
||||
Src: strPtr("https://example.com/img1.png"),
|
||||
},
|
||||
{
|
||||
Src: strPtr("https://example.com/img2.png"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Check out this doc: " {
|
||||
t.Fatalf("unexpected text: '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Docs) != 1 {
|
||||
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
|
||||
}
|
||||
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
|
||||
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
|
||||
}
|
||||
if len(sp.Images) != 2 {
|
||||
t.Fatalf("expected 2 images, got %d", len(sp.Images))
|
||||
}
|
||||
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
|
||||
t.Fatalf("unexpected images: %v", sp.Images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var cb *ContentBlock
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp != nil {
|
||||
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Hello world",
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
block := cb.Blocks[0]
|
||||
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
|
||||
t.Fatal("expected paragraph block")
|
||||
}
|
||||
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
|
||||
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
|
||||
}
|
||||
elem := block.Paragraph.Elements[0]
|
||||
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected textRun element")
|
||||
}
|
||||
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
|
||||
t.Fatalf("unexpected text: %v", elem.TextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Please review",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
|
||||
t.Fatal("unexpected first element")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: " ",
|
||||
Mention: []string{"ou_123"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Empty text should be skipped, only mention remains
|
||||
if len(elems) != 1 {
|
||||
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected mention element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Test",
|
||||
Mention: []string{"ou_123"},
|
||||
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
|
||||
Images: []string{"https://img.png"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Docs and images are ignored in input conversion
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Simulate round-trip: output format has @{userID} in text,
|
||||
// input conversion should strip them to avoid duplicate mentions
|
||||
sp := &SemiPlainContent{
|
||||
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
|
||||
Mention: []string{"ou_zhangsan", "ou_lisi"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have 3 elements: 1 text (stripped) + 2 mentions
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
// Text should have placeholders stripped
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected first element to be textRun")
|
||||
}
|
||||
// Note: space before comma is preserved from the placeholder's trailing space
|
||||
expectedText := "任务一 ,任务二"
|
||||
if *elems[0].TextRun.Text != expectedText {
|
||||
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
|
||||
}
|
||||
// Mentions should be preserved as separate elements
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Text that is only placeholders should result in no text element
|
||||
sp := &SemiPlainContent{
|
||||
Text: " @{ou_123} @{ou_456} ",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have only 2 mention elements, no text element
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected first element to be mention")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected second element to be mention")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var sp *SemiPlainContent
|
||||
cb := sp.ToContentBlock()
|
||||
if cb != nil {
|
||||
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentBlock_Conversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements, got %d", len(elems))
|
||||
}
|
||||
if *elems[0].TextRun.Text != "Test text" {
|
||||
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
|
||||
}
|
||||
if *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
|
||||
}
|
||||
if *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSimpleMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test Objective.ToSimple()
|
||||
text := "Objective text"
|
||||
obj := &Objective{
|
||||
ID: "obj-1",
|
||||
Content: BuildContentBlock(text, []string{"ou_123"}),
|
||||
Notes: BuildContentBlock("Note text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
|
||||
CycleID: "cycle-1",
|
||||
Score: float64Ptr(0.7),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1735776000000"),
|
||||
}
|
||||
simpleObj := obj.ToSimple()
|
||||
if simpleObj == nil {
|
||||
t.Fatal("expected non-nil RespObjectiveSimple")
|
||||
}
|
||||
if simpleObj.ID != "obj-1" {
|
||||
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedContentText := "Objective text @{ou_123} "
|
||||
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
|
||||
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
|
||||
}
|
||||
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
|
||||
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
|
||||
}
|
||||
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
|
||||
t.Fatalf("unexpected score: %v", simpleObj.Score)
|
||||
}
|
||||
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
|
||||
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
|
||||
}
|
||||
|
||||
// Test KeyResult.ToSimple()
|
||||
kr := &KeyResult{
|
||||
ID: "kr-1",
|
||||
ObjectiveID: "obj-1",
|
||||
Content: BuildContentBlock("KR text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
|
||||
Score: float64Ptr(0.5),
|
||||
}
|
||||
simpleKR := kr.ToSimple()
|
||||
if simpleKR == nil {
|
||||
t.Fatal("expected non-nil RespKeyResultSimple")
|
||||
}
|
||||
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
|
||||
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
|
||||
}
|
||||
|
||||
// Test ProgressV1.ToSimple()
|
||||
progress := &ProgressV1{
|
||||
ID: "prog-1",
|
||||
ModifyTime: "1735776000000",
|
||||
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
|
||||
}
|
||||
simpleProgress := progress.ToSimple()
|
||||
if simpleProgress == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple")
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedProgressText := "Progress text @{ou_mention} "
|
||||
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
|
||||
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
|
||||
}
|
||||
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
|
||||
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
|
||||
}
|
||||
|
||||
// Test Progress.ToSimple() (V2 progress record)
|
||||
progressV2 := &Progress{
|
||||
ID: "prog-v2-1",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
|
||||
ProgressRate: &ProgressRate{
|
||||
ProgressPercent: float64Ptr(80.0),
|
||||
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
|
||||
},
|
||||
}
|
||||
simpleProgressV2 := progressV2.ToSimple()
|
||||
if simpleProgressV2 == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
|
||||
}
|
||||
if simpleProgressV2.ID != "prog-v2-1" {
|
||||
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
|
||||
}
|
||||
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
|
||||
t.Fatal("expected non-empty CreateTime for Progress V2")
|
||||
}
|
||||
expectedV2Text := "V2 progress text @{ou_v2_mention} "
|
||||
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
|
||||
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
|
||||
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
|
||||
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
|
||||
}
|
||||
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
|
||||
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
// listTypePtr returns a pointer to the given ListType value.
|
||||
func listTypePtr(v ListType) *ListType { return &v }
|
||||
|
||||
311
shortcuts/okr/okr_patch.go
Normal file
311
shortcuts/okr/okr_patch.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// patchParams holds the parsed parameters for the patch operation.
|
||||
type patchParams struct {
|
||||
Level string
|
||||
TargetID string
|
||||
Style string
|
||||
Content *ContentBlock
|
||||
Notes *ContentBlock
|
||||
Score *float64
|
||||
Deadline *string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
|
||||
p := &patchParams{
|
||||
Level: runtime.Str("level"),
|
||||
TargetID: runtime.Str("target-id"),
|
||||
Style: runtime.Str("style"),
|
||||
UserIDType: runtime.Str("user-id-type"),
|
||||
}
|
||||
|
||||
hasField := false
|
||||
|
||||
// Parse content if provided
|
||||
if contentStr := runtime.Str("content"); contentStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
p.Content = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
p.Content = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse notes if provided (only for objective)
|
||||
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
|
||||
}
|
||||
p.Notes = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
p.Notes = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse score if provided
|
||||
if scoreStr := runtime.Str("score"); scoreStr != "" {
|
||||
hasField = true
|
||||
score, err := strconv.ParseFloat(scoreStr, 64)
|
||||
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
|
||||
}
|
||||
if score < 0 || score > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
|
||||
}
|
||||
// Check for exactly one decimal place
|
||||
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
|
||||
parts := strings.Split(scoreStrTrimmed, ".")
|
||||
if len(parts) == 2 && len(parts[1]) > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
|
||||
}
|
||||
// Validation ensures at most one decimal place, so score is already correctly formatted
|
||||
p.Score = &score
|
||||
}
|
||||
|
||||
// Parse deadline if provided
|
||||
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
|
||||
hasField = true
|
||||
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
|
||||
}
|
||||
if deadlineMs <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
|
||||
}
|
||||
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
|
||||
// Anything less than 1e12 is likely seconds or a wrong unit
|
||||
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
|
||||
}
|
||||
p.Deadline = &deadlineStr
|
||||
}
|
||||
|
||||
// At least one field must be provided
|
||||
if !hasField {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// OKRPatch patches an objective or key result.
|
||||
var OKRPatch = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+patch",
|
||||
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
|
||||
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
if targetID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||
}
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
// Delegate content/notes/score/deadline validation to parsePatchParams
|
||||
if _, err := parsePatchParams(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("").
|
||||
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
api := common.NewDryRunAPI()
|
||||
if p.Level == "objective" {
|
||||
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
|
||||
Set("objective_id", p.TargetID)
|
||||
} else {
|
||||
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
|
||||
Set("key_result_id", p.TargetID)
|
||||
}
|
||||
return api.Params(params).Body(body).
|
||||
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
|
||||
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
var path string
|
||||
if p.Level == "objective" {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
|
||||
} else {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"level": p.Level,
|
||||
"target_id": p.TargetID,
|
||||
"patched": map[string]bool{
|
||||
"content": p.Content != nil,
|
||||
"notes": p.Notes != nil,
|
||||
"score": p.Score != nil,
|
||||
"deadline": p.Deadline != nil,
|
||||
},
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
|
||||
if p.Content != nil {
|
||||
fmt.Fprintf(w, " - content: updated\n")
|
||||
}
|
||||
if p.Notes != nil {
|
||||
fmt.Fprintf(w, " - notes: updated\n")
|
||||
}
|
||||
if p.Score != nil {
|
||||
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
1350
shortcuts/okr/okr_patch_test.go
Normal file
1350
shortcuts/okr/okr_patch_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
|
||||
|
||||
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
||||
style := runtime.Str("style")
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
var contentV1 *ContentBlockV1
|
||||
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
// Validate mention IDs are non-empty
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
// Build ContentBlock from semi-plain content (text + mentions)
|
||||
contentV1 = sp.ToContentBlock().ToV1()
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 = cb.ToV1()
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
targetType := runtime.Str("target-type")
|
||||
targetTypeVal := targetTypeAllowed[targetType]
|
||||
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||
{Name: "progress-percent", Desc: "progress percentage"},
|
||||
@@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
|
||||
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate content is valid JSON and can be parsed as ContentBlock
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
// Validate content based on style
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
// If user provided docs or images in simple mode, warn that they are ignored
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user