#!/bin/bash # Regression: graph analysis helper should compute weights, source omission, and insights deterministically set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" HELPER="$REPO_ROOT/scripts/graph-analysis.js" fail() { echo "FAIL: $1" >&2 exit 1 } assert_text_contains() { local text="$1" local expected="$2" if ! printf '%s' "$text" | grep -F -- "$expected" > /dev/null; then fail "Expected output to contain: $expected" fi } test_helper_computes_weights_and_source_omission() { local tmp_dir output tmp_dir="$(mktemp -d)" mkdir -p "$tmp_dir/wiki/entities" cat > "$tmp_dir/wiki/entities/A.md" <<'EOF' --- sources: ["same.pdf"] --- # A [[B]] EOF cat > "$tmp_dir/wiki/entities/B.md" <<'EOF' --- sources: ["same.pdf"] --- # B [[A]] EOF cat > "$tmp_dir/wiki/entities/C.md" <<'EOF' # C [[A]] EOF cat > "$tmp_dir/nodes.json" < "$tmp_dir/edges.json" <<'EOF' [ {"id":"e1","from":"A","to":"B","type":"EXTRACTED"}, {"id":"e2","from":"B","to":"A","type":"EXTRACTED"}, {"id":"e3","from":"C","to":"A","type":"EXTRACTED"} ] EOF node "$HELPER" "$tmp_dir/nodes.json" "$tmp_dir/edges.json" "$tmp_dir/out.json" 0 500 250 1000 > /dev/null output="$(jq -r '.edges[] | select(.from == "A" and .to == "B") | [.weight, .source_signal_available, .signals.source_overlap, .signals.type_affinity] | @tsv' "$tmp_dir/out.json")" [ "$output" = $'0.667 true 1 1' ] || fail "Unexpected A-B edge metrics: $output" output="$(jq -r '.edges[] | select(.from == "C" and .to == "A") | [.weight, .source_signal_available, (.signals.source_overlap // "null"), .signals.type_affinity] | @tsv' "$tmp_dir/out.json")" [ "$output" = $'0.5 false null 1' ] || fail "Expected missing sources to be omitted from denominator, got: $output" rm -rf "$tmp_dir" } test_single_node_graph_louvain_returns_safely() { local output output="$(node - <<'NODE' "$HELPER" const helper = require(process.argv[2]); const result = helper.runLouvain(["Solo"], new Map()); console.log(JSON.stringify(Object.fromEntries(result))); NODE )" [ "$output" = '{"Solo":"Solo"}' ] || fail "Expected single-node Louvain to return solo assignment, got: $output" } test_parse_sources_yaml_multiline() { local output output="$(node - <<'NODE' "$HELPER" const helper = require(process.argv[2]); const fm = [ "title: Test", "sources:", " - paper_a.pdf", " - paper_b.pdf", " - \"quoted.pdf\"" ].join("\n"); const result = helper.parseSourcesFrontmatter(fm); console.log(JSON.stringify(result)); NODE )" local has_field parsed signal sources has_field="$(printf '%s' "$output" | jq -r '.hasField')" parsed="$(printf '%s' "$output" | jq -r '.parsed')" signal="$(printf '%s' "$output" | jq -r '.signalAvailable')" sources="$(printf '%s' "$output" | jq -r '.sources | join(",")')" [ "$has_field" = "true" ] || fail "Expected hasField=true for YAML multiline, got: $has_field" [ "$parsed" = "true" ] || fail "Expected parsed=true for YAML multiline, got: $parsed" [ "$signal" = "true" ] || fail "Expected signalAvailable=true, got: $signal" [ "$sources" = "paper_a.pdf,paper_b.pdf,quoted.pdf" ] || fail "Unexpected sources: $sources" } test_node_helper_bad_json_exits_with_error() { local tmp_dir output tmp_dir="$(mktemp -d)" printf 'not json' > "$tmp_dir/bad.json" printf '[]' > "$tmp_dir/edges.json" if output="$(node "$HELPER" "$tmp_dir/bad.json" "$tmp_dir/edges.json" "$tmp_dir/out.json" 0 500 250 1000 2>&1)"; then fail "graph-analysis.js should fail on invalid JSON input" fi assert_text_contains "$output" "Invalid JSON" rm -rf "$tmp_dir" } test_helper_preserves_node_source_path() { local tmp_dir output tmp_dir="$(mktemp -d)" mkdir -p "$tmp_dir/wiki/synthesis/sessions" cat > "$tmp_dir/wiki/synthesis/sessions/A.md" <<'EOF' # A 正文。 EOF cat > "$tmp_dir/nodes.json" < "$tmp_dir/edges.json" <<'EOF' [] EOF node "$HELPER" "$tmp_dir/nodes.json" "$tmp_dir/edges.json" "$tmp_dir/out.json" 0 500 250 1000 > /dev/null output="$(jq -r '.nodes[] | select(.id == "A") | .source_path // ""' "$tmp_dir/out.json")" [ "$output" = "$tmp_dir/wiki/synthesis/sessions/A.md" ] || fail "Expected source_path to survive graph analysis, got: $output" rm -rf "$tmp_dir" } test_helper_reports_sparse_and_bridge_insights() { local output output="$(node - <<'NODE' "$HELPER" const helper = require(process.argv[2]); const nodesById = { 's-topic': { id: 's-topic', label: 'Sparse Topic' }, 's1': { id: 's1', label: 'S1' }, 's2': { id: 's2', label: 'S2' }, 's3': { id: 's3', label: 'S3' }, 's4': { id: 's4', label: 'S4' }, 's5': { id: 's5', label: 'S5' }, 'bridge': { id: 'bridge', label: 'Bridge' }, 't2': { id: 't2', label: 'T2' }, 'b1': { id: 'b1', label: 'B1' }, 't3': { id: 't3', label: 'T3' }, 'c1': { id: 'c1', label: 'C1' } }; const edges = [ { from: 's-topic', to: 's1', type: 'EXTRACTED' }, { from: 's-topic', to: 's2', type: 'EXTRACTED' }, { from: 'bridge', to: 's-topic', type: 'EXTRACTED' }, { from: 'bridge', to: 't2', type: 'EXTRACTED' }, { from: 'bridge', to: 't3', type: 'EXTRACTED' }, { from: 't2', to: 'b1', type: 'EXTRACTED' }, { from: 't3', to: 'c1', type: 'EXTRACTED' } ]; const pairMetrics = new Map([ ['bridge\ts-topic', { weight: 0.8 }], ['bridge\tt2', { weight: 0.8 }], ['bridge\tt3', { weight: 0.8 }] ]); const communityAssignments = new Map([ ['s-topic', 's-topic'], ['s1', 's-topic'], ['s2', 's-topic'], ['s3', 's-topic'], ['s4', 's-topic'], ['s5', 's-topic'], ['bridge', 'bridge'], ['t2', 't2'], ['b1', 't2'], ['t3', 't3'], ['c1', 't3'] ]); const insights = helper.buildInsights(nodesById, edges, pairMetrics, communityAssignments, { nodeCount: Object.keys(nodesById).length, edgeCount: edges.length, maxInsightNodes: 250, maxInsightEdges: 1000 }); console.log(JSON.stringify(insights)); NODE )" local bridge sparse bridge="$(printf '%s' "$output" | jq -r '.bridge_nodes[]?.id')" assert_text_contains "$bridge" "bridge" sparse="$(printf '%s' "$output" | jq -r '.sparse_communities[]?.id')" assert_text_contains "$sparse" "s-topic" } main() { test_helper_computes_weights_and_source_omission test_single_node_graph_louvain_returns_safely test_parse_sources_yaml_multiline test_node_helper_bad_json_exits_with_error test_helper_preserves_node_source_path test_helper_reports_sparse_and_bridge_insights echo "PASS: graph analysis helper regression coverage" } main "$@"