Files
arcrun-collector/direct_extract_test.go
T
Leo b9d880413d feat(daemon-beta t14/t15/t17): supervisor log 檔+removed 清本地卡+托盤建新資料夾
- t14 supervisor:子行程 stdout(tee)/stderr 全落 ~/.arcrun-rag/collector.log,
  >5MB 改名 .old 重開(兩代輪替);落 log 失敗一律吞掉不擋看守。新增 2 測試
  (輸出進檔+輪替觸發)。
- t15 direct removed(extractor 模式):雲端 takedown 2xx 後同步刪本地
  system-dev/wiki/cards/<頁名>.md(存在才刪;刪失敗只記 warning 不擋)。
  新增 2 測試(刪原檔→本地卡被清/卡已不在照常下架)。
- t17 arcrun-tray:tray 選單+設定視窗加「建立新資料夾並看守…」——
  widget.Entry 輸入名稱→ ~/ 底下 mkdir(已存在沿用;擋路徑分隔與 ..)→
  addWatchFolder+saveConfig+重啟看守+rebuildTray。

collector: go vet ✓、go test -count=1 ./... 43/43 綠;arcrun-tray: go build ✓(產物已刪)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:06:21 +08:00

222 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// direct_extract_test.go — task 6extractor 模式端到端(本地萃卡→POST rag_ingest_card)。
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// 完整鏈(claude stub 版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card
// → 原文從未離開本機 → manifest 標 ingested(下一輪不重送)。
func TestDirectExtractorModeE2E(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 原稿機密內容 XYZZY"), 0o644); err != nil {
t.Fatal(err)
}
// 假 cypher:收 rag_ingest_card、驗 payload、記帳
var posted []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_ingest_card/trigger") {
t.Errorf("打錯端點:%s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
posted = append(posted, m)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
// stub claude:把原稿萃成卡(模擬 /wiki-capture 行為)
stubDir := t.TempDir()
stub := filepath.Join(stubDir, "claude")
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n## 關聯\\n- 報銷規則 >> 屬於 >> 財務\\n' > 'system-dev/wiki/cards/報銷規則.md'\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "claude", ClaudeBin: stub,
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 {
t.Fatalf("exit=%d results=%+v", exit, results)
}
if len(results) != 1 || results[0].Status != "ingested" {
t.Fatalf("results=%+v", results)
}
// 卡片落地本地(用戶看得到自己的 wiki)
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md")); err != nil {
t.Fatalf("卡片未落地:%v", err)
}
// 上雲的是卡片、不是原文
if len(posted) != 1 {
t.Fatalf("應恰好 POST 一張卡,got %d", len(posted))
}
cc, _ := posted[0]["card_content"].(string)
if !strings.Contains(cc, "## 一句話定義") {
t.Fatalf("card_content 不是卡片:%.80s", cc)
}
if strings.Contains(cc, "XYZZY") {
t.Fatal("原文內容洩上雲=違反四步定稿邊界")
}
// path 必須是「原檔路徑」(takedown 比對鍵+B4 溯源)——不是卡片路徑(07-24 第五枚坑)
if p, _ := posted[0]["path"].(string); p != "報銷規則.md" {
t.Fatalf("path=%q(應為原檔路徑)", p)
}
// 第二輪:原稿沒變 → 不重萃不重送
results2, exit2, _ := RunDirectOnce(cfg, false)
if exit2 != 0 || len(results2) != 0 || len(posted) != 1 {
t.Fatalf("第二輪應零事件:results=%+v posted=%d", results2, len(posted))
}
}
// t15extractor 模式刪原檔 → 雲端 takedown 成功後,本地萃出的卡也要被清掉。
func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 原稿"), 0o644); err != nil {
t.Fatal(err)
}
// 假 cypher:收 rag_ingest_card 與 rag_takedown_direct
var takedowns []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_takedown_direct/trigger") {
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
takedowns = append(takedowns, m)
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
// stub claude:萃卡落地
stubDir := t.TempDir()
stub := filepath.Join(stubDir, "claude")
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n' > 'system-dev/wiki/cards/報銷規則.md'\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "claude", ClaudeBin: stub,
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
// 單檔刪除=removed ratio 100%,預設 0.4 防呆會壓下事件;本測試聚焦下架路,放寬到 1.0
//1 > 1.0×1 為 false → 事件放行)。
MaxRemoved: 1.0,
}
// 第一輪:萃卡+上雲,本地卡存在
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
t.Fatalf("第一輪 ingest 失敗 exit=%d", exit)
}
cardPath := filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md")
if _, err := os.Stat(cardPath); err != nil {
t.Fatalf("前置失敗:卡片未落地 %v", err)
}
// 刪原檔 → 第二輪:takedown 打出去、本地卡也被清
if err := os.Remove(filepath.Join(root, "報銷規則.md")); err != nil {
t.Fatal(err)
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 {
t.Fatalf("第二輪 exit=%d results=%+v", exit, results)
}
if len(results) != 1 || results[0].Status != "removed" {
t.Fatalf("results=%+v", results)
}
if len(takedowns) != 1 {
t.Fatalf("應恰好一次 takedowngot %d", len(takedowns))
}
if pn, _ := takedowns[0]["page_name"].(string); pn != "報銷規則" {
t.Fatalf("takedown page_name=%q", pn)
}
if _, err := os.Stat(cardPath); !os.IsNotExist(err) {
t.Fatalf("本地卡應已被清(err=%v", err)
}
}
// t15:本地卡不存在時(存在才刪)下架照常成功,不多出 warning。
func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
stubDir := t.TempDir()
stub := filepath.Join(stubDir, "claude")
script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# a\\n## 一句話定義\\n卡\\n' > system-dev/wiki/cards/a.md\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Extractor: "claude", ClaudeBin: stub,
CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct",
MaxRemoved: 1.0,
}
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
t.Fatal("第一輪失敗")
}
// 模擬用戶已手動清走本地卡 → removed 分支「存在才刪」不應報錯或多出 warning
if err := os.Remove(filepath.Join(root, "system-dev", "wiki", "cards", "a.md")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(root, "a.md")); err != nil {
t.Fatal(err)
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 0 || len(results) != 1 || results[0].Status != "removed" {
t.Fatalf("exit=%d results=%+v", exit, results)
}
}
// 萃取失敗=該檔標 failed、exit=1、manifest 不標(下輪重試),其他檔不受影響。
func TestDirectExtractorFailKeepsRetry(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
stubDir := t.TempDir()
stub := filepath.Join(stubDir, "claude")
if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil {
t.Fatal(err)
}
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(t.TempDir(), "m.json"),
CypherURL: "https://x.example", Namespace: "demo", APIKey: "demo",
Extractor: "claude", ClaudeBin: stub, MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, false)
if exit != 1 || len(results) != 1 || results[0].Status != "failed" {
t.Fatalf("exit=%d results=%+v", exit, results)
}
// 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試)
results2, _, _ := RunDirectOnce(cfg, false)
if len(results2) != 1 {
t.Fatalf("失敗檔應重試:%+v", results2)
}
}