Files
90CNServer/pvf/services/mcp_test.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

535 lines
16 KiB
Go

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")
}
}