Files
90CNServer/pvf/internal/annotations/engine_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

421 lines
16 KiB
Go

package annotations
import (
"reflect"
"strings"
"testing"
"pvfine/internal/pvf"
"pvfine/internal/rendering"
)
func intPtr(value int) *int { return &value }
func testEngine(t *testing.T, rules ...Rule) *Engine {
t.Helper()
engine, err := Compile(Document{Version: 1, Relations: map[string]RelationSpec{}, Rules: rules})
if err != nil {
t.Fatal(err)
}
return engine
}
func TestValidateRejectsDuplicateRuleID(t *testing.T) {
rule := Rule{
ID: "same", Target: TargetSpec{Kind: "section", Section: "name"},
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
}
err := Validate(Document{Version: 1, Relations: map[string]RelationSpec{}, Rules: []Rule{rule, rule}})
if err == nil || !strings.Contains(err.Error(), "重复") {
t.Fatalf("error = %v", err)
}
}
func TestValidatePathAnnotationRoot(t *testing.T) {
rule := Rule{ID: "path", Target: TargetSpec{Kind: "token", Section: "file", Index: intPtr(0)}, Annotation: AnnotationSpec{Title: "文件", Type: "path", PathRoot: "equipment/character"}}
if err := Validate(Document{Version: 1, Rules: []Rule{rule}}); err != nil {
t.Fatal(err)
}
rule.Annotation.PathRoot = "../outside"
if err := Validate(Document{Version: 1, Rules: []Rule{rule}}); err == nil || !strings.Contains(err.Error(), "pathRoot") {
t.Fatalf("invalid root error = %v", err)
}
}
func TestAnnotateDuplicateSectionsAndConflict(t *testing.T) {
first := Rule{
ID: "first", Match: MatchSpec{Extensions: []string{".equ"}},
Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "装备品级", Type: "enum", Values: map[string]string{"3": "神器", "4": "史诗"}},
}
second := first
second.ID = "second"
second.Annotation = AnnotationSpec{Title: "第二说明", Type: "text", Content: "补充内容"}
engine := testEngine(t, first, second)
results := engine.Annotate("equipment/a.equ", pvf.ParseScriptView("[rarity]\n3\n[rarity]\n4"), nil)
if len(results) != 2 {
t.Fatalf("results = %#v", results)
}
for i, result := range results {
if result.Title != []string{"神器", "史诗"}[i] || len(result.RuleIDs) != 2 || !strings.Contains(result.Content, "第二说明") {
t.Fatalf("result = %#v", result)
}
}
}
func TestAnnotateTokenRangeStaysWithinSection(t *testing.T) {
rangeTarget := &TokenRange{Start: 0, EndExclusive: 2}
engine := testEngine(t, Rule{
ID: "range", Target: TargetSpec{Kind: "token", Section: "pair", Range: rangeTarget},
Annotation: AnnotationSpec{Title: "一组数据", Type: "text"},
})
results := engine.Annotate("a.equ", pvf.ParseScriptView("[pair]\n1 2\n[pair]\n3 4"), nil)
if len(results) != 2 {
t.Fatalf("results = %#v", results)
}
}
func TestAnnotateReference(t *testing.T) {
engine, err := Compile(Document{
Version: 1,
Relations: map[string]RelationSpec{
"equipment": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, NameSection: "name"},
},
Rules: []Rule{{
ID: "ref", Target: TargetSpec{Kind: "token", Section: "ref", Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "关联装备", Type: "reference", Relation: "equipment"},
}},
})
if err != nil {
t.Fatal(err)
}
results := engine.Annotate("a.equ", pvf.ParseScriptView("[ref]\n1008"), func(_, id string) (Reference, bool) {
return Reference{ID: id, Name: "烈火之心", FileIndex: 9}, true
})
if len(results) != 1 || results[0].Title != "烈火之心" || results[0].TargetFileIndex != 9 || !strings.Contains(results[0].Content, "烈火之心") {
t.Fatalf("results = %#v", results)
}
}
func TestAnnotateMissingReferenceDegradesGracefully(t *testing.T) {
engine, err := Compile(Document{
Version: 1,
Relations: map[string]RelationSpec{
"equipment": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, NameSection: "name"},
},
Rules: []Rule{{
ID: "ref", Target: TargetSpec{Kind: "token", Section: "ref", Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "关联装备", Type: "reference", Relation: "equipment"},
}},
})
if err != nil {
t.Fatal(err)
}
results := engine.Annotate("a.equ", pvf.ParseScriptView("[ref]\n404"), nil)
if len(results) != 1 || results[0].Title != "关联装备" || results[0].TargetFileIndex != -1 || !strings.Contains(results[0].Content, "未找到关联文件") {
t.Fatalf("results = %#v", results)
}
}
func TestValidateImageAnnotationTarget(t *testing.T) {
valid := Rule{
ID: "icon", Target: TargetSpec{Kind: "token", Section: "icon", Index: intPtr(1), RecordTokens: 2, ImagePathToken: intPtr(0)},
Annotation: AnnotationSpec{Title: "图标", Type: "image"},
}
if err := Validate(Document{Version: 1, Rules: []Rule{valid}}); err != nil {
t.Fatal(err)
}
invalid := valid
invalid.ID = "invalid"
invalid.Target.ImagePathToken = intPtr(1)
if err := Validate(Document{Version: 1, Rules: []Rule{invalid}}); err == nil || !strings.Contains(err.Error(), "不能与图片索引 token 相同") {
t.Fatalf("invalid image target error = %v", err)
}
}
func TestAnnotateImageAnnotation(t *testing.T) {
engine := testEngine(t, Rule{
ID: "icon", Target: TargetSpec{Kind: "token", Section: "icon", Index: intPtr(1), RecordTokens: 2, ImagePathToken: intPtr(0)},
Annotation: AnnotationSpec{Title: "图标", Type: "image", Content: "装备图标", InlineImage: true},
})
text := "[icon]\n`Item/test.img` 3"
results := engine.Annotate("a.equ", pvf.ParseScriptView(text), nil)
if len(results) != 1 || results[0].Image == nil {
t.Fatalf("image annotation = %#v", results)
}
if !results[0].InlineImage {
t.Fatalf("inline image flag was not propagated: %#v", results[0])
}
indexStart := strings.Index(text, "3")
if results[0].Start != indexStart || results[0].End != indexStart+1 {
t.Fatalf("image annotation anchor = [%d, %d), want numeric token", results[0].Start, results[0].End)
}
if results[0].Image.Path != "Item/test.img" || results[0].Image.Index != 3 || !strings.Contains(results[0].Content, "图片: Item/test.img[3]") {
t.Fatalf("image annotation result = %#v", results[0])
}
}
func TestAnnotateContextualReferenceByRepeatedRecord(t *testing.T) {
engine, err := Compile(Document{
Version: 1,
Relations: map[string]RelationSpec{
"skill": {
Kind: "contextual", ContextToken: 0, IDToken: 0, PathToken: 1,
RecordTokens: 2, NameSection: "name",
ContextPaths: map[string]string{"[fighter]": "skill/fighterskill.lst", "[at fighter]": "skill/atfighterskill.lst"},
},
},
Rules: []Rule{{
ID: "skill.levelup", Target: TargetSpec{
Kind: "token", Section: "skill levelup", Index: intPtr(1), RecordTokens: 3,
},
Annotation: AnnotationSpec{Title: "技能", Type: "reference", Relation: "skill"},
}},
})
if err != nil {
t.Fatal(err)
}
view := pvf.ParseScriptView("[skill levelup]\n`[fighter]` 20 1\n`[fighter]` 19 1\n`[at fighter]` 20 1\n[/skill levelup]")
results := engine.AnnotateWithContextResolver("skills/test.skl", view, func(relation, id, context string) (Reference, bool) {
if relation != "skill" {
t.Fatalf("relation = %q", relation)
}
return Reference{ID: context + ":" + id, Name: context + " skill " + id, FileIndex: 7}, true
})
if len(results) != 3 {
t.Fatalf("results = %#v", results)
}
if results[0].Title != "[fighter] skill 20" || results[1].Title != "[fighter] skill 19" || results[2].Title != "[at fighter] skill 20" {
t.Fatalf("titles = %#v", results)
}
if results[0].Start >= results[1].Start || results[1].Start >= results[2].Start {
t.Fatalf("anchors are not ordered: %#v", results)
}
}
func TestAnnotateRepeatedTokenWithOffset(t *testing.T) {
engine := testEngine(t, Rule{
ID: "records.offset",
Target: TargetSpec{
Kind: "token", Section: "records", Index: intPtr(0),
Offset: 1, RecordTokens: 2,
},
Annotation: AnnotationSpec{Title: "记录", Type: "text"},
})
text := "[records]\n10 100 200 300 400 500\n[/records]"
results := engine.Annotate("a.equ", pvf.ParseScriptView(text), nil)
if len(results) != 2 {
t.Fatalf("results = %#v", results)
}
for i, want := range []string{"100", "300"} {
if got := text[results[i].Start:results[i].End]; got != want {
t.Fatalf("result[%d] = %q, want %q", i, got, want)
}
}
}
func TestAnnotateRepeatedTokenUsesDynamicRecordWidth(t *testing.T) {
dynamicIndex := 0
engine := testEngine(t, Rule{
ID: "records.dynamic",
Target: TargetSpec{
Kind: "token", Section: "records", Index: intPtr(1),
Offset: 1, RecordTokens: 3, TokensPerLineIndex: &dynamicIndex,
},
Annotation: AnnotationSpec{Title: "记录", Type: "text"},
})
text := "[records]\n2 100 200 300 400 500\n[/records]"
results := engine.Annotate("a.equ", pvf.ParseScriptView(text), nil)
if len(results) != 2 {
t.Fatalf("results = %#v", results)
}
for i, want := range []string{"200", "400"} {
if got := text[results[i].Start:results[i].End]; got != want {
t.Fatalf("result[%d] = %q, want %q", i, got, want)
}
}
}
func TestAnnotateStandaloneGroupsWithGroupOffset(t *testing.T) {
engine := testEngine(t, Rule{
ID: "world.drop",
Target: TargetSpec{
Kind: "token", Section: "world drop", Index: intPtr(0),
Offset: 1, GroupOffset: 1, RecordTokens: 2,
StandaloneValues: []int32{-1},
},
Annotation: AnnotationSpec{Title: "掉落", Type: "text"},
})
// Offset applies only once; groupOffset skips the first token of every
// group. Trailing incomplete records and the delimiters are not annotated.
text := "[world drop]\n999 10 100 200 300 -1 20 400 500 -1 -1 30 600 700 800\n[next]"
results := engine.Annotate("a.etc", pvf.ParseScriptView(text), nil)
for i, want := range []string{"100", "400", "600"} {
if i >= len(results) {
t.Fatalf("results = %#v", results)
}
if got := text[results[i].Start:results[i].End]; got != want {
t.Fatalf("result[%d] = %q, want %q", i, got, want)
}
}
if len(results) != 3 {
t.Fatalf("results = %#v", results)
}
}
func TestAnnotateStandaloneGroupsDoNotMatchQuotedSentinel(t *testing.T) {
engine := testEngine(t, Rule{
ID: "records", Target: TargetSpec{
Kind: "token", Section: "records", Index: intPtr(0),
RecordTokens: 2, StandaloneValues: []int32{-1},
},
Annotation: AnnotationSpec{Title: "record", Type: "text"},
})
text := "[records]\n`-1` 5 -1 10 20"
results := engine.Annotate("a.etc", pvf.ParseScriptView(text), nil)
if len(results) != 2 || text[results[0].Start:results[0].End] != "`-1`" ||
text[results[1].Start:results[1].End] != "10" {
t.Fatalf("results = %#v", results)
}
}
func TestDefaultIndependentDropReferencesAcrossOptionalList(t *testing.T) {
// Only the curated rules: generated section notes would add extra section
// level results that this token reference test does not exercise.
engine := handWrittenEngine(t)
renderer, err := rendering.LoadDefault()
if err != nil {
t.Fatal(err)
}
text := "[independent drop]\n" +
"1 101 201 4 5 6 7 8 9 10 11 12 13 14 15 16 17\n" +
"[list]\n901 902\n903 904\n[/list]\n" +
"18 102 202 21 22 23 24 25 26 27 28 29 30 31 32 33 34\n" +
"35 103 203 38 39 40 41 42 43 44 45 46 47 48 49 50 51\n" +
"[next]\n1"
var resolved []string
view := pvf.ParseScriptViewWithNestedSections(text, func(parent, child string) bool {
for _, nested := range renderer.SectionFormat("etc/test.etc", parent).NestedSections {
if nested == child {
return true
}
}
return false
})
results := engine.Annotate("etc/test.etc", view, func(relation, id string) (Reference, bool) {
resolved = append(resolved, relation+":"+id)
return Reference{ID: id, Name: relation + id, FileIndex: 7}, true
})
want := []string{"怪物:101", "物品:201", "怪物:102", "物品:202", "怪物:103", "物品:203"}
wantResolved := []string{"怪物:101", "怪物:102", "怪物:103", "物品:201", "物品:202", "物品:203"}
if strings.Join(resolved, ",") != strings.Join(wantResolved, ",") {
t.Fatalf("resolved = %#v, want %#v", resolved, wantResolved)
}
if len(results) != len(want) {
t.Fatalf("results = %#v", results)
}
for i, result := range results {
if result.TargetFileIndex != 7 || !strings.HasSuffix(want[i], ":"+text[result.Start:result.End]) {
t.Fatalf("result[%d] = %#v", i, result)
}
}
}
func TestAnnotatePathSupportsRecursiveGlobAndDirectories(t *testing.T) {
engine := testEngine(t, Rule{
ID: "path", Match: MatchSpec{Glob: "equipment/**"}, Target: TargetSpec{Kind: "path"},
Annotation: AnnotationSpec{Title: "装备目录", Type: "text"},
})
if len(engine.AnnotatePath("equipment/character", true)) != 1 {
t.Fatal("directory should match recursive glob")
}
if len(engine.AnnotatePath("stackable/item.stk", false)) != 0 {
t.Fatal("unrelated file should not match")
}
}
func TestAnnotatePathIndexMatchesFullScanInRuleOrder(t *testing.T) {
engine := testEngine(t,
Rule{ID: "deep", Match: MatchSpec{Glob: "a/b/**"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "深层目录", Type: "text"}},
Rule{ID: "exact", Match: MatchSpec{Glob: "a/b/c.d"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "精确文件", Type: "text"}},
Rule{ID: "star", Match: MatchSpec{Glob: "**/*.d"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "通配文件", Type: "text"}},
Rule{ID: "all", Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "全部", Type: "text"}},
Rule{ID: "top", Match: MatchSpec{Glob: "a/**"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "顶层目录", Type: "text"}},
Rule{ID: "sibling", Match: MatchSpec{Glob: "a2/**"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "同名前缀目录", Type: "text"}},
)
tests := []struct {
path string
isDir bool
want []string
title string
}{
{"a/b/c.d", false, []string{"deep", "exact", "star", "all", "top"}, "深层目录"},
{"a/b/c/e.f", false, []string{"deep", "all", "top"}, "深层目录"},
{"a", true, []string{"all", "top"}, "全部"},
{"a2/x.d", false, []string{"star", "all", "sibling"}, "通配文件"},
{"stackable/x.d", false, []string{"star", "all"}, "通配文件"},
}
for _, tc := range tests {
results := engine.AnnotatePath(tc.path, tc.isDir)
if len(results) != 1 {
t.Fatalf("%s: results = %#v", tc.path, results)
}
if !reflect.DeepEqual(results[0].RuleIDs, tc.want) {
t.Fatalf("%s: ruleIDs = %v, want %v", tc.path, results[0].RuleIDs, tc.want)
}
if results[0].Title != tc.title {
t.Fatalf("%s: title = %q, want %q", tc.path, results[0].Title, tc.title)
}
}
}
func TestAnnotatePathIndexHonorsExtensionsAndCase(t *testing.T) {
engine := testEngine(t, Rule{
ID: "stk", Match: MatchSpec{Extensions: []string{".stk"}, Glob: "stackable/**"},
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "堆叠目录", Type: "text"},
})
if len(engine.AnnotatePath("stackable/a.equ", false)) != 0 {
t.Fatal("extension mismatch should not match")
}
if len(engine.AnnotatePath("stackable/a.stk", true)) != 0 {
t.Fatal("extension rule should not match a directory")
}
results := engine.AnnotatePath("Stackable\\Nested\\A.STK", false)
if len(results) != 1 || results[0].Title != "堆叠目录" {
t.Fatalf("results = %#v", results)
}
}
func TestAnnotateUnknownEnumKeepsRuleTitle(t *testing.T) {
engine := testEngine(t, Rule{
ID: "rarity", Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "装备品级", Type: "enum", Values: map[string]string{"4": "史诗"}},
})
results := engine.Annotate("a.equ", pvf.ParseScriptView("[rarity]\n99"), nil)
if len(results) != 1 || results[0].Title != "装备品级" || !strings.Contains(results[0].Content, "当前值: 99") {
t.Fatalf("results = %#v", results)
}
}
func TestEnumTooltipListsAllValues(t *testing.T) {
engine := testEngine(t, Rule{
ID: "rarity", Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
Annotation: AnnotationSpec{
Title: "装备品级", Type: "enum", Content: "品级说明",
Values: map[string]string{"4": "史诗", "0": "普通", "3": "神器"},
},
})
for _, value := range []string{"4", "99"} {
t.Run(value, func(t *testing.T) {
results := engine.Annotate("a.equ", pvf.ParseScriptView("[rarity]\n"+value), nil)
if len(results) != 1 {
t.Fatalf("results = %#v", results)
}
if !strings.Contains(results[0].Content, "品级说明") ||
!strings.Contains(results[0].Content, "所有枚举值:\n0 - 普通\n3 - 神器\n4 - 史诗") {
t.Fatalf("tooltip = %q", results[0].Content)
}
})
}
}