DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,534 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"pvfine/internal/mcpserver"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
func newMCPFixture(t *testing.T) *MCPService {
|
||||
t.Helper()
|
||||
c, script, _, _ := newScriptTestService(t)
|
||||
s := newMCPService(c, NewArchiveService(c), NewEditorService(c), script, NewExportService(c), filepath.Join(t.TempDir(), "mcp.json"))
|
||||
s.workspace()
|
||||
t.Cleanup(func() { _ = s.stop() })
|
||||
return s
|
||||
}
|
||||
func mcpCurrent(s *MCPService) mcpArgs {
|
||||
s.workspace()
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
return mcpArgs{WorkspaceID: s.c.mcpWorkspaceID, ExpectedRevision: s.c.batchRevision, ExpectedWorkspaceEpoch: s.c.mcpEpoch, Limit: 50}
|
||||
}
|
||||
func mcpCall(t *testing.T, s *MCPService, name string, a mcpArgs) MCPTask {
|
||||
t.Helper()
|
||||
if a.RequestID == "" {
|
||||
a.RequestID = mcpID()
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
value, err := s.call(context.Background(), name, raw, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := value.(map[string]any)["taskId"].(string)
|
||||
return waitMCPTask(t, s, id)
|
||||
}
|
||||
func waitMCPTask(t *testing.T, s *MCPService, id string) MCPTask {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
value, err := s.task(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := value.(MCPTask)
|
||||
if task.Status != "running" {
|
||||
return task
|
||||
}
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
}
|
||||
t.Fatal("MCP task timed out")
|
||||
return MCPTask{}
|
||||
}
|
||||
func requireMCPCompleted(t *testing.T, task MCPTask) {
|
||||
t.Helper()
|
||||
if task.Status != "completed" {
|
||||
t.Fatalf("task %s: %s", task.Kind, task.Error)
|
||||
}
|
||||
}
|
||||
func TestMCPScriptPreviewApplyAndWorkspaceConflict(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
a := mcpCurrent(s)
|
||||
a.Source = "const f=pvf.find('equipment/first.equ');const d=f.parse();d.set('price',7);f.write(d);"
|
||||
task := mcpCall(t, s, "script_run", a)
|
||||
requireMCPCompleted(t, task)
|
||||
r := task.Result.(ScriptRunResult)
|
||||
if r.PlanID == "" {
|
||||
t.Fatal("missing plan")
|
||||
}
|
||||
p, err := s.script.PreviewPage(r.PlanID, "", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.script.Run(context.Background(), ScriptRunRequest{Source: "pvf.log('other');"}); err == nil || !strings.Contains(err.Error(), "PLAN_PENDING") {
|
||||
t.Fatal("UI run overwrote AI preview")
|
||||
}
|
||||
apply := mcpCurrent(s)
|
||||
apply.PlanID = r.PlanID
|
||||
apply.ChangeKeys = []string{p.Rows[0].ChangeKey}
|
||||
done := mcpCall(t, s, "script_apply", apply)
|
||||
requireMCPCompleted(t, done)
|
||||
s.c.mu.Lock()
|
||||
index, _ := s.c.archive.Find("equipment/first.equ")
|
||||
text, _ := s.c.archive.Text(index)
|
||||
s.c.mu.Unlock()
|
||||
if !strings.Contains(text, "7") {
|
||||
t.Fatal("change missing")
|
||||
}
|
||||
if s.check(a) == nil {
|
||||
t.Fatal("old revision accepted")
|
||||
}
|
||||
query := mcpCurrent(s)
|
||||
query.Source = "pvf.log(pvf.find('equipment/first.equ').path);"
|
||||
q := mcpCall(t, s, "script_run", query)
|
||||
requireMCPCompleted(t, q)
|
||||
if q.Result.(ScriptRunResult).PlanID != "" {
|
||||
t.Fatal("query retained empty plan")
|
||||
}
|
||||
}
|
||||
func TestMCPImportFrozenSelectionAndOverwrite(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
root := t.TempDir()
|
||||
file := filepath.Join(root, "first.equ")
|
||||
if err := os.WriteFile(file, []byte("[price]\n99"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := mcpCurrent(s)
|
||||
a.SourcePaths = []string{file}
|
||||
a.TargetDir = "equipment"
|
||||
a.Mode = "text"
|
||||
prep := mcpCall(t, s, "import_prepare", a)
|
||||
requireMCPCompleted(t, prep)
|
||||
id := prep.Result.(map[string]any)["importPlanId"].(string)
|
||||
if err := os.WriteFile(file, []byte("[price]\n1000"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a = mcpCurrent(s)
|
||||
a.ImportPlanID = id
|
||||
a.SelectedKeys = []string{"equipment/first.equ"}
|
||||
rejected := mcpCall(t, s, "import_apply", a)
|
||||
if !strings.Contains(rejected.Error, "OVERWRITE_REQUIRED") {
|
||||
t.Fatal("implicit overwrite accepted")
|
||||
}
|
||||
a.OverwriteKeys = a.SelectedKeys
|
||||
done := mcpCall(t, s, "import_apply", a)
|
||||
requireMCPCompleted(t, done)
|
||||
s.c.mu.Lock()
|
||||
i, _ := s.c.archive.Find("equipment/first.equ")
|
||||
text, _ := s.c.archive.Text(i)
|
||||
s.c.mu.Unlock()
|
||||
if strings.Contains(text, "1000") || !strings.Contains(text, "99") {
|
||||
t.Fatal("import did not use frozen bytes")
|
||||
}
|
||||
}
|
||||
func TestMCPExportDirectAndMod(t *testing.T) {
|
||||
for _, mode := range []string{"direct", "mod"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
a := mcpCurrent(s)
|
||||
a.Mode = mode
|
||||
a.Source = "selection"
|
||||
a.Scopes = []string{"equipment"}
|
||||
a.Format = "110USextend"
|
||||
a.Name = "AI-test"
|
||||
a.Version = "1.0"
|
||||
prep := mcpCall(t, s, "export_prepare", a)
|
||||
requireMCPCompleted(t, prep)
|
||||
id := prep.Result.(map[string]any)["exportPlanId"].(string)
|
||||
if _, err := s.export.Prepare(ExportRequest{Mode: "direct", Source: "selection", Scopes: []string{"equipment"}}); err == nil {
|
||||
t.Fatal("UI replaced AI export")
|
||||
}
|
||||
page, err := s.exportPreview(mcpArgs{ExportPlanID: id, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec := mcpArgs{ExportPlanID: id, OutputParent: t.TempDir(), OutputName: "files"}
|
||||
for _, row := range page.(map[string]any)["rows"].([]map[string]any) {
|
||||
if !row["required"].(bool) {
|
||||
exec.SelectedEntryKeys = append(exec.SelectedEntryKeys, row["entryKey"].(string))
|
||||
}
|
||||
}
|
||||
done := mcpCall(t, s, "export_execute", exec)
|
||||
requireMCPCompleted(t, done)
|
||||
out := done.Result.(map[string]any)["outputPath"].(string)
|
||||
target := filepath.Join(out, "equipment", "first.equ")
|
||||
if mode == "mod" {
|
||||
target = filepath.Join(out, "pack.json")
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.exportPreview(mcpArgs{ExportPlanID: id, Limit: 100}); err == nil {
|
||||
t.Fatal("export plan not consumed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestMCPSaveCopyPreservesSourceAndDirty(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.pvf")
|
||||
s.c.mu.Lock()
|
||||
if err := s.c.archive.SaveAs(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.c.mcpSourceHash = s.c.archive.SourceHash()
|
||||
s.c.mu.Unlock()
|
||||
if err := s.editor.SetText(0, "[price]\n42"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := mcpCurrent(s)
|
||||
a.Path = filepath.Join(dir, "copy.pvf")
|
||||
copyTask := mcpCall(t, s, "archive_save_as", a)
|
||||
requireMCPCompleted(t, copyTask)
|
||||
s.c.mu.RLock()
|
||||
same := s.c.archive.SourcePath() == source
|
||||
dirty := s.c.archive.Modified()
|
||||
s.c.mu.RUnlock()
|
||||
if !same || !dirty {
|
||||
t.Fatal("copy changed source baseline")
|
||||
}
|
||||
copied, err := pvf.Open(a.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer copied.Release()
|
||||
text, _ := copied.Text(0)
|
||||
if !strings.Contains(text, "42") {
|
||||
t.Fatal("copy missing edit")
|
||||
}
|
||||
a = mcpCurrent(s)
|
||||
done := mcpCall(t, s, "archive_save", a)
|
||||
requireMCPCompleted(t, done)
|
||||
s.c.mu.RLock()
|
||||
dirty = s.c.archive.Modified()
|
||||
s.c.mu.RUnlock()
|
||||
if dirty {
|
||||
t.Fatal("save left archive dirty")
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("external"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conflict := mcpCall(t, s, "archive_save", mcpCurrent(s))
|
||||
if !strings.Contains(conflict.Error, "SOURCE_CHANGED") {
|
||||
t.Fatal("external change not detected")
|
||||
}
|
||||
}
|
||||
func TestMCPOpenFailureCloseAndDeduplication(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
a := mcpCurrent(s)
|
||||
a.Path = filepath.Join(t.TempDir(), "bad.pvf")
|
||||
a.DirtyPolicy = "discard"
|
||||
before := a.WorkspaceID
|
||||
failed := mcpCall(t, s, "archive_open", a)
|
||||
if failed.Status != "failed" || mcpCurrent(s).WorkspaceID != before {
|
||||
t.Fatal("open failure replaced workspace")
|
||||
}
|
||||
a = mcpCurrent(s)
|
||||
a.DirtyPolicy = "discard"
|
||||
a.RequestID = "close-once"
|
||||
raw, _ := json.Marshal(a)
|
||||
value, err := s.call(context.Background(), "archive_close", raw, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := value.(map[string]any)["taskId"].(string)
|
||||
requireMCPCompleted(t, waitMCPTask(t, s, id))
|
||||
retry, err := s.call(context.Background(), "archive_close", raw, a)
|
||||
if err != nil || retry.(map[string]any)["taskId"] != id {
|
||||
t.Fatal("close retry not deduplicated")
|
||||
}
|
||||
a.DirtyPolicy = "save"
|
||||
raw, _ = json.Marshal(a)
|
||||
if _, err = s.call(context.Background(), "archive_close", raw, a); err == nil {
|
||||
t.Fatal("request ID conflict accepted")
|
||||
}
|
||||
if s.workspace()["open"] != false {
|
||||
t.Fatal("close failed")
|
||||
}
|
||||
}
|
||||
func TestMCPUIPreparationProtectsDraftAndBusy(t *testing.T) {
|
||||
for _, draft := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(draft), func(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
s.RegisterWindow("main")
|
||||
a := mcpCurrent(s)
|
||||
a.Source = "pvf.log('query');"
|
||||
a.RequestID = mcpID()
|
||||
raw, _ := json.Marshal(a)
|
||||
value, err := s.call(context.Background(), "script_run", raw, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for s.WindowState()["operationId"] == "" && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
s.Acknowledge("main", s.WindowState()["operationId"], draft, false)
|
||||
task := waitMCPTask(t, s, value.(map[string]any)["taskId"].(string))
|
||||
if draft {
|
||||
if !strings.Contains(task.Error, "UI_DRAFT_PENDING") {
|
||||
t.Fatal("draft not protected")
|
||||
}
|
||||
} else {
|
||||
requireMCPCompleted(t, task)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCancelBusyAndDiscardFileSetMutation(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
a := mcpCurrent(s)
|
||||
a.Source = "while(true){}"
|
||||
a.RequestID = mcpID()
|
||||
raw, _ := json.Marshal(a)
|
||||
value, err := s.call(context.Background(), "script_run", raw, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := value.(map[string]any)["taskId"].(string)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for !s.script.IsRunning() && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
other := a
|
||||
other.RequestID = mcpID()
|
||||
raw2, _ := json.Marshal(other)
|
||||
if _, err = s.call(context.Background(), "script_run", raw2, other); err == nil {
|
||||
t.Fatal("concurrent task accepted")
|
||||
}
|
||||
if !s.CancelActiveScript() {
|
||||
t.Fatal("UI cancel failed")
|
||||
}
|
||||
done := waitMCPTask(t, s, id)
|
||||
requireMCPCompleted(t, done)
|
||||
if done.Result.(ScriptRunResult).Status != "cancelled" {
|
||||
t.Fatal("script not cancelled")
|
||||
}
|
||||
a = mcpCurrent(s)
|
||||
a.Source = "pvf.createFileset('ai-set', ['equipment/first.equ']);"
|
||||
failed := mcpCall(t, s, "script_run", a)
|
||||
if !strings.Contains(failed.Error, "UNSUPPORTED_CHANGE") {
|
||||
t.Fatal("file set mutation exposed")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
pending := s.c.scriptPlan != nil
|
||||
s.c.mu.RUnlock()
|
||||
if pending {
|
||||
t.Fatal("unsupported plan retained")
|
||||
}
|
||||
}
|
||||
func TestMCPImportProtectedSourceAndPlanConflict(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
a := mcpCurrent(s)
|
||||
a.SourcePaths = []string{filepath.Join(t.TempDir(), ".env")}
|
||||
a.Mode = "text"
|
||||
failed := mcpCall(t, s, "import_prepare", a)
|
||||
if failed.Status != "failed" {
|
||||
t.Fatal("protected source accepted")
|
||||
}
|
||||
}
|
||||
func TestMCPOpenValidArchiveAndDirtyPolicy(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
path := filepath.Join(t.TempDir(), "next.pvf")
|
||||
next := pvf.New()
|
||||
if _, err := next.AddFileText("next.equ", "[price]\n123", pvf.TypeScript); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := next.SaveAs(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := mcpCurrent(s)
|
||||
old := a.WorkspaceID
|
||||
a.Path = path
|
||||
failed := mcpCall(t, s, "archive_open", a)
|
||||
if !strings.Contains(failed.Error, "UNSAVED_CHANGES") {
|
||||
t.Fatal("dirty switch accepted")
|
||||
}
|
||||
a.DirtyPolicy = "discard"
|
||||
task := mcpCall(t, s, "archive_open", a)
|
||||
requireMCPCompleted(t, task)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.c.mu.RLock()
|
||||
loading := s.c.versionLoading
|
||||
s.c.mu.RUnlock()
|
||||
if !loading {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
}
|
||||
current := mcpCurrent(s)
|
||||
if current.WorkspaceID == old {
|
||||
t.Fatal("workspace identity reused")
|
||||
}
|
||||
current.Path = "next.equ"
|
||||
result, err := s.readFile(current)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.(map[string]any)["text"].(string), "123") {
|
||||
t.Fatal("opened incorrect archive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPActualToolsOverHTTP(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
server := httptest.NewServer(mcpserver.Handler(s.tools()))
|
||||
defer server.Close()
|
||||
s.server = server.Config
|
||||
configuration, err := s.ConnectionConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var connection map[string]map[string]map[string]any
|
||||
if json.Unmarshal([]byte(configuration), &connection) != nil || len(connection["mcpServers"]["pvfine"]) != 1 || connection["mcpServers"]["pvfine"]["url"] == nil {
|
||||
t.Fatal("connection config must contain only URL")
|
||||
}
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "integration-test", Version: "1"}, nil)
|
||||
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: server.URL + "/mcp"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer session.Close()
|
||||
listed, err := session.ListTools(context.Background(), nil)
|
||||
if err != nil || len(listed.Tools) < 28 {
|
||||
t.Fatal("MCP discovery incomplete")
|
||||
}
|
||||
call := func(name string, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("tool %s failed", name)
|
||||
}
|
||||
var value map[string]any
|
||||
if err = json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
workspace := call("pvf_workspace_info", map[string]any{})
|
||||
docs := call("pvf_annotation_search", map[string]any{"pvfVersion": "all", "limit": 1})
|
||||
if docs["schemaVersion"] != float64(1) {
|
||||
t.Fatal("annotation HTTP schema")
|
||||
}
|
||||
if len(docs["items"].([]any)) > 0 {
|
||||
item := docs["items"].([]any)[0].(map[string]any)
|
||||
call("pvf_annotation_get", map[string]any{"documentId": item["documentId"], "rulesRevision": docs["rulesRevision"], "valuesLimit": 1})
|
||||
}
|
||||
call("pvf_annotation_explain", map[string]any{"workspaceId": workspace["workspaceId"], "expectedRevision": workspace["revision"], "filePath": "equipment/first.equ", "limit": 1})
|
||||
result := call("pvf_script_run", map[string]any{"workspaceId": workspace["workspaceId"], "expectedRevision": workspace["revision"], "source": "pvf.log('http query');", "requestId": "http-run-once"})
|
||||
id := result["taskId"].(string)
|
||||
task := waitMCPTask(t, s, id)
|
||||
requireMCPCompleted(t, task)
|
||||
result = call("pvf_task_status", map[string]any{"taskId": id})
|
||||
if result["status"] != "completed" {
|
||||
t.Fatal("MCP task result unavailable")
|
||||
}
|
||||
invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "pvf_read_file", Arguments: map[string]any{"path": "equipment/first.equ"}})
|
||||
if err != nil || !invalid.IsError {
|
||||
t.Fatal("missing workspace revision accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPExportBinaryAndDestinationConflict(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
s.c.mu.Lock()
|
||||
s.c.archive.AddFile("image/data.bin", []byte{1, 2, 3}, 2)
|
||||
if err := s.c.rebuildArchiveIndexesLocked(s.c.archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.c.mu.Unlock()
|
||||
a := mcpCurrent(s)
|
||||
a.Mode = "direct"
|
||||
a.Source = "selection"
|
||||
a.Scopes = []string{"image"}
|
||||
task := mcpCall(t, s, "export_prepare", a)
|
||||
requireMCPCompleted(t, task)
|
||||
id := task.Result.(map[string]any)["exportPlanId"].(string)
|
||||
parent := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(parent, "files"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a = mcpArgs{ExportPlanID: id, OutputParent: parent, OutputName: "files", SelectedEntryKeys: []string{"image/data.bin"}}
|
||||
conflict := mcpCall(t, s, "export_execute", a)
|
||||
if !strings.Contains(conflict.Error, "TARGET_EXISTS") {
|
||||
t.Fatal("existing output replaced")
|
||||
}
|
||||
a.OutputName = "new-files"
|
||||
done := mcpCall(t, s, "export_execute", a)
|
||||
requireMCPCompleted(t, done)
|
||||
data, err := os.ReadFile(filepath.Join(parent, "new-files", "image", "data.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != string([]byte{1, 2, 3}) {
|
||||
t.Fatal("binary bytes lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAtomicDirectoryPublishNeverReplacesExistingEmptyDirectory(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
temp, err := os.MkdirTemp(parent, "staged-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(temp, "data.txt"), []byte("new"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest := filepath.Join(parent, "existing")
|
||||
if err = os.Mkdir(dest, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = publishModDirectory(temp, dest); err == nil {
|
||||
t.Fatal("existing empty directory was replaced")
|
||||
}
|
||||
entries, err := os.ReadDir(dest)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatal("destination was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPScriptLogsAreBoundedAndReportTruncation(t *testing.T) {
|
||||
s := newMCPFixture(t)
|
||||
a := mcpCurrent(s)
|
||||
a.Source = "for(let i=0;i<100;i++){pvf.log('x'.repeat(20000));}"
|
||||
task := mcpCall(t, s, "script_run", a)
|
||||
requireMCPCompleted(t, task)
|
||||
result := task.Result.(ScriptRunResult)
|
||||
size := 0
|
||||
warned := false
|
||||
for _, line := range result.Logs {
|
||||
size += len(line.Message)
|
||||
if strings.Contains(line.Message, "省略") {
|
||||
warned = true
|
||||
}
|
||||
}
|
||||
if size > 300<<10 || !warned {
|
||||
t.Fatal("log limit or truncation notice missing")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user