DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,75 @@
package annotations
import (
"fmt"
"strings"
"testing"
"pvfine/internal/pvf"
)
func BenchmarkAnnotateManyRules(b *testing.B) {
for _, count := range []int{100, 1000, 10000} {
b.Run(fmt.Sprint(count), func(b *testing.B) {
rules := make([]Rule, count)
for i := range rules {
rules[i] = Rule{
ID: fmt.Sprint(i), Match: MatchSpec{Extensions: []string{".equ"}},
Target: TargetSpec{Kind: "token", Section: fmt.Sprintf("section%d", i), Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "说明", Type: "text"},
}
}
engine, err := Compile(Document{Version: 1, Rules: rules})
if err != nil {
b.Fatal(err)
}
var text strings.Builder
for i := 0; i < 100; i++ {
fmt.Fprintf(&text, "[section%d]\n1 2 3 4 5\n", i)
}
view := pvf.ParseScriptView(text.String())
b.ResetTimer()
for i := 0; i < b.N; i++ {
engine.Annotate("a.equ", view, nil)
}
})
}
}
// BenchmarkAnnotatePathManyRules guards the bulk path note lookup: without the
// literal glob and "<dir>/**" buckets every tree node would scan all rules.
func BenchmarkAnnotatePathManyRules(b *testing.B) {
const files, directories = 15000, 2000
rules := make([]Rule, 0, files+directories+4)
for i := 0; i < files; i++ {
rules = append(rules, Rule{
ID: fmt.Sprintf("file.%d", i), Match: MatchSpec{Glob: fmt.Sprintf("dir%02d/sub%02d/file%05d.ani", i%40, i%50, i)},
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "文件说明", Type: "text"},
})
}
for i := 0; i < directories; i++ {
rules = append(rules, Rule{
ID: fmt.Sprintf("dir.%d", i), Match: MatchSpec{Glob: fmt.Sprintf("dir%02d/sub%02d/**", i%40, i%50)},
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "目录说明", Type: "text"},
})
}
rules = append(rules,
Rule{ID: "star", Match: MatchSpec{Glob: "**/*.ani"}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "通配", Type: "text"}},
Rule{ID: "all", Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "全部", Type: "text"}},
)
engine, err := Compile(Document{Version: 1, Rules: rules})
if err != nil {
b.Fatal(err)
}
paths := []string{
"dir01/sub01/file00001.ani",
"dir01/sub01/file00001.ani.bak",
"dir39/sub49/deep/deeper/1.ani",
"unknown/path.dat",
"dir01/sub01",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
engine.AnnotatePath(paths[i%len(paths)], false)
}
}
+44
View File
@@ -0,0 +1,44 @@
package annotations
import (
"pvfine/internal/pvf"
"strings"
)
// ResolveDocumentationRule shares the engine's whole-block field inheritance.
func ResolveDocumentationRule(rule Rule, fields []FieldDefinition) (Rule, error) {
return resolveFieldRule(rule, fields)
}
// DocumentationMatches uses the same glob and extension semantics as annotations.
func DocumentationMatches(match MatchSpec, filePath string, directory bool) bool {
extensions := make(map[string]struct{}, len(match.Extensions))
for _, ext := range match.Extensions {
extensions[strings.ToLower(ext)] = struct{}{}
}
return (compiledRule{rule: Rule{Match: match}, extensions: extensions}).matches(filePath, directory)
}
type DocumentationAnchor struct {
Rule Rule
Start, End int
Value, Context string
}
// DocumentationAnchors projects compiled rules through the runtime target engine,
// including implicit fields, repeated records, separators and dynamic widths.
func (e *Engine) DocumentationAnchors(filePath string, view pvf.ScriptView) []DocumentationAnchor {
result := []DocumentationAnchor{}
if e == nil {
return result
}
for _, r := range e.rules {
if !r.matches(filePath, false) {
continue
}
for _, a := range e.editorAnchors(r.rule, view) {
result = append(result, DocumentationAnchor{r.rule, a.start, a.end, a.value, a.context})
}
}
return result
}
+875
View File
@@ -0,0 +1,875 @@
package annotations
import (
"fmt"
"path"
"sort"
"strconv"
"strings"
"pvfine/internal/pvf"
)
type Engine struct {
source *Document
version string
scoped bool
document Document
rules []compiledRule
// Path rules resolve once per tree node. Bulk path notes would otherwise
// force a full glob scan per node, so they are bucketed by literal glob and
// by the literal "<dir>/**" prefix at compile time.
pathExact map[string][]int
pathPrefix map[string][]int
pathGeneral []int
// scriptRules lists the non-path rules that annotate script tokens.
scriptRules []int
}
// ContextResolver resolves an ID using an optional value from the same
// repeated record. It extends Resolver for relations whose list path depends
// on another token, such as skill IDs grouped by profession.
type ContextResolver func(relation, id, context string) (Reference, bool)
// ListResolver resolves one concrete list row. Unlike ContextResolver, it
// receives the row's relative path as well, so duplicate IDs in a list still
// navigate to the file represented by that particular row.
type ListResolver func(relation, id, context, listPath, relativePath string) (Reference, bool)
// PathResolver looks up a token path in the archive, using an optional rule root.
type PathResolver func(root, value string) (int32, bool)
type compiledRule struct {
rule Rule
extensions map[string]struct{}
}
type matchedItem struct {
ruleID string
title string
content string
typ string
targetFileIndex int32
image *ImageReference
inlineImage bool
}
func Compile(document Document) (*Engine, error) {
normalizeImageTargets(&document)
if document.Relations == nil {
document.Relations = make(map[string]RelationSpec)
}
if document.Rules == nil {
document.Rules = make([]Rule, 0)
}
if err := Validate(document); err != nil {
return nil, err
}
return compileValidated(document), nil
}
// compileValidated only receives validated documents with valid field references.
func compileValidated(document Document) *Engine {
rules := make([]compiledRule, 0, len(document.Rules)+len(document.Fields))
appendRule := func(rule Rule) {
extensions := make(map[string]struct{}, len(rule.Match.Extensions))
for _, extension := range rule.Match.Extensions {
extensions[strings.ToLower(extension)] = struct{}{}
}
rules = append(rules, compiledRule{rule: rule, extensions: extensions})
}
resolvedRules := make([]Rule, 0, len(document.Rules))
for _, originalRule := range document.Rules {
rule, _ := resolveFieldRule(originalRule, document.Fields)
resolvedRules = append(resolvedRules, rule)
appendRule(rule)
}
// A shared field is itself an annotation definition. Explicit rules may
// still reference a field to override its presentation; in that case the
// explicit rule takes precedence and the implicit copy is skipped.
for _, field := range document.Fields {
fieldRule := Rule{
ID: "field:" + field.ID,
Match: field.Match,
Target: field.Target,
Annotation: field.Annotation,
Group: "field",
}
covered := false
for _, rule := range resolvedRules {
if rule.Field == field.ID || annotationRuleCoversField(rule, fieldRule) {
covered = true
break
}
}
if !covered {
appendRule(fieldRule)
}
}
engine := &Engine{document: document, rules: rules}
return engine.indexRules()
}
// indexRules buckets path rules so AnnotatePath only evaluates plausible
// candidates, and records the script rules that Annotate walks. Rule order is
// preserved because it decides the primary title of a path.
func (e *Engine) indexRules() *Engine {
for index := range e.rules {
rule := &e.rules[index]
if rule.rule.Target.Kind != "path" {
// Token, section and range rules never take part in path lookups.
e.scriptRules = append(e.scriptRules, index)
continue
}
glob := normalizePath(rule.rule.Match.Glob)
if prefix, ok := literalDirPrefix(glob); ok {
e.pathPrefix = appendRuleIndex(e.pathPrefix, prefix, index)
continue
}
if glob != "" && isLiteralGlob(glob) {
e.pathExact = appendRuleIndex(e.pathExact, glob, index)
continue
}
// An empty glob matches every path, and wildcard globs need a real match.
e.pathGeneral = append(e.pathGeneral, index)
}
return e
}
func appendRuleIndex(buckets map[string][]int, key string, index int) map[string][]int {
if buckets == nil {
buckets = make(map[string][]int)
}
buckets[key] = append(buckets[key], index)
return buckets
}
// isLiteralGlob reports whether a normalized glob matches exactly one path.
func isLiteralGlob(glob string) bool {
return !strings.ContainsAny(glob, `*?[]\`)
}
// literalDirPrefix returns the literal directory of a "<dir>/**" pattern. Such
// a pattern matches the directory itself and every path below it.
func literalDirPrefix(glob string) (string, bool) {
if !strings.HasSuffix(glob, "/**") {
return "", false
}
prefix := strings.TrimSuffix(glob, "/**")
if prefix == "" || !isLiteralGlob(prefix) {
return "", false
}
return prefix, true
}
// pathCandidateIndexes lists the rules worth evaluating for one path, in rule
// order. It is a superset filter; callers still verify each rule with matches.
func (e *Engine) pathCandidateIndexes(filePath string) []int {
key := normalizePath(filePath)
indexes := make([]int, 0, len(e.pathGeneral)+4)
indexes = append(indexes, e.pathExact[key]...)
for position := len(key); position > 0; position-- {
if key[position-1] != '/' {
continue
}
indexes = append(indexes, e.pathPrefix[key[:position-1]]...)
}
indexes = append(indexes, e.pathPrefix[key]...)
indexes = append(indexes, e.pathGeneral...)
sort.Ints(indexes)
return indexes
}
func annotationRuleCoversField(rule, field Rule) bool {
if rule.Annotation.Type != field.Annotation.Type || rule.Annotation.Title != field.Annotation.Title ||
rule.Annotation.Relation != field.Annotation.Relation || rule.Annotation.PathRoot != field.Annotation.PathRoot || !sameTarget(rule.Target, field.Target) {
return false
}
return len(rule.Match.Extensions) == 0 && strings.TrimSpace(rule.Match.Glob) == "" || sameMatch(rule.Match, field.Match)
}
func sameMatch(left, right MatchSpec) bool {
if !strings.EqualFold(strings.TrimSpace(left.Glob), strings.TrimSpace(right.Glob)) || len(left.Extensions) != len(right.Extensions) {
return false
}
used := make([]bool, len(right.Extensions))
for _, extension := range left.Extensions {
found := false
for index, candidate := range right.Extensions {
if !used[index] && strings.EqualFold(strings.TrimSpace(extension), strings.TrimSpace(candidate)) {
used[index] = true
found = true
break
}
}
if !found {
return false
}
}
return true
}
func sameTarget(left, right TargetSpec) bool {
if left.Kind != right.Kind || !strings.EqualFold(left.Section, right.Section) || left.Offset != right.Offset ||
left.RecordTokens != right.RecordTokens || left.GroupOffset != right.GroupOffset || !sameInt32Slice(left.StandaloneValues, right.StandaloneValues) || !sameIntPtr(left.Index, right.Index) ||
!sameIntPtr(left.TokensPerLineIndex, right.TokensPerLineIndex) || !sameIntPtr(left.ContextIndex, right.ContextIndex) ||
!sameIntPtr(left.ImagePathToken, right.ImagePathToken) {
return false
}
if (left.Range == nil) != (right.Range == nil) {
return false
}
return left.Range == nil || (left.Range.Start == right.Range.Start && left.Range.EndExclusive == right.Range.EndExclusive)
}
func sameInt32Slice(left, right []int32) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func sameIntPtr(left, right *int) bool {
if (left == nil) != (right == nil) {
return false
}
return left == nil || *left == *right
}
func (e *Engine) Document() Document {
if e.source != nil {
return *e.source
}
return e.document
}
func (e *Engine) Annotate(filePath string, view pvf.ScriptView, resolver Resolver) []Result {
return e.annotate(filePath, view, func(relation, id, _ string) (Reference, bool) {
if resolver == nil {
return Reference{}, false
}
return resolver(relation, id)
}, nil, nil)
}
func (e *Engine) AnnotateWithContextResolver(filePath string, view pvf.ScriptView, resolver ContextResolver) []Result {
return e.annotate(filePath, view, resolver, nil, nil)
}
// AnnotateWithContextAndListResolver resolves ID and list annotations.
func (e *Engine) AnnotateWithContextAndListResolver(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver) []Result {
return e.annotate(filePath, view, resolver, listResolver, nil)
}
// AnnotateWithResolvers also resolves path tokens to archive file indexes.
func (e *Engine) AnnotateWithResolvers(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver, pathResolver PathResolver) []Result {
return e.annotate(filePath, view, resolver, listResolver, pathResolver)
}
func (e *Engine) annotate(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver, pathResolver PathResolver) []Result {
results := make([]Result, 0)
resultByAnchor := make(map[string]int)
appendResult := func(anchor editorAnchor, item matchedItem) {
key := fmt.Sprintf("%d:%d", anchor.start, anchor.end)
if index, ok := resultByAnchor[key]; ok {
result := &results[index]
result.Content = appendTooltip(result.Content, item.title, item.content)
result.RuleIDs = append(result.RuleIDs, item.ruleID)
if result.TargetFileIndex < 0 && item.targetFileIndex >= 0 {
result.TargetFileIndex = item.targetFileIndex
}
if result.Image == nil && item.image != nil {
result.Image = item.image
}
if item.image != nil && item.inlineImage {
result.InlineImage = true
}
return
}
resultByAnchor[key] = len(results)
results = append(results, Result{
Start: anchor.start,
End: anchor.end,
Title: item.title,
Content: appendTooltip("", item.title, item.content),
Type: item.typ,
TargetFileIndex: item.targetFileIndex,
RuleIDs: []string{item.ruleID},
Image: item.image,
InlineImage: item.image != nil && item.inlineImage,
})
}
for _, index := range e.scriptRules {
compiled := e.rules[index]
rule := compiled.rule
if !compiled.matches(filePath, false) {
continue
}
anchors := e.editorAnchors(rule, view)
for _, anchor := range anchors {
value := anchor.value
if rule.Annotation.Type == "image" {
value = anchor.imagePathValue
}
item := annotationItem(rule, value, anchor.imageIndexValue, anchor.context, resolver, pathResolver)
appendResult(anchor, item)
}
}
for _, item := range e.listAnnotations(filePath, view, resolver, listResolver) {
appendResult(item.anchor, item.item)
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].Start != results[j].Start {
return results[i].Start < results[j].Start
}
return results[i].End < results[j].End
})
return results
}
type listAnnotation struct {
anchor editorAnchor
item matchedItem
}
type listBinding struct {
relationName string
context string
listPath string
relation RelationSpec
}
// listAnnotations adds the implicit name/link annotations for a list file.
// List files are formatted as fixed-size records, so the path token is the
// natural anchor for both the displayed name and Cmd/Ctrl-click navigation.
func (e *Engine) listAnnotations(filePath string, view pvf.ScriptView, resolver ContextResolver, listResolver ListResolver) []listAnnotation {
if resolver == nil && listResolver == nil {
return nil
}
bindings := make([]listBinding, 0)
seenBindings := make(map[string]struct{})
appendBinding := func(binding listBinding) {
key := binding.relationName + "\x00" + normalizePath(binding.listPath)
if _, exists := seenBindings[key]; exists {
return
}
seenBindings[key] = struct{}{}
bindings = append(bindings, binding)
}
for relationName, relation := range e.document.Relations {
kind := relation.Kind
if kind == "" {
kind = "list"
}
switch kind {
case "list":
if sameListPath(relation.ListPath, filePath) {
appendBinding(listBinding{
relationName: relationName,
listPath: relation.ListPath,
relation: e.relationWithDefaults(relation),
})
}
case "contextual":
for context, listPath := range relation.ContextPaths {
if !sameListPath(listPath, filePath) {
continue
}
appendBinding(listBinding{
relationName: relationName,
context: context,
listPath: listPath,
relation: e.relationWithDefaults(relation),
})
}
}
}
if len(bindings) == 0 {
return nil
}
sort.SliceStable(bindings, func(i, j int) bool {
if bindings[i].relationName != bindings[j].relationName {
return bindings[i].relationName < bindings[j].relationName
}
if bindings[i].listPath != bindings[j].listPath {
return bindings[i].listPath < bindings[j].listPath
}
return bindings[i].context < bindings[j].context
})
tokens := make([]pvf.ScriptElement, 0, len(view.Elements))
for _, element := range view.Elements {
if element.Kind == pvf.ScriptElementToken {
tokens = append(tokens, element)
}
}
results := make([]listAnnotation, 0)
for _, binding := range bindings {
relation := binding.relation
for offset := 0; offset+relation.RecordTokens <= len(tokens); offset += relation.RecordTokens {
id := tokens[offset+relation.IDToken].Value
pathToken := tokens[offset+relation.PathToken]
if id == "" || pathToken.Value == "" {
continue
}
var reference Reference
var ok bool
if listResolver != nil {
reference, ok = listResolver(binding.relationName, id, binding.context, binding.listPath, pathToken.Value)
} else if resolver != nil {
reference, ok = resolver(binding.relationName, id, binding.context)
}
if !ok || reference.FileIndex < 0 {
continue
}
title := strings.TrimSpace(reference.Name)
if title == "" {
// A valid indexed file can still omit [name]. Keep the
// annotation useful and, importantly, keep the path clickable.
title = reference.Path
}
if title == "" {
title = id
}
content := "ID: " + id
if reference.Path != "" {
content += "\n路径: " + reference.Path
}
results = append(results, listAnnotation{
anchor: editorAnchor{start: pathToken.Start, end: pathToken.End},
item: matchedItem{
ruleID: "list:" + binding.relationName,
title: title,
content: content,
typ: "reference",
targetFileIndex: reference.FileIndex,
},
})
}
}
return results
}
func (e *Engine) relationWithDefaults(relation RelationSpec) RelationSpec {
if relation.RecordTokens == 0 {
relation.RecordTokens = max(relation.IDToken, relation.PathToken) + 1
}
if relation.Kind == "" {
relation.Kind = "list"
}
return relation
}
func (e *Engine) AnnotatePath(filePath string, isDir bool) []Result {
items := make([]matchedItem, 0)
for _, index := range e.pathCandidateIndexes(filePath) {
compiled := e.rules[index]
if !compiled.matches(filePath, isDir) {
continue
}
items = append(items, annotationItem(compiled.rule, "", "", "", nil, nil))
}
if len(items) == 0 {
return nil
}
first := items[0]
result := Result{
Title: first.title,
Type: first.typ,
TargetFileIndex: -1,
RuleIDs: make([]string, 0, len(items)),
}
for _, item := range items {
result.Content = appendTooltip(result.Content, item.title, item.content)
result.RuleIDs = append(result.RuleIDs, item.ruleID)
}
return []Result{result}
}
func (e *Engine) Relation(name string) (RelationSpec, bool) {
relation, ok := e.document.Relations[name]
if ok && relation.RecordTokens == 0 {
relation.RecordTokens = max(relation.IDToken, relation.PathToken) + 1
}
if ok && relation.Kind == "" {
relation.Kind = "list"
}
return relation, ok
}
func (e *Engine) RelationNames() []string {
names := make([]string, 0, len(e.document.Relations))
for name := range e.document.Relations {
names = append(names, name)
}
sort.Strings(names)
return names
}
type editorAnchor struct {
start int
end int
value string
imagePathValue string
imageIndexValue string
context string
}
func (e *Engine) editorAnchors(rule Rule, view pvf.ScriptView) []editorAnchor {
if rule.Target.Kind == "section" {
anchors := make([]editorAnchor, 0)
for _, element := range view.Elements {
if element.Kind == pvf.ScriptElementSection && strings.EqualFold(element.Section, rule.Target.Section) {
anchors = append(anchors, editorAnchor{start: element.Start, end: element.End})
}
}
return anchors
}
if rule.Target.Kind != "token" {
return nil
}
if rule.Target.Index != nil && rule.Target.RecordTokens > 0 {
contextIndex := rule.Target.ContextIndex
if contextIndex == nil && rule.Annotation.Type == "reference" {
if relation, ok := e.Relation(rule.Annotation.Relation); ok && relation.Kind == "contextual" {
value := relation.ContextToken
contextIndex = &value
}
}
return repeatedTokenAnchors(rule, view, contextIndex)
}
if rule.Target.Index != nil {
anchors := make([]editorAnchor, 0)
for _, element := range view.Elements {
if element.Kind == pvf.ScriptElementToken && strings.EqualFold(element.Section, rule.Target.Section) && element.Index == *rule.Target.Index {
anchors = append(anchors, editorAnchor{start: element.Start, end: element.End, value: element.Value})
}
}
return anchors
}
ranges := make(map[int][]pvf.ScriptElement)
sectionOrder := make([]int, 0)
for _, element := range view.Elements {
if element.Kind != pvf.ScriptElementToken || !strings.EqualFold(element.Section, rule.Target.Section) {
continue
}
if _, ok := ranges[element.SectionID]; !ok {
sectionOrder = append(sectionOrder, element.SectionID)
}
ranges[element.SectionID] = append(ranges[element.SectionID], element)
}
anchors := make([]editorAnchor, 0, len(sectionOrder))
for _, sectionID := range sectionOrder {
var first, last *pvf.ScriptElement
for i := range ranges[sectionID] {
element := &ranges[sectionID][i]
if element.Index < rule.Target.Range.Start || element.Index >= rule.Target.Range.EndExclusive {
continue
}
if first == nil {
first = element
}
last = element
}
if first != nil && last != nil {
anchors = append(anchors, editorAnchor{start: first.Start, end: last.End})
}
}
return anchors
}
func repeatedTokenAnchors(rule Rule, view pvf.ScriptView, contextIndex *int) []editorAnchor {
ranges := make(map[int][]pvf.ScriptElement)
sectionOrder := make([]int, 0)
for _, element := range view.Elements {
if element.Kind != pvf.ScriptElementToken || !strings.EqualFold(element.Section, rule.Target.Section) {
continue
}
if _, ok := ranges[element.SectionID]; !ok {
sectionOrder = append(sectionOrder, element.SectionID)
}
ranges[element.SectionID] = append(ranges[element.SectionID], element)
}
anchors := make([]editorAnchor, 0)
for _, sectionID := range sectionOrder {
tokens := ranges[sectionID]
recordTokens := repeatedRecordTokens(rule.Target, tokens)
if recordTokens <= 0 {
continue
}
for _, group := range repeatedTokenGroups(rule.Target, tokens) {
for start := group.start; start+recordTokens <= group.end; start += recordTokens {
targetOffset := *rule.Target.Index
if targetOffset < 0 || targetOffset >= recordTokens {
continue
}
target := tokens[start+targetOffset]
anchor := editorAnchor{start: target.Start, end: target.End, value: target.Value}
if rule.Annotation.Type == "image" && rule.Target.ImagePathToken != nil {
imagePathOffset := *rule.Target.ImagePathToken
if imagePathOffset < 0 || imagePathOffset >= recordTokens {
continue
}
anchor.imagePathValue = tokens[start+imagePathOffset].Value
anchor.imageIndexValue = target.Value
}
if contextIndex != nil {
contextOffset := *contextIndex
if contextOffset < 0 || contextOffset >= recordTokens {
continue
}
anchor.context = tokens[start+contextOffset].Value
}
anchors = append(anchors, anchor)
}
}
}
return anchors
}
type tokenGroup struct{ start, end int }
// repeatedTokenGroups applies the section-wide offset once, then splits at
// standalone integer tokens. Each group's own offset is applied independently.
func repeatedTokenGroups(target TargetSpec, tokens []pvf.ScriptElement) []tokenGroup {
begin := min(target.Offset, len(tokens))
groups := make([]tokenGroup, 0, 1)
appendGroup := func(end int) {
start := min(begin+target.GroupOffset, end)
groups = append(groups, tokenGroup{start: start, end: end})
}
for index := begin; index < len(tokens); index++ {
token := tokens[index]
if token.TokenType != 0 {
continue
}
value, err := strconv.ParseInt(strings.TrimSpace(token.Value), 10, 32)
if err != nil {
continue
}
for _, delimiter := range target.StandaloneValues {
if int32(value) == delimiter {
appendGroup(index)
begin = index + 1
break
}
}
}
appendGroup(len(tokens))
return groups
}
// repeatedRecordTokens returns the fixed record width unless a section token
// supplies a valid positive override. The dynamic index is relative to the
// section's direct token sequence, just like rendering rules.
func repeatedRecordTokens(target TargetSpec, tokens []pvf.ScriptElement) int {
recordTokens := target.RecordTokens
if target.TokensPerLineIndex == nil {
return recordTokens
}
index := *target.TokensPerLineIndex
if index < 0 || index >= len(tokens) {
return recordTokens
}
value, err := strconv.Atoi(strings.TrimSpace(tokens[index].Value))
if err != nil || value <= 0 {
return recordTokens
}
return value
}
func annotationItem(rule Rule, value, imageIndexValue, context string, resolver ContextResolver, pathResolver PathResolver) matchedItem {
item := matchedItem{
ruleID: rule.ID,
title: rule.Annotation.Title,
content: strings.TrimSpace(rule.Annotation.Content),
typ: rule.Annotation.Type,
targetFileIndex: -1,
inlineImage: rule.Annotation.InlineImage,
}
switch rule.Annotation.Type {
case "path":
item.content = joinContent(item.content, "路径: "+strings.TrimSpace(value))
if pathResolver != nil {
if index, ok := pathResolver(rule.Annotation.PathRoot, value); ok {
item.targetFileIndex = index
}
}
case "image":
imageIndex, err := strconv.ParseInt(strings.TrimSpace(imageIndexValue), 10, 32)
item.content = joinContent(item.content, fmt.Sprintf("图片: %s[%s]", value, strings.TrimSpace(imageIndexValue)))
if strings.TrimSpace(value) != "" && err == nil && imageIndex >= 0 {
item.image = &ImageReference{Path: strings.TrimSpace(value), Index: int32(imageIndex)}
}
case "enum":
label, ok := rule.Annotation.Values[value]
detail := "当前值: " + value
if ok {
item.title = label
detail = value + " - " + label
}
item.content = joinContent(item.content, detail)
keys := make([]string, 0, len(rule.Annotation.Values))
for key := range rule.Annotation.Values {
keys = append(keys, key)
}
sort.Strings(keys)
entries := []string{"所有枚举值:"}
for _, key := range keys {
entries = append(entries, key+" - "+rule.Annotation.Values[key])
}
item.content = joinContent(item.content, strings.Join(entries, "\n"))
case "reference":
detail := "ID: " + value + "(未找到关联文件)"
if resolver != nil {
if reference, ok := resolver(rule.Annotation.Relation, value, context); ok {
if reference.Name != "" {
item.title = reference.Name
}
detail = reference.Name
if detail == "" {
detail = reference.Path
}
if detail == "" {
detail = "ID: " + value
} else {
detail += " (ID: " + value + ")"
}
item.targetFileIndex = reference.FileIndex
}
}
item.content = joinContent(item.content, detail)
}
return item
}
func appendTooltip(current, title, content string) string {
item := strings.TrimSpace(title)
if detail := strings.TrimSpace(content); detail != "" {
item = joinContent(item, detail)
}
if item == "" {
return current
}
if current == "" {
return item
}
return current + "\n\n" + item
}
func joinContent(first, second string) string {
if first == "" {
return second
}
if second == "" {
return first
}
return first + "\n" + second
}
func (r compiledRule) matches(filePath string, isDir bool) bool {
filePath = normalizePath(filePath)
if len(r.extensions) > 0 {
if isDir {
return false
}
if _, ok := r.extensions[strings.ToLower(path.Ext(filePath))]; !ok {
return false
}
}
return r.rule.Match.Glob == "" || globMatch(r.rule.Match.Glob, filePath)
}
func normalizePath(value string) string {
return strings.ToLower(strings.Trim(strings.ReplaceAll(value, "\\", "/"), "/"))
}
// sameListPath accepts the list/ location used by newer clients for a
// relation configured with the older category directory layout.
func sameListPath(configured, actual string) bool {
configured, actual = normalizePath(configured), normalizePath(actual)
if configured == actual {
return true
}
if strings.HasPrefix(actual, "list/") && strings.Contains(configured, "/") {
return path.Base(configured) == path.Base(actual)
}
if strings.HasPrefix(configured, "list/") && strings.Contains(actual, "/") {
return path.Base(configured) == path.Base(actual)
}
return false
}
func globMatch(pattern, value string) bool {
patternParts := splitPath(normalizePath(pattern))
valueParts := splitPath(normalizePath(value))
type state struct{ pattern, value int }
memo := make(map[state]bool)
seen := make(map[state]bool)
var match func(int, int) bool
match = func(pi, vi int) bool {
key := state{pi, vi}
if seen[key] {
return memo[key]
}
seen[key] = true
if pi == len(patternParts) {
memo[key] = vi == len(valueParts)
return memo[key]
}
if patternParts[pi] == "**" {
if match(pi+1, vi) || (vi < len(valueParts) && match(pi, vi+1)) {
memo[key] = true
}
return memo[key]
}
if vi >= len(valueParts) {
return false
}
matched, err := path.Match(patternParts[pi], valueParts[vi])
if err == nil && matched {
memo[key] = match(pi+1, vi+1)
}
return memo[key]
}
return match(0, 0)
}
func splitPath(value string) []string {
if value == "" {
return nil
}
return strings.Split(value, "/")
}
// ApplicableRuleIDs exposes resolved annotation applicability for the rule editor.
// Token existence is evaluated by Annotate; here the file/section scope is reported.
func (e *Engine) ApplicableRuleIDs(filePath, section string, isDir bool) map[string]bool {
ids := map[string]bool{}
if e == nil {
return ids
}
for _, r := range e.rules {
if !r.matches(filePath, isDir) {
continue
}
if section != "" {
if !strings.EqualFold(strings.TrimSpace(r.rule.Target.Section), strings.TrimSpace(section)) {
continue
}
} else if r.rule.Target.Kind != "path" {
continue
}
ids[r.rule.ID] = true
}
return ids
}
+420
View File
@@ -0,0 +1,420 @@
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)
}
})
}
}
+298
View File
@@ -0,0 +1,298 @@
package annotations
import (
"fmt"
"path"
"sort"
"strings"
"pvfine/internal/pvf"
)
// PreviewFieldValue is one occurrence of a shared field in an editor view.
// Values contains the selected token(s). For a repeated record target it
// contains the complete record so consumers can use context and neighboring
// values (for example, skill context + skill id + level).
type PreviewFieldValue struct {
Field FieldDefinition
Values []string
Context string
Start int
End int
}
// resolveFieldRule lets legacy rules opt into the shared field definition
// without changing the existing rule JSON shape. A rule's id, description and
// group remain local; non-empty target/match/annotation values override the
// field when supplied.
func resolveFieldRule(rule Rule, fields []FieldDefinition) (Rule, error) {
fieldID := strings.TrimSpace(rule.Field)
if fieldID == "" {
return rule, nil
}
for _, field := range fields {
if field.ID != fieldID {
continue
}
resolved := Rule{
ID: rule.ID,
PVFVersions: rule.PVFVersions,
Description: rule.Description,
Field: rule.Field,
Match: field.Match,
Target: field.Target,
Annotation: field.Annotation,
Group: rule.Group,
}
if len(rule.Match.Extensions) > 0 || rule.Match.Glob != "" {
resolved.Match = rule.Match
}
if rule.Target.Kind != "" {
resolved.Target = rule.Target
}
if rule.Annotation.Title != "" || rule.Annotation.Type != "" ||
rule.Annotation.Content != "" || len(rule.Annotation.Values) > 0 ||
rule.Annotation.Relation != "" || rule.Annotation.PathRoot != "" || rule.Annotation.InlineImage {
resolved.Annotation = rule.Annotation
}
return resolved, nil
}
return Rule{}, fmt.Errorf("引用了不存在的共享字段: %s", fieldID)
}
// PreviewFields returns the provider-specific shared fields that match a
// path, in stable display order. The returned definitions are copies of the
// immutable engine document and are safe for callers to retain.
func (e *Engine) PreviewFields(filePath, provider string) []FieldDefinition {
if e == nil {
return nil
}
items := make([]FieldDefinition, 0)
for _, field := range e.document.Fields {
if field.Preview == nil || !previewSupportsProvider(field.Preview, provider) {
continue
}
if !fieldMatches(field.Match, filePath, false) {
continue
}
items = append(items, field)
}
sort.SliceStable(items, func(i, j int) bool {
left, right := items[i].Preview, items[j].Preview
if left.Order != right.Order {
return left.Order < right.Order
}
return items[i].ID < items[j].ID
})
return items
}
func previewSupportsProvider(preview *PreviewSpec, provider string) bool {
provider = strings.TrimSpace(provider)
if preview == nil || provider == "" {
return false
}
for _, candidate := range preview.providerNames() {
if strings.EqualFold(candidate, provider) {
return true
}
}
return false
}
// ExtractPreviewFields projects a ScriptView through provider field targets.
// It deliberately shares the same target semantics as annotations, including
// repeated records, offsets and dynamic record widths.
func (e *Engine) ExtractPreviewFields(filePath string, view pvf.ScriptView, provider string) []PreviewFieldValue {
fields := e.PreviewFields(filePath, provider)
values := make([]PreviewFieldValue, 0)
for _, field := range fields {
for _, occurrence := range extractField(field, view) {
values = append(values, occurrence)
}
}
return values
}
func extractField(field FieldDefinition, view pvf.ScriptView) []PreviewFieldValue {
target := field.Target
if target.Kind == "section" {
result := make([]PreviewFieldValue, 0)
for _, sectionID := range sectionIDs(view, target.Section) {
tokens := sectionTokens(view, target.Section, sectionID)
if len(tokens) == 0 {
continue
}
result = append(result, PreviewFieldValue{
Field: field, Values: valuesOf(tokens),
Start: tokens[0].Start, End: tokens[len(tokens)-1].End,
})
}
return result
}
if target.Kind != "token" {
return nil
}
result := make([]PreviewFieldValue, 0)
for _, sectionID := range sectionIDs(view, target.Section) {
tokens := sectionTokens(view, target.Section, sectionID)
if target.Index != nil && target.RecordTokens > 0 {
recordTokens := target.RecordTokens
if target.TokensPerLineIndex != nil {
recordTokens = repeatedRecordTokens(target, tokens)
}
if recordTokens <= 0 {
continue
}
for _, group := range repeatedTokenGroups(target, tokens) {
for start := group.start; start+recordTokens <= group.end; start += recordTokens {
offset := *target.Index
if offset < 0 || offset >= recordTokens {
continue
}
record := tokens[start : start+recordTokens]
context := ""
if target.ContextIndex != nil && *target.ContextIndex >= 0 && *target.ContextIndex < len(record) {
context = record[*target.ContextIndex].Value
}
result = append(result, PreviewFieldValue{
Field: field, Values: valuesOf(record), Context: context,
Start: record[offset].Start, End: record[offset].End,
})
}
}
continue
}
if target.Index != nil {
index := *target.Index
if index >= 0 && index < len(tokens) {
token := tokens[index]
result = append(result, PreviewFieldValue{Field: field, Values: []string{token.Value}, Start: token.Start, End: token.End})
}
continue
}
if target.Range == nil {
continue
}
start := maxInt(0, target.Range.Start)
end := minInt(len(tokens), target.Range.EndExclusive)
if start >= end {
continue
}
result = append(result, PreviewFieldValue{
Field: field, Values: valuesOf(tokens[start:end]),
Start: tokens[start].Start, End: tokens[end-1].End,
})
}
return result
}
func fieldMatches(match MatchSpec, filePath string, isDir bool) bool {
filePath = normalizeFieldPath(filePath)
if len(match.Extensions) > 0 {
if isDir {
return false
}
matched := false
for _, extension := range match.Extensions {
if strings.EqualFold(path.Ext(filePath), strings.TrimSpace(extension)) {
matched = true
break
}
}
if !matched {
return false
}
}
if match.Glob == "" {
return true
}
return globFieldMatch(match.Glob, filePath)
}
func globFieldMatch(pattern, value string) bool {
patternParts := strings.Split(normalizeFieldPath(pattern), "/")
valueParts := strings.Split(normalizeFieldPath(value), "/")
type state struct{ pattern, value int }
memo := make(map[state]bool)
seen := make(map[state]bool)
var match func(int, int) bool
match = func(pi, vi int) bool {
key := state{pi, vi}
if seen[key] {
return memo[key]
}
seen[key] = true
if pi == len(patternParts) {
memo[key] = vi == len(valueParts)
return memo[key]
}
if patternParts[pi] == "**" {
memo[key] = match(pi+1, vi) || (vi < len(valueParts) && match(pi, vi+1))
return memo[key]
}
if vi >= len(valueParts) {
return false
}
matched, err := path.Match(patternParts[pi], valueParts[vi])
if err == nil && matched {
memo[key] = match(pi+1, vi+1)
}
return memo[key]
}
return match(0, 0)
}
func normalizeFieldPath(value string) string {
return strings.ToLower(strings.Trim(strings.ReplaceAll(value, "\\", "/"), "/"))
}
func sectionIDs(view pvf.ScriptView, name string) []int {
result := make([]int, 0)
seen := make(map[int]struct{})
for _, element := range view.Elements {
if element.Kind != pvf.ScriptElementSection || !strings.EqualFold(element.Section, name) {
continue
}
if _, ok := seen[element.SectionID]; ok {
continue
}
seen[element.SectionID] = struct{}{}
result = append(result, element.SectionID)
}
return result
}
func sectionTokens(view pvf.ScriptView, name string, sectionID int) []pvf.ScriptElement {
result := make([]pvf.ScriptElement, 0)
for _, element := range view.Elements {
if element.Kind == pvf.ScriptElementToken && element.SectionID == sectionID && strings.EqualFold(element.Section, name) {
result = append(result, element)
}
}
return result
}
func valuesOf(elements []pvf.ScriptElement) []string {
result := make([]string, len(elements))
for i, element := range elements {
result[i] = element.Value
}
return result
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
+207
View File
@@ -0,0 +1,207 @@
package annotations
import (
"strings"
"testing"
"pvfine/internal/pvf"
)
func TestExtractPreviewFieldsUsesSharedTargets(t *testing.T) {
index := 1
context := 0
engine, err := Compile(Document{
Version: 1,
Fields: []FieldDefinition{
{
ID: "equ.name",
Match: MatchSpec{Extensions: []string{".equ"}},
Target: TargetSpec{Kind: "token", Section: "name", Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
Preview: &PreviewSpec{Provider: "equ", Role: "name", Group: "header", Format: "text"},
},
{
ID: "equ.skill",
Match: MatchSpec{Extensions: []string{".equ"}},
Target: TargetSpec{Kind: "token", Section: "skill levelup", Index: &index, RecordTokens: 3, ContextIndex: &context},
Annotation: AnnotationSpec{Title: "技能", Type: "text"},
Preview: &PreviewSpec{Provider: "equ", Role: "skill-levelup", Group: "skills", Format: "skill-levelup"},
},
},
Rules: []Rule{},
})
if err != nil {
t.Fatal(err)
}
values := engine.ExtractPreviewFields("equipment/a.equ", pvf.ParseScriptView("[name]\n`测试`\n[skill levelup]\n`[swordman]` 38 2\n`[knight]` 14 1\n[/skill levelup]"), "equ")
if len(values) != 3 || values[0].Values[0] != "测试" {
t.Fatalf("values = %#v", values)
}
if values[1].Context != "[swordman]" || len(values[1].Values) != 3 || values[2].Values[1] != "14" {
t.Fatalf("repeated values = %#v", values)
}
}
func TestPreviewFieldsUseStandaloneGroupsAndGroupOffset(t *testing.T) {
engine := testEngine(t)
engine, err := Compile(Document{
Version: 1,
Fields: []FieldDefinition{{
ID: "world.drop", Match: MatchSpec{Extensions: []string{".etc"}},
Target: TargetSpec{
Kind: "token", Section: "world drop", Index: intPtr(0), RecordTokens: 2,
Offset: 1, GroupOffset: 1, StandaloneValues: []int32{-1},
},
Annotation: AnnotationSpec{Title: "掉落", Type: "text"},
Preview: &PreviewSpec{Provider: "etc", Role: "drop", Group: "drops", Format: "text"},
}},
})
if err != nil {
t.Fatal(err)
}
view := pvf.ParseScriptView("[world drop]\n999 10 100 200 -1 20 300 400 -1 30 500 600")
values := engine.ExtractPreviewFields("item/a.etc", view, "etc")
if len(values) != 3 {
t.Fatalf("values = %#v", values)
}
for i, want := range []string{"100", "300", "500"} {
if values[i].Values[0] != want || len(values[i].Values) != 2 {
t.Fatalf("values[%d] = %#v, want %s", i, values[i], want)
}
}
}
func TestFieldRuleReferencesSharedDefinition(t *testing.T) {
engine, err := Compile(Document{
Version: 1,
Fields: []FieldDefinition{{
ID: "equ.rarity",
Match: MatchSpec{Extensions: []string{".equ"}},
Target: TargetSpec{Kind: "token", Section: "rarity", Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "稀有度", Type: "enum", Values: map[string]string{"4": "史诗"}},
}},
Rules: []Rule{{ID: "rarity.rule", Field: "equ.rarity"}},
})
if err != nil {
t.Fatal(err)
}
results := engine.Annotate("equipment/a.equ", pvf.ParseScriptView("[rarity]\n4"), nil)
if len(results) != 1 || results[0].Title != "史诗" {
t.Fatalf("results = %#v", results)
}
}
func TestSharedFieldLoadsAsAnnotationAndSupportsMultipleProviders(t *testing.T) {
engine, err := Compile(Document{
Version: 1,
Fields: []FieldDefinition{{
ID: "global.cool-time",
Match: MatchSpec{Extensions: []string{".equ", ".stk"}},
Target: TargetSpec{Kind: "token", Section: "cool time", Index: intPtr(0)},
Annotation: AnnotationSpec{Title: "冷却时间", Type: "text"},
Preview: &PreviewSpec{Providers: []string{"equ", "stk"}, Role: "cool-time", Group: "summary", Format: "text"},
}},
})
if err != nil {
t.Fatal(err)
}
for _, extension := range []string{".equ", ".stk"} {
results := engine.Annotate("item/test"+extension, pvf.ParseScriptView("[cool time]\n100"), nil)
if len(results) != 1 || results[0].Title != "冷却时间" {
t.Fatalf("extension %s results = %#v", extension, results)
}
}
if len(engine.PreviewFields("item/test.equ", "equ")) != 1 || len(engine.PreviewFields("item/test.stk", "stk")) != 1 {
t.Fatal("multiple preview providers were not matched")
}
}
func TestLoadDefaultIncludesEquipmentPreviewFields(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
fields := engine.PreviewFields("equipment/a.equ", "equ")
if len(fields) < 20 {
t.Fatalf("equipment fields = %d", len(fields))
}
}
func TestDefaultNewRarityAnnotations(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
engine = engine.ForVersion("110US")
for _, path := range []string{"equipment/test.equ", "stackable/test.stk"} {
for value, label := range map[string]string{"7": "神话", "8": "太初"} {
results := engine.Annotate(path, pvf.ParseScriptView("[rarity]\n"+value), nil)
if len(results) != 1 {
t.Fatalf("%s rarity %s annotations = %#v", path, value, results)
}
for _, result := range results {
if len(result.RuleIDs) != 1 || result.RuleIDs[0] != "field:*.rarity" || strings.Count(result.Content, "所有枚举值:") != 1 {
t.Fatalf("%s rarity %s duplicated tooltip = %#v", path, value, result)
}
if result.Title != label {
t.Fatalf("%s rarity %s = %q, want %q", path, value, result.Title, label)
}
}
}
}
}
func TestDefaultEquipmentRarityPreviewUsesGlobalField(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
values := engine.ForVersion("110US").ExtractPreviewFields("equipment/test.equ", pvf.ParseScriptView("[rarity]\n8"), "equ")
if len(values) != 1 || values[0].Field.ID != "*.rarity" || values[0].Field.Preview.Role != "rarity" || values[0].Values[0] != "8" {
t.Fatalf("rarity preview fields = %#v", values)
}
}
func TestDefaultFameSections(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
engine = engine.ForVersion("110US")
for _, section := range []string{"unique option fame value", "fame value"} {
t.Run(section, func(t *testing.T) {
view := pvf.ParseScriptView("[" + section + "]\n748")
results := engine.Annotate("equipment/test.equ", view, nil)
if len(results) != 1 || results[0].Title != "冒险家名望" {
t.Fatalf("annotations = %#v", results)
}
values := engine.ExtractPreviewFields("equipment/test.equ", view, "equ")
if len(values) != 1 || len(values[0].Values) != 1 || values[0].Values[0] != "748" {
t.Fatalf("preview fields = %#v", values)
}
if results := engine.Annotate("item/test.stk", view, nil); len(results) != 0 {
t.Fatalf("non-equ annotations = %#v", results)
}
})
}
}
func TestDefaultBuffBasicExplainSection(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
engine = engine.ForVersion("110US")
view := pvf.ParseScriptView("[buff basic explain]\n{10=`<1::buff>`}")
results := engine.Annotate("equipment/test.equ", view, nil)
if len(results) != 1 || results[0].Title != "辅助职业效果" {
t.Fatalf("annotations = %#v", results)
}
values := engine.ExtractPreviewFields("equipment/test.equ", view, "equ")
if len(values) != 1 || values[0].Field.ID != "equ.buff-basic-explain" || values[0].Field.Preview.Role != "buff-basic-explain" {
t.Fatalf("preview fields = %#v", values)
}
if results := engine.Annotate("item/test.stk", view, nil); len(results) != 0 {
t.Fatalf("non-equ annotations = %#v", results)
}
}
+548
View File
@@ -0,0 +1,548 @@
package annotations
import (
"bytes"
"encoding/json"
"fmt"
"io"
"path"
"sort"
"strings"
"sync"
appconfig "pvfine/config"
)
var (
defaultEngineOnce sync.Once
defaultEngine *Engine
defaultEngineErr error
)
// LoadDefault parses the embedded rule set once and reuses the immutable
// engine. The generated path notes make the document large enough that a parse
// per preview or auxiliary core would be measurable.
func LoadDefault() (*Engine, error) {
defaultEngineOnce.Do(func() {
defaultEngine, defaultEngineErr = loadDefault()
})
return defaultEngine, defaultEngineErr
}
// generatedRuleFile is one bulk rule set that ships next to annotations.json.
type generatedRuleFile struct {
// name is the sibling file that overrides the embedded copy at runtime.
name string
label string
data []byte
}
// generatedRuleFiles lists the generated rule sets merged after the hand-written
// rules. Order only decides the primary title of a path or section, so the
// curated rules in annotations.json always stay first.
func generatedRuleFiles() []generatedRuleFile {
return []generatedRuleFile{
{name: "paths.json", label: "path 标注", data: appconfig.PathsJSON},
{name: "sections.json", label: "section 标注", data: appconfig.SectionsJSON},
}
}
func loadDefault() (*Engine, error) {
document, err := parseDocument(appconfig.AnnotationsJSON)
if err != nil {
return nil, err
}
for _, generated := range generatedRuleFiles() {
if err := mergeGeneratedRules(&document, generated.data, generated.label); err != nil {
return nil, err
}
}
lists, err := ParseLists(appconfig.ListsJSON)
if err != nil {
return nil, err
}
document.Relations = lists.Relations
if err := Validate(document); err != nil {
return nil, err
}
return Compile(document)
}
// mergeGeneratedRules appends a generated rule set after the hand-written rules,
// so an explicit rule keeps the primary title for a path or section. Rules whose
// ID is already present are skipped, which keeps merging idempotent when a user
// mirrors a generated file by hand.
func mergeGeneratedRules(document *Document, data []byte, label string) error {
if len(data) == 0 {
return nil
}
generated, err := parseDocument(data)
if err != nil {
return fmt.Errorf("解析 %s 失败: %w", label, err)
}
known := make(map[string]struct{}, len(document.Rules))
for _, rule := range document.Rules {
known[rule.ID] = struct{}{}
}
for _, rule := range generated.Rules {
if _, exists := known[rule.ID]; exists {
continue
}
known[rule.ID] = struct{}{}
document.Rules = append(document.Rules, rule)
}
return nil
}
func Parse(data []byte) (Document, error) {
document, err := parseDocument(data)
if err != nil {
return Document{}, err
}
if err := Validate(document); err != nil {
return Document{}, err
}
return document, nil
}
func ParseRules(data []byte) (Document, error) {
return parseDocument(data)
}
func parseDocument(data []byte) (Document, error) {
var document Document
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil {
return Document{}, fmt.Errorf("解析标注规则失败: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return Document{}, fmt.Errorf("标注规则只能包含一个 JSON 文档")
}
return Document{}, fmt.Errorf("解析标注规则失败: %w", err)
}
normalizeImageTargets(&document)
return document, nil
}
func ParseLists(data []byte) (ListDocument, error) {
var lists ListDocument
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&lists); err != nil {
return ListDocument{}, fmt.Errorf("解析列表配置失败: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return ListDocument{}, fmt.Errorf("列表配置只能包含一个 JSON 文档")
}
return ListDocument{}, fmt.Errorf("解析列表配置失败: %w", err)
}
document := Document{Version: lists.Version, Relations: lists.Relations}
if err := Validate(document); err != nil {
return ListDocument{}, err
}
return lists, nil
}
func Marshal(document Document) ([]byte, error) {
normalizeImageTargets(&document)
if err := Validate(document); err != nil {
return nil, err
}
data, err := json.MarshalIndent(document, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
func MarshalRules(document Document) ([]byte, error) {
normalizeImageTargets(&document)
if err := Validate(document); err != nil {
return nil, err
}
document.Relations = nil
data, err := json.MarshalIndent(document, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
func MarshalLists(lists ListDocument) ([]byte, error) {
document := Document{Version: lists.Version, Relations: lists.Relations}
if err := Validate(document); err != nil {
return nil, err
}
data, err := json.MarshalIndent(lists, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
func Validate(document Document) error {
normalizeImageTargets(&document)
problems := make([]string, 0)
validateVersions := func(prefix string, versions []string) {
for _, version := range versions {
if !ValidPVFVersion(version) {
problems = append(problems, fmt.Sprintf("%s.pvfVersions 包含无效版本 %q,只允许 90US、90CN、110US、115US、115CN、100CN、TW", prefix, version))
}
}
}
for i, rule := range document.Rules {
validateVersions(fmt.Sprintf("rules[%d]", i), rule.PVFVersions)
}
for i, field := range document.Fields {
validateVersions(fmt.Sprintf("fields[%d]", i), field.PVFVersions)
}
if document.Version != 1 {
problems = append(problems, fmt.Sprintf("version 必须为 1,当前为 %d", document.Version))
}
relationNames := make([]string, 0, len(document.Relations))
for name := range document.Relations {
relationNames = append(relationNames, name)
}
sort.Strings(relationNames)
for _, name := range relationNames {
relation := document.Relations[name]
prefix := fmt.Sprintf("relations.%s", name)
if strings.TrimSpace(name) == "" {
problems = append(problems, "relation 名称不能为空")
}
kind := relation.Kind
if kind == "" {
kind = "list"
}
if kind != "list" && kind != "contextual" && kind != "union" {
problems = append(problems, prefix+".kind 必须是 list、contextual 或 union")
}
if kind == "union" {
if len(relation.Relations) == 0 {
problems = append(problems, prefix+".relations 不能为空")
}
for _, member := range relation.Relations {
if strings.TrimSpace(member) == "" || member == name {
problems = append(problems, prefix+".relations 包含无效关联类型")
break
}
}
continue
}
if kind == "list" && strings.TrimSpace(relation.ListPath) == "" {
problems = append(problems, prefix+".listPath 不能为空")
}
if relation.IDToken < 0 || relation.PathToken < 0 || relation.ContextToken < 0 {
problems = append(problems, prefix+" 的 token 下标不能为负数")
}
if kind == "contextual" {
if len(relation.ContextPaths) == 0 {
problems = append(problems, prefix+".contextPaths 不能为空")
}
for context, listPath := range relation.ContextPaths {
if strings.TrimSpace(context) == "" || strings.TrimSpace(listPath) == "" {
problems = append(problems, prefix+".contextPaths 的键和值不能为空")
break
}
}
if relation.RecordTokens <= relation.IDToken || relation.RecordTokens <= relation.PathToken {
problems = append(problems, prefix+".recordTokens 必须覆盖 idToken 和 pathToken")
}
}
recordTokens := relation.RecordTokens
if recordTokens == 0 {
recordTokens = max(relation.IDToken, relation.PathToken) + 1
}
if recordTokens <= relation.IDToken || recordTokens <= relation.PathToken {
problems = append(problems, prefix+".recordTokens 必须覆盖 idToken 和 pathToken")
}
if strings.TrimSpace(relation.NameSection) == "" {
problems = append(problems, prefix+".nameSection 不能为空")
}
}
unionState := make(map[string]uint8)
var visitUnion func(string)
visitUnion = func(name string) {
if unionState[name] == 2 {
return
}
if unionState[name] == 1 {
problems = append(problems, "relations."+name+" 存在循环引用")
return
}
relation, ok := document.Relations[name]
if !ok || relation.Kind != "union" {
unionState[name] = 2
return
}
unionState[name] = 1
for _, member := range relation.Relations {
if _, ok := document.Relations[member]; !ok {
problems = append(problems, fmt.Sprintf("relations.%s 引用了不存在的 relation: %s", name, member))
continue
}
visitUnion(member)
}
unionState[name] = 2
}
for _, name := range relationNames {
visitUnion(name)
}
seenIDs := make(map[string]bool)
for i, originalRule := range document.Rules {
rule := originalRule
if strings.TrimSpace(originalRule.Field) != "" {
resolved, err := resolveFieldRule(originalRule, document.Fields)
if err != nil {
problems = append(problems, fmt.Sprintf("rules[%d]: %s", i, err))
continue
}
rule = resolved
}
prefix := fmt.Sprintf("rules[%d]", i)
if strings.TrimSpace(rule.ID) == "" {
problems = append(problems, prefix+".id 不能为空")
} else if seenIDs[rule.ID] {
problems = append(problems, prefix+".id 重复: "+rule.ID)
}
seenIDs[rule.ID] = true
for _, extension := range rule.Match.Extensions {
if extension == "" || !strings.HasPrefix(extension, ".") || strings.ContainsAny(extension, "/\\") {
problems = append(problems, prefix+".match.extensions 必须是以点开头的文件后缀")
break
}
}
if rule.Match.Glob != "" {
if err := validateGlob(rule.Match.Glob); err != nil {
problems = append(problems, prefix+".match.glob 无效: "+err.Error())
}
}
switch rule.Target.Kind {
case "path":
if rule.Target.Offset != 0 || rule.Target.GroupOffset != 0 || len(rule.Target.StandaloneValues) > 0 || rule.Target.TokensPerLineIndex != nil {
problems = append(problems, prefix+" 的 offset/tokensPerLineIndex 只允许用于 token 标注")
}
if rule.Annotation.Type != "text" {
problems = append(problems, prefix+" 的 path 标注只支持 text 类型")
}
case "section":
if rule.Target.Offset != 0 || rule.Target.GroupOffset != 0 || len(rule.Target.StandaloneValues) > 0 || rule.Target.TokensPerLineIndex != nil {
problems = append(problems, prefix+" 的 offset/tokensPerLineIndex 只允许用于 token 标注")
}
if strings.TrimSpace(rule.Target.Section) == "" {
problems = append(problems, prefix+".target.section 不能为空")
}
if rule.Annotation.Type != "text" {
problems = append(problems, prefix+" 的 section 标注只支持 text 类型")
}
case "token":
if rule.Annotation.Type != "image" && rule.Target.ImagePathToken != nil {
problems = append(problems, prefix+".target.imagePathToken 只允许用于 image 标注")
}
if strings.TrimSpace(rule.Target.Section) == "" {
problems = append(problems, prefix+".target.section 不能为空")
}
if (rule.Target.Index == nil) == (rule.Target.Range == nil) {
problems = append(problems, prefix+" 的 token 目标必须且只能配置 index 或 range")
}
if rule.Target.Index != nil && *rule.Target.Index < 0 {
problems = append(problems, prefix+".target.index 不能为负数")
}
if rule.Target.Offset < 0 {
problems = append(problems, prefix+".target.offset 不能为负数")
}
if rule.Target.GroupOffset < 0 {
problems = append(problems, prefix+".target.groupOffset 不能为负数")
}
if rule.Target.TokensPerLineIndex != nil && *rule.Target.TokensPerLineIndex < 0 {
problems = append(problems, prefix+".target.tokensPerLineIndex 不能为负数")
}
if rule.Target.RecordTokens < 0 {
problems = append(problems, prefix+".target.recordTokens 不能为负数")
}
if rule.Target.RecordTokens == 0 && rule.Target.ContextIndex != nil {
problems = append(problems, prefix+".target.contextIndex 需要同时配置 recordTokens")
}
if rule.Target.RecordTokens > 0 {
if rule.Target.Index == nil {
problems = append(problems, prefix+".target.recordTokens 需要配合单个 index 使用")
} else if *rule.Target.Index >= rule.Target.RecordTokens {
problems = append(problems, prefix+".target.index 必须小于 recordTokens")
}
if rule.Target.ContextIndex != nil && (*rule.Target.ContextIndex < 0 || *rule.Target.ContextIndex >= rule.Target.RecordTokens) {
problems = append(problems, prefix+".target.contextIndex 必须位于 recordTokens 范围内")
}
}
if rule.Target.Offset != 0 || rule.Target.GroupOffset != 0 || len(rule.Target.StandaloneValues) > 0 || rule.Target.TokensPerLineIndex != nil {
if rule.Target.RecordTokens <= 0 {
problems = append(problems, prefix+".target.offset/tokensPerLineIndex 需要配置正数 recordTokens 作为回退值")
}
if rule.Target.Index == nil || rule.Target.Range != nil {
problems = append(problems, prefix+".target.offset/tokensPerLineIndex 需要配合单个 index 使用")
}
}
if rule.Target.Range != nil {
if rule.Target.Range.Start < 0 || rule.Target.Range.EndExclusive <= rule.Target.Range.Start {
problems = append(problems, prefix+".target.range 必须满足 0 <= start < endExclusive")
}
if rule.Annotation.Type != "text" {
problems = append(problems, prefix+" 的 token 范围只支持 text 类型")
}
}
if rule.Annotation.Type == "image" {
if rule.Target.Index == nil || rule.Target.Range != nil {
problems = append(problems, prefix+" 的 image 标注必须使用单个路径 token")
}
if rule.Target.RecordTokens <= 0 {
problems = append(problems, prefix+" 的 image 标注必须配置正数 target.recordTokens")
}
if rule.Target.ImagePathToken == nil {
problems = append(problems, prefix+" 的 image 标注必须配置 target.imagePathToken")
} else if rule.Target.RecordTokens > 0 && (*rule.Target.ImagePathToken < 0 || *rule.Target.ImagePathToken >= rule.Target.RecordTokens) {
problems = append(problems, prefix+".target.imagePathToken 必须位于 recordTokens 范围内")
}
if rule.Target.Index != nil && rule.Target.ImagePathToken != nil && *rule.Target.Index == *rule.Target.ImagePathToken {
problems = append(problems, prefix+" 的 imagePathToken 不能与图片索引 token 相同")
}
if rule.Target.ContextIndex != nil {
problems = append(problems, prefix+" 的 image 标注不支持 contextIndex")
}
}
default:
problems = append(problems, prefix+".target.kind 必须是 path、section 或 token")
}
switch rule.Annotation.Type {
case "text":
case "image":
case "path":
if root := strings.TrimSpace(strings.ReplaceAll(rule.Annotation.PathRoot, "\\", "/")); root != "" && (strings.HasPrefix(root, "/") || root == ".." || strings.HasPrefix(root, "../") || strings.Contains(root, "/../")) {
problems = append(problems, prefix+".annotation.pathRoot 必须是归档内的相对目录")
}
case "enum":
if len(rule.Annotation.Values) == 0 {
problems = append(problems, prefix+".annotation.values 不能为空")
}
case "reference":
relation, ok := document.Relations[rule.Annotation.Relation]
if !ok {
problems = append(problems, prefix+" 引用了不存在的 relation: "+rule.Annotation.Relation)
} else if relation.Kind == "contextual" {
if rule.Target.RecordTokens == 0 {
problems = append(problems, prefix+" 的 contextual relation 需要配置 target.recordTokens")
}
contextIndex := relation.ContextToken
if rule.Target.ContextIndex != nil {
contextIndex = *rule.Target.ContextIndex
}
if rule.Target.RecordTokens > 0 && (contextIndex < 0 || contextIndex >= rule.Target.RecordTokens) {
problems = append(problems, prefix+" 的 contextual relation 上下文 token 下标必须位于 target.recordTokens 范围内")
}
}
default:
problems = append(problems, prefix+".annotation.type 必须是 text、image、enum、reference 或 path")
}
if rule.Annotation.Type != "path" && rule.Annotation.PathRoot != "" {
problems = append(problems, prefix+".annotation.pathRoot 只允许用于 path 标注")
}
if rule.Annotation.InlineImage && rule.Annotation.Type != "image" {
problems = append(problems, prefix+".annotation.inlineImage 只允许用于 image 标注")
}
}
seenFieldIDs := make(map[string]bool, len(document.Fields))
for i, field := range document.Fields {
prefix := fmt.Sprintf("fields[%d]", i)
if strings.TrimSpace(field.ID) == "" {
problems = append(problems, prefix+".id 不能为空")
} else if seenFieldIDs[field.ID] {
problems = append(problems, prefix+".id 重复: "+field.ID)
}
seenFieldIDs[field.ID] = true
// Reuse the mature rule validator for the shared target and annotation
// schema. Fields are not rules at runtime, so this temporary document
// cannot recurse into the fields collection.
if err := Validate(Document{
Version: 1, Relations: document.Relations,
Rules: []Rule{{ID: field.ID, Match: field.Match, Target: field.Target, Annotation: field.Annotation}},
}); err != nil {
problems = append(problems, prefix+": "+err.Error())
}
if field.Preview != nil {
if len(field.Preview.providerNames()) == 0 {
problems = append(problems, prefix+".preview.provider 不能为空")
}
if strings.TrimSpace(field.Preview.Role) == "" {
problems = append(problems, prefix+".preview.role 不能为空")
}
if strings.TrimSpace(field.Preview.Group) == "" {
problems = append(problems, prefix+".preview.group 不能为空")
}
if strings.TrimSpace(field.Preview.Format) == "" {
problems = append(problems, prefix+".preview.format 不能为空")
}
}
}
if len(problems) > 0 {
return fmt.Errorf("标注规则校验失败:\n- %s", strings.Join(problems, "\n- "))
}
return nil
}
// normalizeImageTargets migrates the original image rule shape. Originally
// target.index was the IMG path token and target.imageIndexToken was the
// numeric image index token. Image annotations now anchor on the numeric
// index and use imagePathToken for the associated IMG path token.
func normalizeImageTargets(document *Document) {
if document == nil {
return
}
document.Rules = append([]Rule(nil), document.Rules...)
for i := range document.Rules {
normalizeImageTarget(&document.Rules[i].Target, document.Rules[i].Annotation.Type)
}
document.Fields = append([]FieldDefinition(nil), document.Fields...)
for i := range document.Fields {
normalizeImageTarget(&document.Fields[i].Target, document.Fields[i].Annotation.Type)
}
}
func normalizeImageTarget(target *TargetSpec, annotationType string) {
if target == nil || annotationType != "image" || target.ImageIndexToken == nil {
return
}
if target.ImagePathToken == nil {
pathToken := target.Index
target.Index = target.ImageIndexToken
target.ImagePathToken = pathToken
}
target.ImageIndexToken = nil
}
func validateGlob(pattern string) error {
for _, segment := range strings.Split(normalizePath(pattern), "/") {
if segment == "**" {
continue
}
if _, err := path.Match(segment, segment); err != nil {
return err
}
}
return nil
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+477
View File
@@ -0,0 +1,477 @@
package annotations
import (
"os"
"path/filepath"
"strings"
"testing"
appconfig "pvfine/config"
"pvfine/internal/pvf"
)
func TestParseRejectsUnsupportedVersionAndUnknownFields(t *testing.T) {
tests := []struct {
name string
json string
want string
}{
{
name: "version",
json: `{"version":2,"relations":{},"rules":[]}`,
want: "version 必须为 1",
},
{
name: "unknown field",
json: `{"version":1,"relations":{},"rules":[],"extra":true}`,
want: "unknown field",
},
{
name: "multiple documents",
json: `{"version":1,"relations":{},"rules":[]} {}`,
want: "只能包含一个 JSON 文档",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := Parse([]byte(test.json))
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}
func TestValidateRejectsInvalidRuleFields(t *testing.T) {
index := -1
document := Document{
Version: 1,
Relations: map[string]RelationSpec{},
Rules: []Rule{{
ID: "invalid",
Match: MatchSpec{Extensions: []string{"equ"}},
Target: TargetSpec{Kind: "token", Section: "rarity", Index: &index},
Annotation: AnnotationSpec{Title: "", Type: "reference", Relation: "missing"},
}},
}
err := Validate(document)
if err == nil {
t.Fatal("expected validation error")
}
for _, want := range []string{"以点开头", "不能为负数", "不存在的 relation"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want %q", err, want)
}
}
}
func TestValidateTokenGroupingFields(t *testing.T) {
dynamicIndex := 0
valid := Rule{
ID: "records",
Target: TargetSpec{
Kind: "token", Section: "records", Index: intPtr(1),
Offset: 1, RecordTokens: 3, TokensPerLineIndex: &dynamicIndex,
},
Annotation: AnnotationSpec{Title: "记录", Type: "text"},
}
if err := Validate(Document{Version: 1, Rules: []Rule{valid}}); err != nil {
t.Fatalf("valid grouping target rejected: %v", err)
}
tests := []struct {
name string
edit func(*Rule)
want string
}{
{
name: "negative offset",
edit: func(rule *Rule) { rule.Target.Offset = -1 },
want: "offset 不能为负数",
},
{
name: "negative group offset",
edit: func(rule *Rule) { rule.Target.GroupOffset = -1 },
want: "groupOffset 不能为负数",
},
{
name: "negative dynamic index",
edit: func(rule *Rule) {
index := -1
rule.Target.TokensPerLineIndex = &index
},
want: "tokensPerLineIndex 不能为负数",
},
{
name: "standalone values need record width",
edit: func(rule *Rule) {
rule.Target.Offset = 0
rule.Target.TokensPerLineIndex = nil
rule.Target.RecordTokens = 0
rule.Target.StandaloneValues = []int32{-1}
},
want: "需要配置正数 recordTokens",
},
{
name: "missing fallback",
edit: func(rule *Rule) { rule.Target.RecordTokens = 0 },
want: "需要配置正数 recordTokens",
},
{
name: "range target",
edit: func(rule *Rule) {
rule.Target.Index = nil
rule.Target.Range = &TokenRange{Start: 0, EndExclusive: 1}
},
want: "需要配合单个 index 使用",
},
{
name: "non-token target",
edit: func(rule *Rule) {
rule.Target.Kind = "section"
rule.Target.Section = "records"
},
want: "只允许用于 token 标注",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rule := valid
test.edit(&rule)
err := Validate(Document{Version: 1, Rules: []Rule{rule}})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}
func TestRuleGroupRoundTrip(t *testing.T) {
for _, group := range []string{"", "装备"} {
document := Document{Version: 1, Rules: []Rule{{
ID: "name", Group: group, Target: TargetSpec{Kind: "section", Section: "name"},
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
}}}
data, err := Marshal(document)
if err != nil {
t.Fatal(err)
}
parsed, err := Parse(data)
if err != nil {
t.Fatal(err)
}
if parsed.Rules[0].Group != group {
t.Fatalf("group = %q", parsed.Rules[0].Group)
}
}
}
func TestValidateInlineImageRequiresImageAnnotation(t *testing.T) {
document := Document{Version: 1, Rules: []Rule{{
ID: "text",
Target: TargetSpec{Kind: "section", Section: "name"},
Annotation: AnnotationSpec{Title: "名称", Type: "text", InlineImage: true},
}}}
err := Validate(document)
if err == nil || !strings.Contains(err.Error(), "inlineImage 只允许用于 image 标注") {
t.Fatalf("error = %v", err)
}
}
func TestParseMigratesLegacyImageTokenOrder(t *testing.T) {
data := []byte(`{"version":1,"rules":[{"id":"icon","target":{"kind":"token","section":"icon","index":0,"recordTokens":2,"imageIndexToken":1},"annotation":{"title":"图标","type":"image"}}]}`)
document, err := Parse(data)
if err != nil {
t.Fatal(err)
}
target := document.Rules[0].Target
if target.Index == nil || *target.Index != 1 || target.ImagePathToken == nil || *target.ImagePathToken != 0 || target.ImageIndexToken != nil {
t.Fatalf("migrated target = %#v", target)
}
formatted, err := MarshalRules(document)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(formatted), "imagePathToken") || strings.Contains(string(formatted), "imageIndexToken") {
t.Fatalf("formatted target = %s", formatted)
}
}
func TestLoadDefaultIncludesContextualSkillRelation(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
relation, ok := engine.Relation("技能")
if !ok || relation.Kind != "contextual" || relation.ContextPaths["[fighter]"] == "" {
t.Fatalf("skill relation = %#v, found=%v", relation, ok)
}
union, ok := engine.Relation("物品")
if !ok || union.Kind != "union" || len(union.Relations) != 2 {
t.Fatalf("item union relation = %#v, found=%v", union, ok)
}
}
func TestMarshalRulesSeparatesRelations(t *testing.T) {
document := Document{
Version: 1,
Relations: map[string]RelationSpec{
"equipment": {ListPath: "equipment/equipment.lst", IDToken: 0, PathToken: 1, RecordTokens: 2, NameSection: "name"},
},
Rules: []Rule{{
ID: "name", Target: TargetSpec{Kind: "section", Section: "name"},
Annotation: AnnotationSpec{Title: "名称", Type: "text"},
}},
}
data, err := MarshalRules(document)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "relations") {
t.Fatalf("rules JSON contains relations: %s", data)
}
parsed, err := ParseRules(data)
if err != nil || parsed.Relations != nil {
t.Fatalf("parsed rules = %#v, err = %v", parsed, err)
}
}
func TestParseLists(t *testing.T) {
lists, err := ParseLists([]byte(`{"version":1,"relations":{"equipment":{"listPath":"equipment/equipment.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"},"物品":{"kind":"union","relations":["装备","道具"]},"装备":{"listPath":"equipment/equipment.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"},"道具":{"listPath":"stackable/stackable.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"}}}`))
if err != nil || lists.Relations["equipment"].ListPath != "equipment/equipment.lst" || lists.Relations["物品"].Kind != "union" {
t.Fatalf("lists = %#v, err = %v", lists, err)
}
}
func TestLoadFileMergesSiblingLists(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "annotations.json")
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[{"id":"ref","target":{"kind":"token","section":"related","index":0},"annotation":{"title":"关联","type":"reference","relation":"equipment"}}]}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "lists.json"), []byte(`{"version":1,"relations":{"equipment":{"listPath":"equipment/equipment.lst","idToken":0,"pathToken":1,"recordTokens":2,"nameSection":"name"}}}`), 0o600); err != nil {
t.Fatal(err)
}
engine, err := LoadFile(rulesPath)
if err != nil {
t.Fatal(err)
}
if _, ok := engine.Relation("equipment"); !ok {
t.Fatal("sibling lists.json was not merged")
}
}
func TestLoadDefaultMergesPathNotes(t *testing.T) {
paths, err := ParseRules(appconfig.PathsJSON)
if err != nil {
t.Fatal(err)
}
if len(paths.Rules) == 0 {
t.Fatal("config/paths.json has no rules")
}
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
merged := make(map[string]bool)
for _, rule := range engine.Document().Rules {
if rule.Target.Kind == "path" {
merged[rule.ID] = true
}
}
for _, rule := range paths.Rules {
if !merged[rule.ID] {
t.Fatalf("path rule %s was not merged", rule.ID)
}
}
tests := []struct {
path string
isDir bool
title string
}{
{"equipment/equipment.lst", false, "装备列表"},
{"equipment", true, "装备"},
{"list/npc.lst", false, "NPC列表"},
{"string", true, "字符串表目录"},
{"etc", true, "杂项文件"},
{"etc/advancealtar", true, "守护者祭坛"},
// A generated directory note describes that directory only: paths without
// a rule of their own stay unannotated instead of inheriting an ancestor.
{"etc/100lvfarming", true, ""},
{"etc/100lvfarming/anything.dat", false, ""},
{"etc/advancealtar/unknown.dat", false, ""},
// The hand-maintained rule in annotations.json keeps the primary title.
{"etc/itemdropinfo_monster_hell.etc", false, "深渊掉落"},
{"unknown/path.dat", false, ""},
}
for _, tc := range tests {
results := engine.AnnotatePath(tc.path, tc.isDir)
title := ""
if len(results) > 0 {
title = results[0].Title
}
if title != tc.title {
t.Fatalf("%s: title = %q, want %q", tc.path, title, tc.title)
}
}
}
func TestLoadFilePrefersSiblingPaths(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "annotations.json")
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "paths.json"), []byte(`{"version":1,"rules":[{"id":"path.sibling","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"本地目录说明","type":"text"},"group":"path"}]}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sections.json"), []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
engine, err := LoadFile(rulesPath)
if err != nil {
t.Fatal(err)
}
results := engine.AnnotatePath("equipment/equipment.lst", false)
if len(results) != 1 || results[0].Title != "本地目录说明" {
t.Fatalf("results = %#v", results)
}
if len(engine.Document().Rules) != 1 {
t.Fatalf("sibling paths.json should replace the built-in path notes, got %d rules", len(engine.Document().Rules))
}
}
func TestLoadFileFallsBackToBuiltinGeneratedRules(t *testing.T) {
dir := t.TempDir()
rulesPath := filepath.Join(dir, "annotations.json")
if err := os.WriteFile(rulesPath, []byte(`{"version":1,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
engine, err := LoadFile(rulesPath)
if err != nil {
t.Fatal(err)
}
if results := engine.AnnotatePath("equipment/equipment.lst", false); len(results) != 1 || results[0].Title != "装备列表" {
t.Fatalf("results = %#v", results)
}
if results := engine.Annotate("a.act", pvf.ParseScriptView("[accelerate]\n100 5"), nil); len(results) != 1 || results[0].Title != "加速参数" {
t.Fatalf("section results = %#v", results)
}
}
func TestLoadDefaultMergesSectionNotes(t *testing.T) {
sections, err := ParseRules(appconfig.SectionsJSON)
if err != nil {
t.Fatal(err)
}
if len(sections.Rules) == 0 {
t.Fatal("config/sections.json has no rules")
}
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
merged := make(map[string]bool)
for _, rule := range engine.Document().Rules {
merged[rule.ID] = true
}
for _, rule := range sections.Rules {
if !merged[rule.ID] {
t.Fatalf("section rule %s was not merged", rule.ID)
}
}
tests := []struct {
file string
text string
want string
}{
// pvfUtility PvfComments notes reach the section tag.
{"a.act", "[accelerate]\n100 5", "加速参数"},
// The rule is extension scoped, so another extension stays untouched.
{"a.equ", "[accelerate]\n100 5", ""},
// A hand-maintained rule keeps the primary title for its section.
{"a.act", "[attack]\n1", "攻击"},
{"a.act", "[unknown section]\n1", ""},
}
for _, tc := range tests {
results := engine.Annotate(tc.file, pvf.ParseScriptView(tc.text), nil)
title := ""
if len(results) > 0 {
title = results[0].Title
}
if title != tc.want {
t.Fatalf("%s %q: title = %q, want %q", tc.file, tc.text, title, tc.want)
}
}
}
// TestLoadDefaultExplainFieldFeedsPreviewWithoutDuplicateTag checks that the
// shared [explain] preview field neither adds a second section tag nor stays
// invisible to the equipment preview.
func TestLoadDefaultExplainFieldFeedsPreviewWithoutDuplicateTag(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
view := pvf.ParseScriptView("[explain]\n`主要说明`")
results := engine.Annotate("equipment/test.equ", view, nil)
if len(results) != 1 || results[0].Title != "装备说明" {
t.Fatalf("results = %#v", results)
}
found := false
for _, field := range engine.PreviewFields("equipment/test.equ", "equ") {
if field.ID == "equ.explain" && field.Preview != nil && field.Preview.Role == "base-explain" {
found = true
}
}
if !found {
t.Fatal("equ.explain preview field is missing")
}
if values := engine.ExtractPreviewFields("equipment/test.equ", view, "equ"); len(values) == 0 {
t.Fatal("[explain] produced no preview value")
}
}
// handWrittenEngine compiles config/annotations.json with its relations but
// without the generated path and section rule sets, for tests that assert the
// behaviour of the curated rules alone.
func handWrittenEngine(t *testing.T) *Engine {
t.Helper()
document, err := ParseRules(appconfig.AnnotationsJSON)
if err != nil {
t.Fatal(err)
}
lists, err := ParseLists(appconfig.ListsJSON)
if err != nil {
t.Fatal(err)
}
document.Relations = lists.Relations
engine, err := Compile(document)
if err != nil {
t.Fatal(err)
}
return engine
}
func TestMergeGeneratedRulesSkipsExistingIDs(t *testing.T) {
document := Document{Version: 1, Rules: []Rule{{
ID: "path.keep", Match: MatchSpec{Glob: "equipment/**"}, Target: TargetSpec{Kind: "path"},
Annotation: AnnotationSpec{Title: "保留", Type: "text"},
}}}
data := []byte(`{"version":1,"rules":[
{"id":"path.keep","match":{"glob":"equipment/**"},"target":{"kind":"path"},"annotation":{"title":"重复","type":"text"}},
{"id":"path.new","match":{"glob":"stackable/**"},"target":{"kind":"path"},"annotation":{"title":"新增","type":"text"}}
]}`)
if err := mergeGeneratedRules(&document, data, "path 标注"); err != nil {
t.Fatal(err)
}
if len(document.Rules) != 2 || document.Rules[0].Annotation.Title != "保留" || document.Rules[1].ID != "path.new" {
t.Fatalf("rules = %#v", document.Rules)
}
if err := mergeGeneratedRules(&document, data, "path 标注"); err != nil {
t.Fatal(err)
}
if len(document.Rules) != 2 {
t.Fatalf("merging twice duplicated rules: %#v", document.Rules)
}
}
+166
View File
@@ -0,0 +1,166 @@
package annotations
import "strings"
type Document struct {
Version int `json:"version"`
Description string `json:"description,omitempty"`
Relations map[string]RelationSpec `json:"relations,omitempty"`
Fields []FieldDefinition `json:"fields,omitempty"`
Rules []Rule `json:"rules"`
}
type ListDocument struct {
Version int `json:"version"`
Description string `json:"description,omitempty"`
Relations map[string]RelationSpec `json:"relations"`
}
type RelationSpec struct {
Kind string `json:"kind,omitempty"`
ListPath string `json:"listPath"`
IDToken int `json:"idToken"`
PathToken int `json:"pathToken"`
RecordTokens int `json:"recordTokens,omitempty"`
NameSection string `json:"nameSection"`
ContextToken int `json:"contextToken,omitempty"`
ContextPaths map[string]string `json:"contextPaths,omitempty"`
Relations []string `json:"relations,omitempty"`
}
type Rule struct {
PVFVersions []string `json:"pvfVersions,omitempty"`
ID string `json:"id"`
Description string `json:"description,omitempty"`
Field string `json:"field,omitempty"`
Match MatchSpec `json:"match"`
Target TargetSpec `json:"target"`
Annotation AnnotationSpec `json:"annotation"`
Group string `json:"group,omitempty"`
}
// FieldDefinition is a reusable field description shared by annotation rules
// and structured previews. A field may optionally expose a PreviewSpec; fields
// without it are still useful to annotations and remain out of previews.
type FieldDefinition struct {
PVFVersions []string `json:"pvfVersions,omitempty"`
ID string `json:"id"`
Match MatchSpec `json:"match"`
Target TargetSpec `json:"target"`
Annotation AnnotationSpec `json:"annotation"`
Preview *PreviewSpec `json:"preview,omitempty"`
}
// PreviewSpec describes how a shared field participates in a preview. The
// parser deliberately treats Group, Role and Format as opaque strings so new
// preview providers can add roles without changing the annotation schema.
type PreviewSpec struct {
// Provider is the backwards-compatible single-provider form.
Provider string `json:"provider,omitempty"`
// Providers allows one shared field to feed multiple preview components.
Providers []string `json:"providers,omitempty"`
Role string `json:"role"`
Group string `json:"group"`
Order int `json:"order"`
Format string `json:"format"`
Label string `json:"label,omitempty"`
}
func (preview *PreviewSpec) providerNames() []string {
if preview == nil {
return nil
}
result := make([]string, 0, len(preview.Providers)+1)
if provider := strings.TrimSpace(preview.Provider); provider != "" {
result = append(result, provider)
}
for _, provider := range preview.Providers {
provider = strings.TrimSpace(provider)
if provider == "" {
continue
}
duplicate := false
for _, existing := range result {
if strings.EqualFold(existing, provider) {
duplicate = true
break
}
}
if !duplicate {
result = append(result, provider)
}
}
return result
}
type MatchSpec struct {
Extensions []string `json:"extensions,omitempty"`
Glob string `json:"glob,omitempty"`
}
type TargetSpec struct {
Kind string `json:"kind"`
Section string `json:"section,omitempty"`
Index *int `json:"index,omitempty"`
Range *TokenRange `json:"range,omitempty"`
// Offset skips the first Offset direct tokens in a section before
// repeated records are matched. It mirrors rendering rules where a
// section header is kept outside the repeated token groups.
Offset int `json:"offset,omitempty"`
RecordTokens int `json:"recordTokens,omitempty"`
// GroupOffset skips this many tokens at the start of every delimiter-separated group.
GroupOffset int `json:"groupOffset,omitempty"`
// StandaloneValues are integer delimiters, excluded from repeated records.
StandaloneValues []int32 `json:"standaloneValues,omitempty"`
// TokensPerLineIndex is a zero-based direct token index whose positive
// integer value overrides RecordTokens for that section. RecordTokens is
// retained as the fallback when the token is missing or invalid.
TokensPerLineIndex *int `json:"tokensPerLineIndex,omitempty"`
ContextIndex *int `json:"contextIndex,omitempty"`
ImagePathToken *int `json:"imagePathToken,omitempty"`
// ImageIndexToken is kept only to migrate rules written by the previous
// schema, where target.index pointed to the IMG path and this field pointed
// to the numeric image index.
ImageIndexToken *int `json:"imageIndexToken,omitempty"`
}
type TokenRange struct {
Start int `json:"start"`
EndExclusive int `json:"endExclusive"`
}
type AnnotationSpec struct {
Title string `json:"title"`
Content string `json:"content,omitempty"`
Type string `json:"type"`
Values map[string]string `json:"values,omitempty"`
Relation string `json:"relation,omitempty"`
PathRoot string `json:"pathRoot,omitempty"`
InlineImage bool `json:"inlineImage,omitempty"`
}
type Reference struct {
ID string
Name string
Path string
FileIndex int32
}
type Resolver func(relation, id string) (Reference, bool)
type Result struct {
Start int `json:"start"`
End int `json:"end"`
Title string `json:"title"`
Content string `json:"content"`
Type string `json:"type"`
TargetFileIndex int32 `json:"targetFileIndex"`
RuleIDs []string `json:"ruleIds,omitempty"`
Image *ImageReference `json:"image,omitempty"`
InlineImage bool `json:"inlineImage,omitempty"`
}
type ImageReference struct {
Path string `json:"path"`
Index int32 `json:"index"`
}
+122
View File
@@ -0,0 +1,122 @@
package annotations
import (
"fmt"
"os"
"path/filepath"
appconfig "pvfine/config"
)
const sourceRelativePath = "config/annotations.json"
// LoadRulesFile reads and validates a hand-maintained rule file together with
// its sibling lists.json, without merging the generated rule sets. The
// annotation editor uses it so a save never writes the bulk path or section
// rules back into annotations.json.
func LoadRulesFile(path string) (Document, error) {
data, err := os.ReadFile(path)
if err != nil {
return Document{}, fmt.Errorf("读取标注规则失败: %w", err)
}
document, err := parseDocument(data)
if err != nil {
return Document{}, err
}
listsPath := filepath.Join(filepath.Dir(path), "lists.json")
if listsData, listsErr := os.ReadFile(listsPath); listsErr == nil {
lists, err := ParseLists(listsData)
if err != nil {
return Document{}, err
}
document.Relations = lists.Relations
} else if !os.IsNotExist(listsErr) {
return Document{}, fmt.Errorf("读取列表配置失败: %w", listsErr)
} else if document.Relations == nil {
lists, err := ParseLists(appconfig.ListsJSON)
if err != nil {
return Document{}, err
}
document.Relations = lists.Relations
}
if err := Validate(document); err != nil {
return Document{}, err
}
return document, nil
}
// LoadFile validates and compiles a rule document from disk. A paths.json or
// sections.json next to the rule file overrides the built-in generated rules, so
// a runtime mirror can ship its own notes.
func LoadFile(path string) (*Engine, error) {
document, err := LoadRulesFile(path)
if err != nil {
return nil, err
}
for _, generated := range generatedRuleFiles() {
data := generated.data
sibling := filepath.Join(filepath.Dir(path), generated.name)
if override, readErr := os.ReadFile(sibling); readErr == nil {
data = override
} else if !os.IsNotExist(readErr) {
return nil, fmt.Errorf("读取 %s 失败: %w", generated.label, readErr)
}
if err := mergeGeneratedRules(&document, data, generated.label); err != nil {
return nil, err
}
}
if err := Validate(document); err != nil {
return nil, err
}
return Compile(document)
}
// RuntimePath is the writable rule mirror used by packaged applications.
func RuntimePath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("获取用户配置目录失败: %w", err)
}
return filepath.Join(configDir, "pvfine", "annotations.json"), nil
}
func RuntimeListsPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("获取用户配置目录失败: %w", err)
}
return filepath.Join(configDir, "pvfine", "lists.json"), nil
}
// FindSourcePath locates the repository rule file for local development.
func FindSourcePath() (string, bool) {
starts := make([]string, 0, 2)
if cwd, err := os.Getwd(); err == nil {
starts = append(starts, cwd)
}
if executable, err := os.Executable(); err == nil {
starts = append(starts, filepath.Dir(executable))
}
seen := make(map[string]struct{})
for _, start := range starts {
for dir := filepath.Clean(start); ; dir = filepath.Dir(dir) {
if _, ok := seen[dir]; !ok {
seen[dir] = struct{}{}
candidate := filepath.Join(dir, filepath.FromSlash(sourceRelativePath))
if fileExists(filepath.Join(dir, "go.mod")) && fileExists(candidate) {
return candidate, true
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
}
}
return "", false
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
@@ -0,0 +1,130 @@
package annotations
import (
"pvfine/internal/pvf"
"strings"
"testing"
)
const upgradeRuleRow = "2.0 0 0 0 0 0.6 0 0.828 20000 30000 2 3 1 0 3037 50 0.6 0.09 0.5 0.169 0.83 0.684 0 0.7866"
func TestDefaultUpgradeRulePositions(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ path, prefix, kind string }{{"etc/upgrade.etc", "etc.upgrade", "normal"}, {"etc/amplifyupgrade.etc", "etc.amplify", "amplify"}} {
text := "[table]\n[table type]\n`" + tc.kind + "` " + upgradeRuleRow + " " + upgradeRuleRow + " 999\n[/table]\n[table]\n[table type]\n`" + tc.kind + "` " + upgradeRuleRow + "\n[/table]\n[safe upgrade]\n0 1 94000 1000 2 10327281 40 124448 1 1 20000 5000 1 10327281 41 124448\n[/safe upgrade]"
view := pvf.ParseScriptView(text)
results := engine.Annotate(tc.path, view, func(relation, id string) (Reference, bool) {
return Reference{ID: id, Name: "材料 " + id, FileIndex: 9}, relation == "物品"
})
checks := map[string]struct {
value, title string
count int
}{
tc.prefix + ".table-type.8": {"20000", "武器失败率", 3},
tc.prefix + ".table-type.9": {"30000", "防具失败率", 3},
tc.prefix + ".table-type.10": {"2", "降级", 3},
tc.prefix + ".table-type.14": {"3037", "材料 3037", 3},
tc.prefix + ".table-type.15": {"50", "消耗数量", 3},
tc.prefix + ".safe-upgrade.5": {"10327281", "材料 10327281", 2},
}
for id, check := range checks {
count := 0
for _, r := range results {
for _, ruleID := range r.RuleIDs {
if ruleID == id {
count++
if text[r.Start:r.End] != check.value || r.Title != check.title {
t.Fatalf("%s = %+v", id, r)
}
}
}
}
if count != check.count {
t.Fatalf("%s matches %d, want %d", id, count, check.count)
}
}
for _, r := range engine.Annotate("etc/unrelated.etc", view, nil) {
for _, id := range r.RuleIDs {
if strings.HasPrefix(id, "etc.upgrade.") || strings.HasPrefix(id, "etc.amplify.") {
t.Fatal("rule leaked to unrelated file")
}
}
}
}
}
func TestDefaultForgeAndLegacyRules(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
text := "[table]\n2 1 10000 1 3 2 5000 1.11 999\n[/table]\n[item weights by grade]\n1 3326 1 2 3326 1 3 3326\n[/item weights by grade]\n[item weights by grade 100lv]\n96 3326 51\n[/item weights by grade 100lv]"
results := engine.Annotate("etc/upgrade_separate.etc", pvf.ParseScriptView(text), nil)
counts := map[string]int{}
for _, r := range results {
for _, id := range r.RuleIDs {
counts[id]++
if id == "etc.forge.table.2" && r.Title != "锻造成功率" {
t.Fatal("wrong forge probability meaning")
}
}
}
for id, want := range map[string]int{"etc.forge.table.2": 2, "etc.forge.item-weights-by-grade.1": 2, "etc.forge.item-weights-by-grade-100lv.1": 1} {
if counts[id] != want {
t.Fatalf("%s matches %d, want %d", id, counts[id], want)
}
}
legacy := "[table]\n2 0 0 0 0 0 12500 2 3 3037 10 1 0 0 0 0 0\n[/table]"
results = engine.Annotate("etc/upgrade.etc", pvf.ParseScriptView(legacy), nil)
found := false
for _, r := range results {
for _, id := range r.RuleIDs {
if id == "etc.upgrade.table.6" {
found = true
if legacy[r.Start:r.End] != "12500" {
t.Fatal("legacy position changed")
}
}
}
}
if !found {
t.Fatal("missing legacy annotation")
}
}
func Test115CNForgeRulePositions(t *testing.T) {
engine, err := LoadDefault()
if err != nil {
t.Fatal(err)
}
text := "[table]\n2 1 2.6 1 10000 1 3 2 3.6 2 5000 1.11\n[/table]"
results := engine.ForVersion("115CN").Annotate("etc/upgrade_separate.etc", pvf.ParseScriptView(text), nil)
probabilities := 0
for _, result := range results {
for _, id := range result.RuleIDs {
if strings.HasPrefix(id, "etc.forge.table.") {
t.Fatal("legacy four-column annotation leaked into 115CN")
}
if id == "etc.forge.115cn.table.4" {
value := text[result.Start:result.End]
if value != "10000" && value != "5000" {
t.Fatal("probability annotation points at a coefficient")
}
probabilities++
}
}
}
if probabilities != 2 {
t.Fatal("six-column probability annotations missing")
}
for _, result := range engine.ForVersion("115US").Annotate("etc/upgrade_separate.etc", pvf.ParseScriptView(text), nil) {
for _, id := range result.RuleIDs {
if strings.HasPrefix(id, "etc.forge.115cn.") {
t.Fatal("115CN annotations leaked into 115US")
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package annotations
import "strings"
// ValidPVFVersion reports whether a value is a supported configuration version.
func ValidPVFVersion(version string) bool {
return version == "90US" || version == "90CN" || version == "110US" || version == "115US" || version == "115CN" || version == "100CN" || version == "TW"
}
func supportsPVFVersion(versions []string, version string) bool {
if len(versions) == 0 {
return true
}
for _, candidate := range versions {
if candidate == version {
return true
}
}
return false
}
// ForVersion builds an immutable runtime view while retaining the full source
// document for serialization and subsequent archive switches. Unknown versions
// only enable unrestricted definitions. Filtering precedes implicit field rules.
func (e *Engine) ForVersion(version string) *Engine {
if e == nil {
return nil
}
if !ValidPVFVersion(version) {
version = ""
}
if e.scoped && e.version == version {
return e
}
source := e.Document()
document := source
document.Fields = make([]FieldDefinition, 0, len(source.Fields))
activeFields := make(map[string]bool, len(source.Fields))
for _, field := range source.Fields {
if supportsPVFVersion(field.PVFVersions, version) {
document.Fields = append(document.Fields, field)
activeFields[field.ID] = true
}
}
document.Rules = make([]Rule, 0, len(source.Rules))
for _, rule := range source.Rules {
if !supportsPVFVersion(rule.PVFVersions, version) {
continue
}
if id := strings.TrimSpace(rule.Field); id != "" && !activeFields[id] {
continue
}
document.Rules = append(document.Rules, rule)
}
result := compileValidated(document)
result.source, result.version, result.scoped = &source, version, true
return result
}
+141
View File
@@ -0,0 +1,141 @@
package annotations
import (
"reflect"
"strings"
"testing"
"pvfine/internal/pvf"
)
func TestPVFVersionRulesAndSourceRoundTrip(t *testing.T) {
document := Document{Version: 1}
for i, versions := range [][]string{nil, {}, {"90US"}, {"90CN", "110US"}, {"115US"}, {"100CN"}, {"TW"}, {"115CN"}} {
document.Rules = append(document.Rules, Rule{ID: string(rune('a' + i)), PVFVersions: versions,
Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: string(rune('a' + i)), Type: "text"}})
}
engine, err := Compile(document)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
version string
want int
}{{"90US", 3}, {"90CN", 3}, {"110US", 3}, {"115US", 3}, {"115CN", 3}, {"", 2}, {"future", 2}, {"100CN", 3}, {"TW", 3}} {
t.Run(tc.version, func(t *testing.T) {
runtime := engine.ForVersion(tc.version)
if got := len(runtime.AnnotatePath("a.equ", false)[0].RuleIDs); got != tc.want {
t.Fatalf("got %d, want %d", got, tc.want)
}
if !reflect.DeepEqual(runtime.Document(), engine.Document()) {
t.Fatal("runtime lost original document")
}
data, err := Marshal(runtime.Document())
if err != nil {
t.Fatal(err)
}
restored, err := Parse(data)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(restored.Rules[3].PVFVersions, []string{"90CN", "110US"}) {
t.Fatal("serialization lost versions")
}
if !reflect.DeepEqual(restored.Rules[4].PVFVersions, []string{"115US"}) {
t.Fatal("serialization lost 115US scope")
}
if got := runtime.ForVersion("115US").AnnotatePath("a.equ", false)[0].RuleIDs; !reflect.DeepEqual(got, []string{"a", "b", "e"}) {
t.Fatalf("115US enabled rules from a different version: %v", got)
}
if got := runtime.ForVersion("100CN").AnnotatePath("a.equ", false)[0].RuleIDs; !reflect.DeepEqual(got, []string{"a", "b", "f"}) {
t.Fatalf("100CN enabled rules from a different version: %v", got)
}
if got := len(runtime.ForVersion("90US").AnnotatePath("a.equ", false)[0].RuleIDs); got != 3 {
t.Fatal("switching versions lost rules")
}
})
}
}
func TestPVFVersionFieldIntersectionAndImplicitFallback(t *testing.T) {
for _, tc := range []struct {
name string
field, rule []string
version string
want string
previews int
}{
{"intersection", []string{"90US", "90CN"}, []string{"90CN", "110US"}, "90CN", "explicit", 1},
{"field excludes rule", []string{"90US", "90CN"}, []string{"90CN", "110US"}, "110US", "", 0},
{"rule excludes field falls back", []string{"90US", "90CN"}, []string{"90CN", "110US"}, "90US", "field:shared", 1},
{"empty intersection", []string{"90US"}, []string{"110US"}, "110US", "", 0},
{"115US intersection", []string{"110US", "115US"}, []string{"115US"}, "115US", "explicit", 1},
{"110US field excludes 115US", []string{"110US"}, []string{"115US"}, "115US", "", 0},
{"115US field excludes 110US", []string{"115US"}, []string{"110US"}, "110US", "", 0},
{"unrestricted rule restricted field", []string{"90CN"}, nil, "90US", "", 0},
{"restricted rule unrestricted field", nil, []string{"110US"}, "90CN", "field:shared", 1},
{"unknown restricted field", []string{"90US"}, nil, "", "", 0},
{"unknown unrestricted field", nil, []string{"90US"}, "", "field:shared", 1},
{"all unrestricted", nil, nil, "", "explicit", 1},
} {
t.Run(tc.name, func(t *testing.T) {
engine, err := Compile(Document{Version: 1,
Fields: []FieldDefinition{{ID: "shared", PVFVersions: tc.field, Target: TargetSpec{Kind: "token", Section: "name", Index: intPtr(0)}, Annotation: AnnotationSpec{Title: "name", Type: "text"}, Preview: &PreviewSpec{Provider: "equ", Role: "name", Group: "header", Format: "text"}}},
Rules: []Rule{{ID: "explicit", Field: "shared", PVFVersions: tc.rule}},
})
if err != nil {
t.Fatal(err)
}
runtime := engine.ForVersion(tc.version)
view := pvf.ParseScriptView("[name]\n`example`")
results := runtime.Annotate("a.equ", view, nil)
if tc.want == "" {
if len(results) != 0 {
t.Fatalf("unexpected results: %#v", results)
}
} else if len(results) != 1 || !reflect.DeepEqual(results[0].RuleIDs, []string{tc.want}) {
t.Fatalf("results = %#v", results)
}
if got := len(runtime.ExtractPreviewFields("a.equ", view, "equ")); got != tc.previews {
t.Fatalf("preview fields = %d", got)
}
})
}
}
func TestPVFVersionNonReferenceRuleDoesNotSuppressField(t *testing.T) {
field := FieldDefinition{ID: "shared", Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "shared", Type: "text"}}
engine, err := Compile(Document{Version: 1, Fields: []FieldDefinition{field}, Rules: []Rule{{ID: "explicit", PVFVersions: []string{"110US"}, Target: field.Target, Annotation: field.Annotation}}})
if err != nil {
t.Fatal(err)
}
for _, version := range []string{"90US", "110US"} {
results := engine.ForVersion(version).AnnotatePath("a.equ", false)
want := "explicit"
if version == "90US" {
want = "field:shared"
}
if len(results) != 1 || !reflect.DeepEqual(results[0].RuleIDs, []string{want}) {
t.Fatalf("%s: %#v", version, results)
}
}
}
func TestPVFVersionValidation(t *testing.T) {
for _, value := range []string{"90us", "unknown", "", "120US"} {
for _, field := range []bool{false, true} {
document := Document{Version: 1}
if field {
document.Fields = []FieldDefinition{{ID: "f", PVFVersions: []string{value}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "f", Type: "text"}}}
} else {
document.Rules = []Rule{{ID: "r", PVFVersions: []string{value}, Target: TargetSpec{Kind: "path"}, Annotation: AnnotationSpec{Title: "r", Type: "text"}}}
}
if _, err := Compile(document); err == nil || !strings.Contains(err.Error(), "pvfVersions") {
t.Fatalf("invalid version accepted: %q, field=%v, err=%v", value, field, err)
}
if _, err := Marshal(document); err == nil {
t.Fatal("invalid version serialized")
}
}
}
}