Files
90CNServer/pvf/services/services_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

899 lines
27 KiB
Go

package services
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
annotationrules "pvfine/internal/annotations"
"pvfine/internal/pvf"
)
func openForTest(path string) (*pvf.Archive, error) {
return pvf.Open(path)
}
func mustReopen(t *testing.T, path string) *pvf.Archive {
t.Helper()
a, err := pvf.Open(path)
if err != nil {
t.Fatal(err)
}
return a
}
func testArchive(t *testing.T) *core {
t.Helper()
src := os.Getenv("PVF_TESTFILE")
if src == "" {
t.Skip("PVF_TESTFILE not set; skipping service integration test")
}
// 硬链接到临时目录,编辑/保存测试不会触碰源文件
link := filepath.Join(t.TempDir(), "Script.pvf")
if err := os.Link(src, link); err != nil {
// 跨盘等情况下退回复制
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(link, data, 0o644); err != nil {
t.Fatal(err)
}
}
c := NewCore()
a, err := openForTest(link)
if err != nil {
t.Fatal(err)
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
c.startSearchIndex()
waitForSearchIndex(t, c)
t.Cleanup(c.closeArchive)
return c
}
func waitForSearchIndex(t *testing.T, c *core) IndexStatus {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
svc := NewArchiveService(c)
for time.Now().Before(deadline) {
status := svc.IndexStatus()
if status.State == IndexStateReady || status.State == IndexStateError {
if status.State == IndexStateError {
t.Fatalf("search index failed: %s", status.Error)
}
return status
}
time.Sleep(time.Millisecond)
}
t.Fatalf("search index did not finish: %#v", svc.IndexStatus())
return IndexStatus{}
}
func waitForSearchRefresh(t *testing.T, c *core) IndexStatus {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
svc := NewArchiveService(c)
for time.Now().Before(deadline) {
status := svc.IndexStatus()
if !status.Refreshing {
if status.State == IndexStateError {
t.Fatalf("search index refresh failed: %s", status.Error+status.RefreshError)
}
return status
}
time.Sleep(time.Millisecond)
}
t.Fatalf("search index refresh did not finish: %#v", svc.IndexStatus())
return IndexStatus{}
}
func writeSearchFixture(t *testing.T, name string) string {
t.Helper()
a := pvf.New()
_, err := a.AddFileText("equipment/equipment.lst", "1008 `character/common/amulet/1008.equ` 1010 `character/common/amulet/1008.equ` 1011 `character/common/amulet/missing.equ`", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
_, err = a.AddFileText("stackable/stackable.lst", "1008 `consumable/1008.stk`", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
_, err = a.AddFileText("equipment/character/common/amulet/1008.equ", "[name]\n`烈火之心项链`\n[icon]\n`Item/test.img`\n3\n[field image]\n`Item/field.img`\n4\n[grade]\n1", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
_, err = a.AddFileText("stackable/consumable/1008.stk", "[name]\n`回复药`", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
a.AddFile("misc/readme.txt", []byte("hello"), pvf.TypeScript)
path := filepath.Join(t.TempDir(), name)
var out bytes.Buffer
if err := a.SaveTo(&out); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, out.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestSyntheticSearchIndex(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
path := writeSearchFixture(t, "search.pvf")
if _, err := svc.Open(path); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
status := waitForSearchIndex(t, c)
if status.Skipped != 1 {
t.Fatalf("skipped = %d, want 1", status.Skipped)
}
assertIndexTimingsRecorded(t, status)
byID, err := svc.Search("1008", 0, 20)
if err != nil {
t.Fatal(err)
}
if len(byID.Hits) != 3 {
t.Fatalf("id hits = %d, want 3", len(byID.Hits))
}
counts := map[string]int{}
for _, hit := range byID.Hits {
counts[hit.Category]++
}
if counts[SearchCategoryEquipment] != 2 || counts[SearchCategoryStackable] != 1 {
t.Fatalf("id categories = %#v", counts)
}
byName, err := svc.Search("烈火之心项链", 0, 20)
if err != nil || len(byName.Hits) != 2 {
t.Fatalf("name hits = %d, err = %v", len(byName.Hits), err)
}
if byName.Hits[0].ID == "" || byName.Hits[0].Path != "equipment/character/common/amulet/1008.equ" {
t.Fatalf("name hit = %#v", byName.Hits[0])
}
if byName.Hits[0].Icon == nil || byName.Hits[0].Icon.Path != "Item/test.img" || byName.Hits[0].Icon.Index != 3 || byName.Hits[0].FieldImage == nil || byName.Hits[0].FieldImage.Index != 4 {
t.Fatalf("name hit images = %#v", byName.Hits[0])
}
byPath, err := svc.Search("misc/readme.txt", 0, 20)
if err != nil || len(byPath.Hits) != 1 {
t.Fatalf("path hits = %d, err = %v", len(byPath.Hits), err)
}
if byPath.Hits[0].Category != SearchCategoryFile || byPath.Hits[0].Name != "readme.txt" {
t.Fatalf("path hit = %#v", byPath.Hits[0])
}
exactName, err := svc.SearchExact("readme.txt", 0, 20)
if err != nil || len(exactName.Hits) != 1 {
t.Fatalf("exact name hits = %d, err = %v", len(exactName.Hits), err)
}
exactPartial, err := svc.SearchExact("readme", 0, 20)
if err != nil || len(exactPartial.Hits) != 0 {
t.Fatalf("exact partial hits = %d, err = %v", len(exactPartial.Hits), err)
}
amulet, err := svc.ListChildren("equipment/character/common/amulet")
if err != nil {
t.Fatal(err)
}
var amuletFile *TreeNode
for _, node := range amulet {
if node.Path == "equipment/character/common/amulet/1008.equ" {
amuletFile = node
break
}
}
if amuletFile == nil || len(amuletFile.Tags) != 2 {
t.Fatalf("tree tags = %#v, want two equipment mappings", amuletFile)
}
if amuletFile.Tags[0].ID != "1008" || amuletFile.Tags[0].Name != "烈火之心项链" ||
amuletFile.Tags[1].ID != "1010" || amuletFile.Tags[1].Name != "烈火之心项链" {
t.Fatalf("tree tags = %#v", amuletFile.Tags)
}
if amuletFile.Icon == nil || amuletFile.Icon.Index != 3 || amuletFile.FieldImage == nil || amuletFile.FieldImage.Index != 4 {
t.Fatalf("tree images = %#v", amuletFile)
}
}
func TestSearchSupportsBasicWildcards(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
path := writeSearchFixture(t, "wildcard-search.pvf")
if _, err := svc.Open(path); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
waitForSearchIndex(t, c)
byExtension, err := svc.Search("*.equ", 0, 20)
if err != nil {
t.Fatal(err)
}
if len(byExtension.Hits) != 2 {
t.Fatalf("extension wildcard hits = %d, want 2", len(byExtension.Hits))
}
for _, hit := range byExtension.Hits {
if !strings.HasSuffix(hit.Path, ".equ") {
t.Fatalf("extension wildcard escaped pattern: %q", hit.Path)
}
}
byQuestion, err := svc.Search("misc/readme.tx?", 0, 20)
if err != nil || len(byQuestion.Hits) != 1 || byQuestion.Hits[0].Path != "misc/readme.txt" {
t.Fatalf("question wildcard result = %#v, err = %v", byQuestion.Hits, err)
}
byExactGlob, err := svc.SearchExact("misc/*.txt", 0, 20)
if err != nil || len(byExactGlob.Hits) != 1 || byExactGlob.Hits[0].Path != "misc/readme.txt" {
t.Fatalf("exact wildcard result = %#v, err = %v", byExactGlob.Hits, err)
}
withoutWildcard, err := svc.Search("readme", 0, 20)
if err != nil || len(withoutWildcard.Hits) != 1 || withoutWildcard.Hits[0].Path != "misc/readme.txt" {
t.Fatalf("plain search result = %#v, err = %v", withoutWildcard.Hits, err)
}
}
func TestListDescendantFiles(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
path := writeSearchFixture(t, "descendants.pvf")
if _, err := svc.Open(path); err != nil {
t.Fatal(err)
}
defer c.closeArchive()
root, err := svc.ListDescendantFiles("")
if err != nil {
t.Fatal(err)
}
if len(root) != 5 {
t.Fatalf("root files = %d, want 5", len(root))
}
seenIndexes := make(map[int32]bool, len(root))
for i, node := range root {
if node == nil || node.IsDir || node.FileIndex < 0 {
t.Fatalf("root file %d invalid: %#v", i, node)
}
if i > 0 && root[i-1].Path >= node.Path {
t.Fatalf("root files are not sorted: %q then %q", root[i-1].Path, node.Path)
}
if seenIndexes[node.FileIndex] {
t.Fatalf("duplicate file index %d", node.FileIndex)
}
seenIndexes[node.FileIndex] = true
}
equipment, err := svc.ListDescendantFiles("equipment")
if err != nil {
t.Fatal(err)
}
if len(equipment) != 2 {
t.Fatalf("equipment files = %d, want 2", len(equipment))
}
for _, node := range equipment {
if !strings.HasPrefix(node.Path, "equipment/") {
t.Fatalf("equipment scope escaped: %q", node.Path)
}
}
amulet, err := svc.ListDescendantFiles("equipment/character/common/amulet/")
if err != nil {
t.Fatal(err)
}
if len(amulet) != 1 || amulet[0].Path != "equipment/character/common/amulet/1008.equ" {
t.Fatalf("amulet files = %#v", amulet)
}
missing, err := svc.ListDescendantFiles("does/not/exist")
if err != nil {
t.Fatal(err)
}
if len(missing) != 0 {
t.Fatalf("missing scope files = %d, want 0", len(missing))
}
}
func TestListModifiedFilesWithoutVersionControl(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
path := writeSearchFixture(t, "modified-files.pvf")
a := mustReopen(t, path)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
modifiedIndex, ok := a.Find("equipment/character/common/amulet/1008.equ")
if !ok {
t.Fatal("modified fixture file not found")
}
if err := NewEditorService(c).SetText(modifiedIndex, "[name]\n`changed`"); err != nil {
t.Fatal(err)
}
created, err := svc.CreateFile("misc/generated.str", pvf.TypeUnicode)
if err != nil {
t.Fatal(err)
}
files, err := svc.ListModifiedFiles()
if err != nil {
t.Fatal(err)
}
if len(files) != 2 {
t.Fatalf("modified files = %d, want 2: %#v", len(files), files)
}
if files[0].Path != "equipment/character/common/amulet/1008.equ" ||
files[0].ChangeKind != ChangeKindModified {
t.Fatalf("first modified file = %#v", files[0])
}
if files[1].Path != created.Path || files[1].ChangeKind != ChangeKindAdded {
t.Fatalf("second modified file = %#v", files[1])
}
}
func TestResolveFiles(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
path := writeSearchFixture(t, "resolve-files.pvf")
if _, err := svc.Open(path); err != nil {
t.Fatal(err)
}
defer c.closeArchive()
waitForSearchIndex(t, c)
resolved, err := svc.ResolveFiles([]string{
"misc/readme.txt",
"equipment/character/common/amulet/1008.equ",
"misc/readme.txt",
"does/not/exist",
})
if err != nil {
t.Fatal(err)
}
if len(resolved) != 2 {
t.Fatalf("resolved files = %d, want 2", len(resolved))
}
if resolved[0].Path != "misc/readme.txt" || resolved[0].Name != "readme.txt" {
t.Fatalf("first resolved file = %#v", resolved[0])
}
if resolved[1].Path != "equipment/character/common/amulet/1008.equ" ||
len(resolved[1].Tags) != 2 {
t.Fatalf("second resolved file = %#v", resolved[1])
}
}
func TestSyntheticSkillSearchIndex(t *testing.T) {
c := NewCore()
a := pvf.New()
mustAddText(t, a, "skill/fighterskill.lst", "20 `Fighter/Skill20.skl`", pvf.TypeScript)
skillIndex := mustAddText(t, a, "skill/fighter/skill20.skl", "[name]\n`旋风腿`", pvf.TypeScript)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
c.startSearchIndex()
waitForSearchIndex(t, c)
result, err := NewArchiveService(c).Search("旋风腿", 0, 20)
if err != nil {
t.Fatal(err)
}
if len(result.Hits) != 1 || result.Hits[0].Category != SearchCategorySkill ||
result.Hits[0].ID != "20" || result.Hits[0].FileIndex != skillIndex {
t.Fatalf("skill search result = %#v", result.Hits)
}
children, err := NewArchiveService(c).ListChildren("skill/fighter")
if err != nil {
t.Fatal(err)
}
skill := findTreeNode(children, "skill20.skl")
if skill == nil || len(skill.Tags) != 1 || skill.Tags[0].ID != "20" || skill.Tags[0].Name != "旋风腿" {
t.Fatalf("skill tree tags = %#v", skill)
}
}
func TestItemShopSearchAndListAnnotationsUseNPCName(t *testing.T) {
c := NewCore()
a := pvf.New()
listIndex := mustAddText(t, a, itemShopListPath, "500 `EquipmentShop7.shp`", pvf.TypeScript)
shopIndex := mustAddText(t, a, "itemshop/(r)equipmentshop7.shp", "[NPC]\n200", pvf.TypeScript)
mustAddText(t, a, npcListPath, "200 `merchant.npc`", pvf.TypeScript)
// The nested [name] is a decoy: item-shop NPC names must come from the
// file's top-level [name] section only.
npcIndex := mustAddText(t, a, "npc/merchant.npc", "[face]\n[name]\n`嵌套诱饵`\n[/face]\n[name]\n`测试商人`", pvf.TypeScript)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
c.startSearchIndex()
waitForSearchIndex(t, c)
search, err := NewArchiveService(c).Search("测试商人", 0, 20)
if err != nil {
t.Fatal(err)
}
var shopHit *SearchHit
for _, hit := range search.Hits {
if hit != nil && hit.Path == "itemshop/(r)equipmentshop7.shp" {
shopHit = hit
break
}
}
if shopHit == nil || shopHit.Name != "测试商人" || shopHit.FileIndex != shopIndex {
t.Fatalf("itemshop search hits = %#v", search.Hits)
}
children, err := NewArchiveService(c).ListChildren("itemshop")
if err != nil {
t.Fatal(err)
}
shopNode := findTreeNode(children, "(r)equipmentshop7.shp")
if shopNode == nil || len(shopNode.Tags) != 1 || shopNode.Tags[0].Name != "测试商人" {
t.Fatalf("itemshop tree tags = %#v", shopNode)
}
meta, err := NewEditorService(c).GetFile(listIndex)
if err != nil {
t.Fatal(err)
}
if len(meta.Annotations) != 1 || meta.Annotations[0].Title != "测试商人" || meta.Annotations[0].TargetFileIndex != shopIndex {
t.Fatalf("itemshop list annotations = %#v", meta.Annotations)
}
if err := NewEditorService(c).SetText(npcIndex, "[name]\n`新商人名`"); err != nil {
t.Fatal(err)
}
waitForSearchRefresh(t, c)
updatedSearch, err := NewArchiveService(c).Search("新商人名", 0, 20)
if err != nil {
t.Fatal(err)
}
var updatedShopHit *SearchHit
for _, hit := range updatedSearch.Hits {
if hit != nil && hit.Path == "itemshop/(r)equipmentshop7.shp" {
updatedShopHit = hit
break
}
}
if updatedShopHit == nil || updatedShopHit.Name != "新商人名" {
t.Fatalf("updated itemshop search hits = %#v", updatedSearch.Hits)
}
updatedMeta, err := NewEditorService(c).GetFile(listIndex)
if err != nil {
t.Fatal(err)
}
if len(updatedMeta.Annotations) != 1 || updatedMeta.Annotations[0].Title != "新商人名" {
t.Fatalf("updated itemshop list annotations = %#v", updatedMeta.Annotations)
}
}
func TestConfiguredListIndexesExposeTreeTags(t *testing.T) {
type listCase struct {
listPath string
entryPath string
target string
id string
name string
}
cases := []listCase{
{listPath: "appendage/appendage.lst", entryPath: "apd/example.apd", target: "appendage/apd/example.apd", id: "apd-1", name: "附加对象"},
{listPath: "character/character.lst", entryPath: "example.chr", target: "character/example.chr", id: "character-1", name: "鬼剑士"},
{listPath: "creature/creature.lst", entryPath: "example.cre", target: "creature/example.cre", id: "creature-1", name: "测试宠物"},
{listPath: "dungeon/dungeon.lst", entryPath: "example.dgn", target: "dungeon/example.dgn", id: "dungeon-1", name: "测试地下城"},
{listPath: "itemshop/itemshop.lst", entryPath: "example.shop", target: "itemshop/example.shop", id: "shop-1", name: "测试商店"},
{listPath: "map/map.lst", entryPath: "example.map", target: "map/example.map", id: "map-1", name: "测试地图"},
{listPath: "monster/monster.lst", entryPath: "example.mon", target: "monster/example.mon", id: "monster-1", name: "测试怪物"},
{listPath: "n_quest/quest.lst", entryPath: "example.qst", target: "n_quest/example.qst", id: "quest-1", name: "测试任务"},
{listPath: "npc/npc.lst", entryPath: "example.npc", target: "npc/example.npc", id: "npc-1", name: "测试 NPC"},
{listPath: "passiveobject/passiveobject.lst", entryPath: "example.obj", target: "passiveobject/example.obj", id: "object-1", name: "测试被动对象"},
{listPath: "region/region.lst", entryPath: "example.reg", target: "region/example.reg", id: "region-1", name: "测试区域"},
{listPath: "town/town.lst", entryPath: "example.twn", target: "town/example.twn", id: "town-1", name: "测试城镇"},
{listPath: "worldmap/worldmap.lst", entryPath: "example.wmp", target: "worldmap/example.wmp", id: "worldmap-1", name: "测试副本接口"},
}
c := NewCore()
a := pvf.New()
for _, item := range cases {
if _, err := a.AddFileText(item.listPath, item.id+" `"+item.entryPath+"`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
if _, err := a.AddFileText(item.target, "[name]\n`"+item.name+"`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
c.startSearchIndex()
waitForSearchIndex(t, c)
svc := NewArchiveService(c)
for _, item := range cases {
targetIndex, ok := a.Find(item.target)
if !ok {
t.Fatalf("fixture target missing: %s", item.target)
}
slash := strings.LastIndexByte(item.target, '/')
parent, base := item.target[:slash], item.target[slash+1:]
nodes, err := svc.ListChildren(parent)
if err != nil {
t.Fatal(err)
}
node := findTreeNode(nodes, base)
if node == nil || node.FileIndex != targetIndex {
t.Fatalf("tree node for %s = %#v", item.target, node)
}
found := false
for _, tag := range node.Tags {
if tag.ID == item.id && tag.Name == item.name {
found = true
break
}
}
if !found {
t.Fatalf("tree tags for %s = %#v", item.target, node.Tags)
}
}
}
func TestSearchIndexUsesConfiguredRelationListPaths(t *testing.T) {
indexToken := 0
engine, err := annotationrules.Compile(annotationrules.Document{
Version: 1,
Relations: map[string]annotationrules.RelationSpec{
"装备": {
ListPath: "custom/equipment.lst", IDToken: 0, PathToken: 1,
RecordTokens: 2, NameSection: "name",
},
},
Rules: []annotationrules.Rule{{
ID: "name", Target: annotationrules.TargetSpec{Kind: "token", Section: "name", Index: &indexToken},
Annotation: annotationrules.AnnotationSpec{Title: "名称", Type: "text"},
}},
})
if err != nil {
t.Fatal(err)
}
c := &core{annotationEngine: engine}
a := pvf.New()
mustAddText(t, a, "custom/equipment.lst", "1008 `item.equ`", pvf.TypeScript)
itemIndex := mustAddText(t, a, "custom/item.equ", "[name]\n`自定义装备`", pvf.TypeScript)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
c.startSearchIndex()
waitForSearchIndex(t, c)
result, err := NewArchiveService(c).Search("自定义装备", 0, 20)
if err != nil {
t.Fatal(err)
}
if len(result.Hits) != 1 || result.Hits[0].Category != SearchCategoryEquipment || result.Hits[0].FileIndex != itemIndex {
t.Fatalf("configured relation search result = %#v", result.Hits)
}
}
func TestArchiveServiceCreateAndDeleteFiles(t *testing.T) {
c := NewCore()
a := pvf.New()
first := a.AddFile("dir/first.equ", []byte("first"), pvf.TypeScript)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
svc := NewArchiveService(c)
created, err := svc.CreateFile("dir/new.str", pvf.TypeUnicode)
if err != nil {
t.Fatal(err)
}
if created == nil || created.Path != "dir/new.str" || created.FileIndex <= first {
t.Fatalf("created node = %#v", created)
}
if got := svc.Info().FileCount; got != 2 {
t.Fatalf("file count after create = %d", got)
}
removed, err := svc.DeleteFiles([]int32{first, created.FileIndex})
if err != nil {
t.Fatal(err)
}
if len(removed) != 2 || svc.Info().FileCount != 0 {
t.Fatalf("removed=%#v info=%#v", removed, svc.Info())
}
children, err := svc.ListChildren("")
if err != nil {
t.Fatal(err)
}
if len(children) != 0 {
t.Fatalf("root children after delete = %#v", children)
}
}
func TestArchiveServiceCreateFileInMissingDirectories(t *testing.T) {
c := NewCore()
a := pvf.New()
first := a.AddFile("dir/first.equ", []byte("first"), pvf.TypeScript)
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
svc := NewArchiveService(c)
// The path introduces three directories that do not exist in the archive.
created, err := svc.CreateFile("brand/new/deep/generated.equ", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
if created == nil || created.Path != "brand/new/deep/generated.equ" || created.FileIndex <= first {
t.Fatalf("created node = %#v", created)
}
// Every level of the new chain must be reachable as a directory node.
root, err := svc.ListChildren("")
if err != nil {
t.Fatal(err)
}
if !hasDirChild(root, "brand") {
t.Fatalf("root children missing brand dir: %#v", root)
}
level, err := svc.ListChildren("brand/new")
if err != nil {
t.Fatal(err)
}
if !hasDirChild(level, "deep") {
t.Fatalf("brand/new children missing deep dir: %#v", level)
}
leaf, err := svc.ListChildren("brand/new/deep")
if err != nil {
t.Fatal(err)
}
if len(leaf) != 1 || leaf[0].Path != "brand/new/deep/generated.equ" {
t.Fatalf("deep children = %#v", leaf)
}
descendants, err := svc.ListDescendantFiles("brand")
if err != nil {
t.Fatal(err)
}
if len(descendants) != 1 || descendants[0].Path != "brand/new/deep/generated.equ" {
t.Fatalf("descendants of brand = %#v", descendants)
}
}
func hasDirChild(nodes []*TreeNode, name string) bool {
for _, node := range nodes {
if node.IsDir && node.Name == name {
return true
}
}
return false
}
func TestArchiveServiceDeleteFileRegistrations(t *testing.T) {
c := NewCore()
a := pvf.New()
_, err := a.AddFileText(
"equipment/equipment.lst",
"1008 `character/common/amulet/1008.equ` 1009 `character/common/amulet/1009.equ`",
pvf.TypeScript,
)
if err != nil {
t.Fatal(err)
}
target, err := a.AddFileText(
"equipment/character/common/amulet/1008.equ",
"[name]\n`目标装备`",
pvf.TypeScript,
)
if err != nil {
t.Fatal(err)
}
if _, err := a.AddFileText(
"equipment/character/common/amulet/1009.equ",
"[name]\n`保留装备`",
pvf.TypeScript,
); err != nil {
t.Fatal(err)
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
svc := NewArchiveService(c)
registrations, err := svc.FindFileRegistrations([]int32{target})
if err != nil {
t.Fatal(err)
}
if len(registrations) != 1 || registrations[0].ID != "1008" || registrations[0].ListPath != "equipment/equipment.lst" {
t.Fatalf("registrations = %#v", registrations)
}
if _, err := svc.DeleteFilesWithRegistrations([]int32{target}, true); err != nil {
t.Fatal(err)
}
if _, ok := c.archive.Find("equipment/character/common/amulet/1008.equ"); ok {
t.Fatal("deleted target is still indexed")
}
listIndex, ok := c.archive.Find("equipment/equipment.lst")
if !ok {
t.Fatal("list file was deleted unexpectedly")
}
listText, err := c.archive.Text(listIndex)
if err != nil {
t.Fatal(err)
}
if strings.Contains(listText, "1008") || !strings.Contains(listText, "1009") {
t.Fatalf("list after synced delete = %q", listText)
}
}
func TestSearchRequiresReadyIndex(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
a := pvf.New()
if _, err := a.AddFileText("misc/readme.txt", "hello", pvf.TypeScript); err != nil {
t.Fatal(err)
}
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
defer c.closeArchive()
if _, err := svc.Search("readme", 0, 20); !errors.Is(err, ErrSearchIndexing) {
t.Fatalf("search error = %v, want ErrSearchIndexing", err)
}
c.startSearchIndex()
waitForSearchIndex(t, c)
if res, err := svc.Search("readme", 0, 20); err != nil || len(res.Hits) != 1 {
t.Fatalf("ready search = %d hits, err = %v", len(res.Hits), err)
}
}
func TestSearchIndexNameRefresh(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
path := writeSearchFixture(t, "refresh.pvf")
if _, err := svc.Open(path); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
waitForSearchIndex(t, c)
before, err := svc.Search("烈火之心项链", 0, 20)
if err != nil || len(before.Hits) != 2 {
t.Fatalf("before hits = %d, err = %v", len(before.Hits), err)
}
index := before.Hits[0].FileIndex
editor := NewEditorService(c)
if err := editor.SetText(index, "[name]\n`改名后的项链`\n[grade]\n1"); err != nil {
t.Fatal(err)
}
waitForSearchRefresh(t, c)
after, err := svc.Search("改名后的项链", 0, 20)
if err != nil || len(after.Hits) != 2 {
t.Fatalf("after hits = %d, err = %v", len(after.Hits), err)
}
old, err := svc.Search("烈火之心项链", 0, 20)
if err != nil || len(old.Hits) != 0 {
t.Fatalf("old hits = %d, err = %v", len(old.Hits), err)
}
}
func TestSearchIndexResetOnCloseAndReopen(t *testing.T) {
c := NewCore()
svc := NewArchiveService(c)
first := writeSearchFixture(t, "first.pvf")
second := writeSearchFixture(t, "second.pvf")
if _, err := svc.Open(first); err != nil {
t.Fatal(err)
}
svc.Close()
if status := svc.IndexStatus(); status.State != IndexStateIdle {
t.Fatalf("status after close = %#v", status)
}
if _, err := svc.Open(second); err != nil {
t.Fatal(err)
}
waitForSearchIndex(t, c)
res, err := svc.Search("1008", 0, 20)
if err != nil || len(res.Hits) != 3 {
t.Fatalf("reopen hits = %d, err = %v", len(res.Hits), err)
}
}
func TestArchiveServiceTreeAndSearch(t *testing.T) {
c := testArchive(t)
svc := NewArchiveService(c)
// The fixture is a local game revision and changes between builds, so assert
// on structure rather than a version-specific count.
if svc.Info().FileCount < 100000 {
t.Fatalf("file count = %d, want a large archive", svc.Info().FileCount)
}
root, err := svc.ListChildren("")
if err != nil {
t.Fatal(err)
}
if len(root) == 0 || !root[0].IsDir {
t.Fatalf("root children invalid: %d entries", len(root))
}
// 展开一个已知目录
monster, err := svc.ListChildren("monster")
if err != nil || len(monster) == 0 {
t.Fatalf("monster dir: %v, %d entries", err, len(monster))
}
// 分页搜索
res, err := svc.Search("100300001", 0, 10)
if err != nil {
t.Fatal(err)
}
if len(res.Hits) == 0 || !strings.HasSuffix(res.Hits[0].Path, "100300001.equ") {
t.Fatalf("search result unexpected: %d hits", len(res.Hits))
}
}
func TestEditorServiceEditAndSave(t *testing.T) {
c := testArchive(t)
archiveSvc := NewArchiveService(c)
editorSvc := NewEditorService(c)
// 通过搜索定位目标文件(与前端行为一致)
res, err := archiveSvc.Search("equipment/character/common/amulet/100300001.equ", 0, 5)
if err != nil || len(res.Hits) == 0 {
t.Fatalf("locate target: %v", err)
}
idx := res.Hits[0].FileIndex
meta, err := editorSvc.GetFile(idx)
if err != nil {
t.Fatal(err)
}
if !meta.Editable || !strings.Contains(meta.Text, "[name]") {
t.Fatalf("unexpected meta: editable=%v len=%d", meta.Editable, len(meta.Text))
}
if err := editorSvc.SetText(idx, "[name]\n`测试项链`\n"); err != nil {
t.Fatal(err)
}
if c.archive.ModifiedCount() != 1 {
t.Fatalf("modified count = %d", c.archive.ModifiedCount())
}
info, err := editorSvc.Save()
if err != nil {
t.Fatal(err)
}
if info.ModifiedCount != 0 {
t.Fatalf("modified after save = %d", info.ModifiedCount)
}
// 重新打开验证落盘
if err := c.setArchive(mustReopen(t, c.archive.SourcePath())); err != nil {
t.Fatal(err)
}
meta2, err := editorSvc.GetFile(idx)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(meta2.Text, "测试项链") {
t.Fatalf("edit not persisted: %.80s", meta2.Text)
}
}