// // 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[].obj` carrying // `component_name` and `params.outputs[].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"][] — 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"][] (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) } }) } }