mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 21:37:33 +08:00
## Summary
Adds a side-effect-free DataFlow canvas **debug (dry-run) mode** plus a
**debug run log with a "View result" panel**, so a canvas can be
executed synchronously and inspected end-to-end (per-component progress
and parsed chunks) without persisting anything.
### Dry-run execution (inline parsed chunks)
- `task/debug.go`: `NewDebugTaskContext` builds an in-memory
`TaskContext` with **`KB.ID == ""` — the single debug signal used across
the ingestion pipeline**. A canvas debug run has no knowledgebase, and
production ingestion always supplies one, so `kb_id == ""` occurs ONLY
in debug mode. Components gate their own side effects on this signal
without any dedicated debug vocabulary (the former `CANVAS_DEBUG_DOC_ID`
marker constant is removed).
- `task/pipeline_executor.go`: `validateTaskContext` no longer requires
a KB when `KB.ID == ""` (debug); debug runs return `collectDebugOutput`
(chunks) instead of a no-op; uploaded bytes are delivered as
`inputs['binary']` for doc-less runs; `injectDebugPageCap` caps the
parser to the first pages for a fast preview via the production
`override_params` channel (Parser cpnID + family).
- `component/tokenizer.go`: `shouldHaveEmbedding` skips embedding when
`kb_id == ""` — the embedder is configured on the knowledgebase, so a
debug run has nothing to resolve against and stays side-effect free.
- `chunker/register.go`: chunk images are uploaded to MinIO only when a
KB is present (persist run). **In debug mode the raw image bytes are
intentionally dropped (`delete(ck, "image")`)** — the debug preview does
not render chunk images, and dropping the bytes keeps them out of memory
and out of the Redis-stored debug log. This is a deliberate trade-off,
not an oversight.
- `component/file.go`: pass through in-memory binary bytes, skipping
`doc_id` -> storage resolution.
- `handler/agent.go` + `agent_webhook.go`: detect `dataflow_canvas` and
run a sync debug returning chunks inline on the existing
chat/completions endpoint; reject DataFlow canvases from webhooks (fixes
the previously dead `== "DataFlow"` check; mirrors Python
`agent_api.py`).
- `parser_dispatch.go`: export `ParserFileFamily` for the executor's
page-cap injection.
### Debug run log + "View result"
Mirrors Python's debug-log contract so the front-end can replay each
component's progress and parsed output:
- `task/debug_log_sink.go`: a `DebugLogSink` records every component's
lifecycle into a `[{component_id, trace}]` array (each trace entry
carries `message`, `progress`, `timestamp`, `elapsed_time`). `Flush`
appends a terminal `END` marker whose first trace message is non-empty
so the front-end detects completion. On failure the END marker is
prefixed `[ERROR]` yet still carries the run, so the failure timeline
renders instead of being stuck empty. Timestamps and `elapsed_time` are
in seconds (matching the rest of the app).
- `task/debug_result_dsl.go`: `BuildDebugResultDSL` builds the `dsl` the
END marker carries — the Go analogue of Python's `Graph.__str__` +
END-marker `dsl` in `rag/flow/pipeline.py`. It combines the static DSL
structure (component_name / downstream / params / graph.nodes) with the
run output map (`output["state"][<id>]`) to emit, per component,
`obj.params.outputs[<format>].value` (chunks / text / json / html /
markdown) — the exact keys the front-end `dataflow-result` page reads to
render each step's parsed chunks. Raw embedding vectors (including the
dimension-scoped `q_<dim>_vec` keys) are stripped so the stored log
stays Python-scale.
- `task/pipeline_executor.go`: after the run, attach the built `dsl` to
the END marker via the `ResultSink` capability.
- `handler/agent.go`: `runCanvasPipelineDebug` generates a stable
`message_id` up-front and always flushes the log (success or failure);
`respondWithDebugResult` returns `message_id` in **both** the success
and the error envelope so the front-end can poll the log. The debug-log
endpoint `GET /agents/:id/logs/:message_id` serves the array.
- `web/src/pages/agent/hooks/use-run-dataflow.ts`: on a run failure,
also surface `message_id` via `setMessageId` so the log sheet renders
the failure timeline (the `[ERROR]` END marker is already written).
Guarded by `if (msgId)`, so it is a safe no-op when the back-end does
not return an id.
## Behavioral notes
- Debug parses only the first pages (`debugPageCapPages`) for a fast
preview; an explicit `pages` cap already present in the ParserConfig is
respected.
- Debug mode does not keep chunk images (see above) and does not compute
embeddings — it exercises parse + chunk only.
## Test plan
- Go: `debug_test.go`, `debug_log_sink_test.go` (trace pairing, END
marker, `[ERROR]` prefix, fractional-second timestamp/elapsed_time, size
caps, and a real-pipeline test asserting the END-marker `dsl` carries
non-empty per-component `params.outputs` with chunks),
`debug_result_dsl_test.go` (flat and real nested `output["state"]`
shapes, vector stripping, format priority),
`debug_pages_integration_test.go`, `pipeline_executor_persist_test.go`,
`handler/agent_pipeline_debug_test.go`, `handler/agent_logs_test.go`
(incl. `TestRunCanvasPipelineDebug_ErrorStillExposesMessageID` /
`TestRespondWithDebugResult_ErrorCarriesMessageID` locking `message_id`
on failure), plus updates to `agent_test.go` / `agent_webhook_test.go` /
`chunker/image_upload_test.go` / `tokenizer*.go`.
- `go build ./...` and `./build.sh --test` for affected packages.
🤖 Generated with [CodeBuddy Code](https://cnb.cool/codebuddy)
---------
Co-authored-by: CodeBuddy Code <noreply@tencent.com>
431 lines
16 KiB
Go
431 lines
16 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package task
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// TestBuildDebugResultDSL locks the contract that the Go debug log's END-marker
|
|
// `dsl` must satisfy so the front-end "View result" page renders parsed chunks.
|
|
//
|
|
// The shape is the faithful Go analogue of Python's `Graph.__str__`
|
|
// (agent/canvas.py:119) + the END-marker `dsl` attachment (rag/flow/pipeline.py:98):
|
|
// for every component in the DSL we emit `dsl.components[<id>].obj` carrying
|
|
// `component_name` and `params.outputs[<format>].value`, plus `downstream` and
|
|
// `graph.nodes[].data.name`. The front-end reads exactly these keys
|
|
// (web/src/pages/dataflow-result/parser.tsx:41, hooks.ts:215).
|
|
func TestBuildDebugResultDSL(t *testing.T) {
|
|
const (
|
|
compA = "my.Parser"
|
|
compB = "my.Tokenizer"
|
|
)
|
|
dsl := `{
|
|
"dsl": {
|
|
"components": {
|
|
"begin": {"obj": {"component_name": "Begin", "params": {}}, "downstream": ["a"]},
|
|
"a": {"obj": {"component_name": "` + compA + `", "params": {"setups": {"pdf": {"parse_method": "general"}}}}, "upstream": ["begin"], "downstream": ["b"]},
|
|
"b": {"obj": {"component_name": "` + compB + `", "params": {"field_name": "content"}}, "upstream": ["a"]}
|
|
},
|
|
"graph": {"nodes": [
|
|
{"id": "begin", "data": {"name": "开始"}},
|
|
{"id": "a", "data": {"name": "解析"}},
|
|
{"id": "b", "data": {"name": "分词"}}
|
|
]}
|
|
}
|
|
}`
|
|
|
|
// Run output: keyed by component id (state.go:284 `out[ref]=v`). `a` emits
|
|
// chunks, `b` emits plain text, `begin` has no recognized output key.
|
|
output := map[string]any{
|
|
"a": map[string]any{
|
|
"chunks": []any{
|
|
map[string]any{"text": "hello", "vector": []float64{0.1, 0.2}},
|
|
},
|
|
},
|
|
"b": map[string]any{"text": "plain"},
|
|
}
|
|
|
|
result, err := BuildDebugResultDSL(dsl, output)
|
|
if err != nil {
|
|
t.Fatalf("BuildDebugResultDSL: %v", err)
|
|
}
|
|
|
|
components, ok := result["components"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("result.components missing or wrong type: %#v", result["components"])
|
|
}
|
|
if len(components) != 3 {
|
|
t.Fatalf("want 3 components (begin,a,b), got %d: %#v", len(components), components)
|
|
}
|
|
|
|
// --- component "a": must carry chunks under params.outputs ---
|
|
a, ok := components["a"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("components.a wrong type: %#v", components["a"])
|
|
}
|
|
aObj, ok := a["obj"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("components.a.obj wrong type: %#v", a["obj"])
|
|
}
|
|
if name, _ := aObj["component_name"].(string); name != compA {
|
|
t.Errorf("components.a.obj.component_name=%v want %q", name, compA)
|
|
}
|
|
// downstream preserved
|
|
if down, _ := a["downstream"].(string); down != "b" {
|
|
// accept []any form too
|
|
if dl, _ := a["downstream"].([]any); len(dl) != 1 || dl[0] != "b" {
|
|
t.Errorf("components.a.downstream=%v want [b]", a["downstream"])
|
|
}
|
|
}
|
|
aParams, ok := aObj["params"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("components.a.obj.params wrong type: %#v", aObj["params"])
|
|
}
|
|
aOutputs, ok := aParams["outputs"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("components.a.obj.params.outputs wrong type: %#v", aParams["outputs"])
|
|
}
|
|
// output_format selects "chunks"
|
|
of, ok := aOutputs["output_format"].(map[string]any)
|
|
if !ok || of["value"] != "chunks" {
|
|
t.Errorf("components.a output_format=%#v want {value:\"chunks\"}", aOutputs["output_format"])
|
|
}
|
|
chunksOut, ok := aOutputs["chunks"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("components.a outputs.chunks wrong type: %#v", aOutputs["chunks"])
|
|
}
|
|
chunksVal, _ := chunksOut["value"].([]any)
|
|
if len(chunksVal) != 1 {
|
|
t.Fatalf("components.a outputs.chunks.value len=%d want 1", len(chunksVal))
|
|
}
|
|
chunk0, _ := chunksVal[0].(map[string]any)
|
|
if chunk0["text"] != "hello" {
|
|
t.Errorf("chunk text=%v want hello", chunk0["text"])
|
|
}
|
|
// VECTOR STRIPPING: Python's serialized obj excludes raw vectors; verify gone.
|
|
if _, exists := chunk0["vector"]; exists {
|
|
t.Errorf("vector key must be stripped from chunk payload, but it is present: %#v", chunk0)
|
|
}
|
|
// static params (setups) carried through, matching Python's cpn["params"] copy.
|
|
if setups, _ := aParams["setups"].(map[string]any); setups == nil {
|
|
t.Errorf("static params.setups must be carried from DSL, got %#v", aParams)
|
|
}
|
|
|
|
// --- component "b": plain text format ---
|
|
b, _ := components["b"].(map[string]any)
|
|
bObj, _ := b["obj"].(map[string]any)
|
|
bParams, _ := bObj["params"].(map[string]any)
|
|
bOutputs, _ := bParams["outputs"].(map[string]any)
|
|
if bOutputs["output_format"].(map[string]any)["value"] != "text" {
|
|
t.Errorf("components.b output_format=%#v want {value:\"text\"}", bOutputs["output_format"])
|
|
}
|
|
// static params.field_name carried (used by front-end chunk text key).
|
|
if fn, _ := bParams["field_name"].(string); fn != "content" {
|
|
t.Errorf("components.b params.field_name=%v want content", fn)
|
|
}
|
|
|
|
// --- component "begin": no recognized output -> outputs empty, safe ---
|
|
begin, _ := components["begin"].(map[string]any)
|
|
beginObj, _ := begin["obj"].(map[string]any)
|
|
beginParams, _ := beginObj["params"].(map[string]any)
|
|
if _, exists := beginParams["outputs"]; exists {
|
|
t.Errorf("begin has no run output, outputs must be absent: %#v", beginParams)
|
|
}
|
|
|
|
// --- graph.nodes copied through ---
|
|
graph, ok := result["graph"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("result.graph missing: %#v", result["graph"])
|
|
}
|
|
nodes, _ := graph["nodes"].([]any)
|
|
if len(nodes) != 3 {
|
|
t.Fatalf("graph.nodes len=%d want 3", len(nodes))
|
|
}
|
|
}
|
|
|
|
// TestBuildDebugResultDSL_NestedState locks the REAL pipeline output shape.
|
|
//
|
|
// Unlike TestBuildDebugResultDSL (which uses a flat keyed-by-id map for
|
|
// readability), the production `pipe.Run` return value nests each component's
|
|
// outputs under output["state"][<componentID>] — see finalizeResult
|
|
// (pipeline.go:636, `merged["state"] = runState.Snapshot()`) and
|
|
// CanvasState.Snapshot() (state.go:296), which returns
|
|
// map[string]map[string]any keyed by cpn id. statePost (scheduler.go:229)
|
|
// writes each component's top-level output keys there via SetVar(cpnID, k, v).
|
|
//
|
|
// A prior bug read output[id] at the TOP level, which is always nil for the
|
|
// real run, so every component's params.outputs stayed empty and the front-end
|
|
// "View result" rendered blank tabs. This test pins the nested lookup so that
|
|
// regression cannot recur.
|
|
func TestBuildDebugResultDSL_NestedState(t *testing.T) {
|
|
const (
|
|
compA = "my.Parser"
|
|
compB = "my.Tokenizer"
|
|
)
|
|
dsl := `{
|
|
"dsl": {
|
|
"components": {
|
|
"begin": {"obj": {"component_name": "Begin", "params": {}}, "downstream": ["a"]},
|
|
"a": {"obj": {"component_name": "` + compA + `", "params": {"setups": {"pdf": {"parse_method": "general"}}}}, "upstream": ["begin"], "downstream": ["b"]},
|
|
"b": {"obj": {"component_name": "` + compB + `", "params": {"field_name": "content"}}, "upstream": ["a"]}
|
|
},
|
|
"graph": {"nodes": [
|
|
{"id": "begin", "data": {"name": "开始"}},
|
|
{"id": "a", "data": {"name": "解析"}},
|
|
{"id": "b", "data": {"name": "分词"}}
|
|
]}
|
|
}
|
|
}`
|
|
|
|
// REAL shape: Snapshot() returns map[string]map[string]any (state.go:296),
|
|
// so state is typed map[string]map[string]any at runtime — NOT
|
|
// map[string]any. Using the real type here is what makes this test a true
|
|
// regression guard: a single-type assertion in lookupComponentOutput would
|
|
// pass a map[string]any-shaped test yet fail the production output.
|
|
output := map[string]any{
|
|
"state": map[string]map[string]any{
|
|
"a": {
|
|
"chunks": []any{
|
|
map[string]any{"text": "hello", "vector": []float64{0.1, 0.2}},
|
|
},
|
|
},
|
|
"b": {"text": "plain"},
|
|
},
|
|
}
|
|
|
|
result, err := BuildDebugResultDSL(dsl, output)
|
|
if err != nil {
|
|
t.Fatalf("BuildDebugResultDSL: %v", err)
|
|
}
|
|
components, ok := result["components"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("result.components missing: %#v", result["components"])
|
|
}
|
|
|
|
// "a" must carry chunks under params.outputs even though the run output is
|
|
// nested under output["state"]["a"].
|
|
aObj := components["a"].(map[string]any)["obj"].(map[string]any)
|
|
aParams := aObj["params"].(map[string]any)
|
|
aOutputs, ok := aParams["outputs"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("NESTED-SHAPE REGRESSION: components.a params.outputs missing; "+
|
|
"BuildDebugResultDSL must read output[\"state\"][id], got params=%#v", aParams)
|
|
}
|
|
if of, _ := aOutputs["output_format"].(map[string]any); of["value"] != "chunks" {
|
|
t.Errorf("components.a output_format=%#v want {value:\"chunks\"}", aOutputs["output_format"])
|
|
}
|
|
chunksVal := aOutputs["chunks"].(map[string]any)["value"].([]any)
|
|
if len(chunksVal) != 1 {
|
|
t.Fatalf("components.a outputs.chunks.value len=%d want 1", len(chunksVal))
|
|
}
|
|
chunk0 := chunksVal[0].(map[string]any)
|
|
if chunk0["text"] != "hello" {
|
|
t.Errorf("chunk text=%v want hello", chunk0["text"])
|
|
}
|
|
if _, exists := chunk0["vector"]; exists {
|
|
t.Errorf("vector key must be stripped, but present: %#v", chunk0)
|
|
}
|
|
|
|
// "b": plain text nested under output["state"]["b"].
|
|
bParams := components["b"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)
|
|
bOutputs, ok := bParams["outputs"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("NESTED-SHAPE REGRESSION: components.b params.outputs missing; "+
|
|
"got params=%#v", bParams)
|
|
}
|
|
if of, _ := bOutputs["output_format"].(map[string]any); of["value"] != "text" {
|
|
t.Errorf("components.b output_format=%#v want {value:\"text\"}", bOutputs["output_format"])
|
|
}
|
|
}
|
|
|
|
// TestLookupComponentOutput locks the resolution rules of
|
|
// lookupComponentOutput: the production run output nests each component under
|
|
// output["state"][<id>] (finalizeResult + Snapshot), so that is the primary
|
|
// lookup; a flat keyed-by-id shape at the top level is the documented fallback
|
|
// for tests and any non-Snapshot caller. These branches are defensive and must
|
|
// keep behaving if the run-output envelope changes.
|
|
func TestLookupComponentOutput(t *testing.T) {
|
|
const id = "a"
|
|
|
|
// 1. nested present -> returns the nested value (primary path).
|
|
out1 := map[string]any{"state": map[string]any{id: map[string]any{"chunks": "x"}}}
|
|
if got := lookupComponentOutput(out1, id); got == nil {
|
|
t.Errorf("case1: nested present, want non-nil, got nil")
|
|
}
|
|
|
|
// 2. nested present but NOT a map -> must fall back to top-level.
|
|
out2 := map[string]any{"state": "not-a-map", id: map[string]any{"text": "y"}}
|
|
if got := lookupComponentOutput(out2, id); got == nil {
|
|
t.Errorf("case2: state wrong type, must fall back to top-level, got nil")
|
|
} else if m, _ := got.(map[string]any); m["text"] != "y" {
|
|
t.Errorf("case2: fallback got %#v want {text:\"y\"}", got)
|
|
}
|
|
|
|
// 3. nested present, id missing in state but present at top-level -> fallback.
|
|
out3 := map[string]any{"state": map[string]any{"other": map[string]any{}}, id: map[string]any{"text": "z"}}
|
|
if got := lookupComponentOutput(out3, id); got == nil {
|
|
t.Errorf("case3: id missing in state, must fall back to top-level, got nil")
|
|
} else if m, _ := got.(map[string]any); m["text"] != "z" {
|
|
t.Errorf("case3: fallback got %#v want {text:\"z\"}", got)
|
|
}
|
|
|
|
// 4. nested present, id missing everywhere -> nil (safe empty).
|
|
out4 := map[string]any{"state": map[string]any{"other": map[string]any{}}}
|
|
if got := lookupComponentOutput(out4, id); got != nil {
|
|
t.Errorf("case4: id missing everywhere, want nil, got %#v", got)
|
|
}
|
|
|
|
// 5. no "state" key at all -> flat top-level lookup.
|
|
out5 := map[string]any{id: map[string]any{"json": "j"}}
|
|
if got := lookupComponentOutput(out5, id); got == nil {
|
|
t.Errorf("case5: flat-only, want non-nil, got nil")
|
|
}
|
|
}
|
|
|
|
// TestDeepCopyStrip_StripsVectorsFromMapSlice locks the regression for review
|
|
// comment #5: real component chunk output is []map[string]any (as produced by
|
|
// ChunkDocsToMaps), which the type switch MUST recurse into AND strip vectorKeys
|
|
// from. Previously []map[string]any fell into the default branch — no recursion,
|
|
// no stripping — so embedding vectors leaked into the Redis debug log and the
|
|
// copy shared mutable state with the source.
|
|
func TestDeepCopyStrip_StripsVectorsFromMapSlice(t *testing.T) {
|
|
src := []map[string]any{
|
|
{
|
|
"text": "real chunk",
|
|
"vector": []float64{0.1, 0.2},
|
|
"embedding": []float64{0.3},
|
|
"q_1024_vec": []float64{0.7},
|
|
"nested": map[string]any{"q_vec": []float64{0.9}, "q_4_vec": []float64{0.8}, "keep": "x"},
|
|
},
|
|
{"text": "second", "feature": []float64{0.4}},
|
|
}
|
|
|
|
got := deepCopyStrip(src)
|
|
|
|
// Returned slice must be a deep copy (new []any holding new maps), not the
|
|
// original slice/map identity.
|
|
cp, ok := got.([]any)
|
|
if !ok {
|
|
t.Fatalf("deepCopyStrip returned %T, want []any", got)
|
|
}
|
|
if len(cp) != 2 {
|
|
t.Fatalf("len=%d want 2", len(cp))
|
|
}
|
|
|
|
// First chunk: vector & embedding dropped, text kept, nested vector dropped.
|
|
c0, ok := cp[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("element 0 type %T, want map[string]any", cp[0])
|
|
}
|
|
if _, exists := c0["vector"]; exists {
|
|
t.Errorf("vector must be stripped, but present: %#v", c0)
|
|
}
|
|
if _, exists := c0["embedding"]; exists {
|
|
t.Errorf("embedding must be stripped, but present: %#v", c0)
|
|
}
|
|
if _, exists := c0["q_1024_vec"]; exists {
|
|
t.Errorf("q_1024_vec (dimension-scoped vector key) must be stripped, but present: %#v", c0)
|
|
}
|
|
if c0["text"] != "real chunk" {
|
|
t.Errorf("text=%v want 'real chunk'", c0["text"])
|
|
}
|
|
nested, _ := c0["nested"].(map[string]any)
|
|
if _, exists := nested["q_vec"]; exists {
|
|
t.Errorf("nested q_vec must be stripped, but present: %#v", nested)
|
|
}
|
|
if _, exists := nested["q_4_vec"]; exists {
|
|
t.Errorf("nested q_4_vec (dimension-scoped vector key) must be stripped, but present: %#v", nested)
|
|
}
|
|
if nested["keep"] != "x" {
|
|
t.Errorf("nested.keep=%v want x", nested["keep"])
|
|
}
|
|
|
|
// Second chunk: feature (a vectorKey) stripped, text kept.
|
|
c1, ok := cp[1].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("element 1 type %T, want map[string]any", cp[1])
|
|
}
|
|
if _, exists := c1["feature"]; exists {
|
|
t.Errorf("feature must be stripped, but present: %#v", c1)
|
|
}
|
|
if c1["text"] != "second" {
|
|
t.Errorf("text=%v want 'second'", c1["text"])
|
|
}
|
|
|
|
// Mutation isolation: mutating the copy must not touch the source.
|
|
c0["text"] = "mutated"
|
|
if src[0]["text"] != "real chunk" {
|
|
t.Errorf("copy mutation leaked into source: %#v", src[0])
|
|
}
|
|
}
|
|
|
|
// TestDeepCopy_MapSliceIsDeep locks that deepCopy also recurses []map[string]any
|
|
// (structure preservation, no vector-strip concern for plain deepCopy).
|
|
func TestDeepCopy_MapSliceIsDeep(t *testing.T) {
|
|
src := []map[string]any{{"text": "a", "n": map[string]any{"v": 1}}}
|
|
got := deepCopy(src)
|
|
cp, ok := got.([]any)
|
|
if !ok {
|
|
t.Fatalf("deepCopy returned %T, want []any", got)
|
|
}
|
|
m, ok := cp[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("element type %T, want map[string]any", cp[0])
|
|
}
|
|
if m["text"] != "a" {
|
|
t.Errorf("text=%v want a", m["text"])
|
|
}
|
|
if n, _ := m["n"].(map[string]any); n["v"] != 1 {
|
|
t.Errorf("nested not copied: %#v", m["n"])
|
|
}
|
|
// isolate
|
|
m["text"] = "mut"
|
|
if src[0]["text"] != "a" {
|
|
t.Errorf("deepCopy shares mutable state: %#v", src[0])
|
|
}
|
|
}
|
|
|
|
// TestDetectFormat_Priority locks the format selection order
|
|
// (chunks > json > text > html > markdown) used to pick a component's payload
|
|
// key. A component that emits multiple recognized keys must surface the
|
|
// highest-priority one, matching NormalizeChunks and the front-end tab render.
|
|
func TestDetectFormat_Priority(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in map[string]any
|
|
want string
|
|
}{
|
|
{"chunks beats text", map[string]any{"text": "t", "chunks": "c"}, "chunks"},
|
|
{"json beats text", map[string]any{"text": "t", "json": "j"}, "json"},
|
|
{"text beats html", map[string]any{"html": "h", "text": "t"}, "text"},
|
|
{"html beats markdown", map[string]any{"markdown": "m", "html": "h"}, "html"},
|
|
{"markdown alone", map[string]any{"markdown": "m"}, "markdown"},
|
|
{"only unrecognized -> empty", map[string]any{"ok": true}, ""},
|
|
{"empty map -> empty", map[string]any{}, ""},
|
|
{"nil -> empty", nil, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, _ := detectFormat(c.in)
|
|
if got != c.want {
|
|
t.Errorf("detectFormat(%#v) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|