panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
// Package dayzxml merges mod XML snippets into a running DayZ server's
|
||||
// Central Economy mission XMLs (types.xml, events.xml, cfgspawnabletypes.xml,
|
||||
// cfgeventspawns.xml, cfgrandompresets.xml, messages.xml).
|
||||
//
|
||||
// Why this exists:
|
||||
//
|
||||
// Mods almost always ship "snippet" XMLs — a types.xml with just the mod's new
|
||||
// items, or an events.xml with the mod's new dynamic events. Operators are
|
||||
// expected to merge these into their base mission files. The canonical
|
||||
// approach Bohemia documents is to register each mod's folder via
|
||||
// cfgeconomycore.xml and let the engine merge at load time, but most community
|
||||
// operators still pre-merge into a single file per kind to make debugging,
|
||||
// diffing, and rolling back easier. That's what this package does.
|
||||
//
|
||||
// Merge semantics (per Bohemia wiki "DayZ:Central Economy mission files modding"):
|
||||
//
|
||||
// - types.xml, cfgspawnabletypes.xml, cfgeventspawns.xml, cfgrandompresets.xml
|
||||
// → replace existing entry by `name` attribute; new names appended.
|
||||
// - events.xml
|
||||
// → replace the entry's scalar children; APPEND new <child> nodes (events'
|
||||
// children list is append-only in engine merge behavior — there's no
|
||||
// "remove a child" via merge; operators must redefine the entire event).
|
||||
// - messages.xml
|
||||
// → append-only; messages don't have a unique key and there's no
|
||||
// replace-by-id semantic in the engine.
|
||||
//
|
||||
// We use github.com/beevik/etree rather than Go's encoding/xml so element
|
||||
// order, attribute order, and comments are preserved verbatim. DayZ's
|
||||
// engine doesn't care about order, but operators diff these files by hand
|
||||
// and expect them to stay stable across panel edits.
|
||||
|
||||
package dayzxml
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
)
|
||||
|
||||
// FileKind identifies a DayZ CE XML file type whose merge semantics we know.
|
||||
type FileKind string
|
||||
|
||||
const (
|
||||
KindTypes FileKind = "types" // types.xml
|
||||
KindSpawnableTypes FileKind = "spawnabletypes" // cfgspawnabletypes.xml
|
||||
KindEvents FileKind = "events" // events.xml
|
||||
KindEventSpawns FileKind = "eventposdef" // cfgeventspawns.xml
|
||||
KindRandomPresets FileKind = "randompresets" // cfgrandompresets.xml
|
||||
KindMessages FileKind = "messages" // messages.xml
|
||||
)
|
||||
|
||||
// MergeMode controls what happens when a snippet entry collides with an existing one.
|
||||
type MergeMode int
|
||||
|
||||
const (
|
||||
// ReplaceByName overwrites the existing entry wholesale.
|
||||
ReplaceByName MergeMode = iota
|
||||
// AppendOnly ignores the existing entry and just appends the snippet's copy.
|
||||
// Used for files without a unique key (messages.xml).
|
||||
AppendOnly
|
||||
// EventsMerge replaces the entry's scalar attributes + non-child descendants,
|
||||
// and APPENDS <child> nodes from the snippet that don't already exist in
|
||||
// base (matched by `type=` attribute). Matches engine behavior for events.xml.
|
||||
EventsMerge
|
||||
)
|
||||
|
||||
// Spec describes how to identify and merge entries in one file kind.
|
||||
type Spec struct {
|
||||
RootTag string // expected root element tag ("types", "events", ...)
|
||||
EntryTag string // entry element tag ("type", "event"); empty = heterogeneous
|
||||
KeyAttr string // attribute that identifies an entry ("name"); empty = no key
|
||||
Mode MergeMode // how to resolve collisions
|
||||
EntryTags []string // for heterogeneous files (randompresets: "cargo"/"attachments")
|
||||
}
|
||||
|
||||
// defaultSpecs holds the canonical spec per kind. Callers should treat
|
||||
// these as read-only.
|
||||
var defaultSpecs = map[FileKind]Spec{
|
||||
KindTypes: {RootTag: "types", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
|
||||
KindSpawnableTypes: {RootTag: "spawnabletypes", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
|
||||
KindEvents: {RootTag: "events", EntryTag: "event", KeyAttr: "name", Mode: EventsMerge},
|
||||
KindEventSpawns: {RootTag: "eventposdef", EntryTag: "event", KeyAttr: "name", Mode: ReplaceByName},
|
||||
KindRandomPresets: {RootTag: "randompresets", KeyAttr: "name", Mode: ReplaceByName, EntryTags: []string{"cargo", "attachments"}},
|
||||
KindMessages: {RootTag: "messages", EntryTag: "message", Mode: AppendOnly},
|
||||
}
|
||||
|
||||
// SpecFor returns the default Spec for a file kind, or zero + false if unknown.
|
||||
func SpecFor(k FileKind) (Spec, bool) {
|
||||
s, ok := defaultSpecs[k]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// SupportedKinds returns all FileKinds this package knows how to merge, in
|
||||
// a stable order suitable for UI lists.
|
||||
func SupportedKinds() []FileKind {
|
||||
return []FileKind{
|
||||
KindTypes, KindSpawnableTypes, KindEvents, KindEventSpawns,
|
||||
KindRandomPresets, KindMessages,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan is the outcome of a (possibly dry-run) merge.
|
||||
type Plan struct {
|
||||
Kind FileKind `json:"kind"`
|
||||
Added []string `json:"added"` // new entry names appended
|
||||
Replaced []string `json:"replaced"` // existing names overwritten
|
||||
Unchanged []string `json:"unchanged"` // snippet entries byte-identical to base
|
||||
AppendedChildren map[string][]string `json:"appended_children,omitempty"` // event name → child types added
|
||||
Conflicts []string `json:"conflicts,omitempty"` // snippet has same key twice; first wins
|
||||
Warnings []string `json:"warnings,omitempty"` // non-fatal (BOM stripped, EOL adjusted, etc.)
|
||||
}
|
||||
|
||||
// Result is returned by Merge. Bytes is the merged XML (empty in dry-run mode).
|
||||
type Result struct {
|
||||
Plan Plan
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// DetectKind classifies a raw snippet by its root element tag. Returns
|
||||
// the matching FileKind or an error if the root doesn't match any known spec.
|
||||
func DetectKind(snippet []byte) (FileKind, error) {
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(stripBOM(snippet)); err != nil {
|
||||
return "", fmt.Errorf("parse snippet: %w", err)
|
||||
}
|
||||
root := doc.Root()
|
||||
if root == nil {
|
||||
return "", fmt.Errorf("snippet has no root element")
|
||||
}
|
||||
for k, s := range defaultSpecs {
|
||||
if s.RootTag == root.Tag {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unknown root element %q — not a supported DayZ CE XML", root.Tag)
|
||||
}
|
||||
|
||||
// Merge applies the snippet to the base file. If dryRun is true, only the
|
||||
// Plan is populated (Bytes is nil). Otherwise Bytes contains the merged
|
||||
// XML, ready to write.
|
||||
func Merge(kind FileKind, base, snippet []byte, dryRun bool) (*Result, error) {
|
||||
spec, ok := defaultSpecs[kind]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported file kind %q", kind)
|
||||
}
|
||||
baseDoc, warnings, err := parseDoc(base, "base")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snipDoc, snipWarnings, err := parseDoc(snippet, "snippet")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warnings = append(warnings, snipWarnings...)
|
||||
|
||||
baseRoot := baseDoc.Root()
|
||||
if baseRoot == nil || baseRoot.Tag != spec.RootTag {
|
||||
return nil, fmt.Errorf("base is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(baseDoc))
|
||||
}
|
||||
snipRoot := snipDoc.Root()
|
||||
if snipRoot == nil || snipRoot.Tag != spec.RootTag {
|
||||
return nil, fmt.Errorf("snippet is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(snipDoc))
|
||||
}
|
||||
|
||||
plan := Plan{
|
||||
Kind: kind,
|
||||
AppendedChildren: map[string][]string{},
|
||||
}
|
||||
|
||||
// Index base entries by key, preserving insertion order.
|
||||
baseIndex, baseOrder := indexEntries(baseRoot, spec)
|
||||
// Walk the snippet in order.
|
||||
seenInSnippet := map[string]bool{}
|
||||
|
||||
for _, child := range snipRoot.ChildElements() {
|
||||
if !matchesEntry(child, spec) {
|
||||
continue
|
||||
}
|
||||
key := entryKey(child, spec)
|
||||
if spec.Mode == AppendOnly || key == "" {
|
||||
// No dedup; append unconditionally.
|
||||
baseRoot.AddChild(child.Copy())
|
||||
plan.Added = append(plan.Added, fmt.Sprintf("%s#%d", child.Tag, len(plan.Added)+1))
|
||||
continue
|
||||
}
|
||||
if seenInSnippet[key] {
|
||||
plan.Conflicts = append(plan.Conflicts, key)
|
||||
continue
|
||||
}
|
||||
seenInSnippet[key] = true
|
||||
existing, exists := baseIndex[key]
|
||||
if !exists {
|
||||
baseRoot.AddChild(child.Copy())
|
||||
plan.Added = append(plan.Added, key)
|
||||
continue
|
||||
}
|
||||
// Collision — resolve per mode.
|
||||
switch spec.Mode {
|
||||
case ReplaceByName:
|
||||
if elementsEqualish(existing, child) {
|
||||
plan.Unchanged = append(plan.Unchanged, key)
|
||||
continue
|
||||
}
|
||||
replaceElement(baseRoot, existing, child)
|
||||
baseIndex[key] = child.Copy()
|
||||
plan.Replaced = append(plan.Replaced, key)
|
||||
case EventsMerge:
|
||||
appended, changed := mergeEventChildren(existing, child)
|
||||
if !changed {
|
||||
plan.Unchanged = append(plan.Unchanged, key)
|
||||
continue
|
||||
}
|
||||
plan.Replaced = append(plan.Replaced, key)
|
||||
if len(appended) > 0 {
|
||||
plan.AppendedChildren[key] = appended
|
||||
}
|
||||
}
|
||||
_ = baseOrder // preserved via baseRoot ordering
|
||||
}
|
||||
|
||||
// Stable-sort output slices for deterministic UI.
|
||||
sort.Strings(plan.Added)
|
||||
sort.Strings(plan.Replaced)
|
||||
sort.Strings(plan.Unchanged)
|
||||
sort.Strings(plan.Conflicts)
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
|
||||
res := &Result{Plan: plan}
|
||||
if dryRun {
|
||||
return res, nil
|
||||
}
|
||||
baseDoc.Indent(4)
|
||||
out, err := baseDoc.WriteToBytes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render merged xml: %w", err)
|
||||
}
|
||||
// Preserve the base file's line-ending convention.
|
||||
if bytes.Contains(base, []byte("\r\n")) && !bytes.Contains(out, []byte("\r\n")) {
|
||||
out = bytes.ReplaceAll(out, []byte("\n"), []byte("\r\n"))
|
||||
}
|
||||
res.Bytes = out
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func parseDoc(raw []byte, label string) (*etree.Document, []string, error) {
|
||||
warnings := []string{}
|
||||
clean, bomStripped := stripBOMWithFlag(raw)
|
||||
if bomStripped {
|
||||
warnings = append(warnings, label+": stripped UTF-8 BOM")
|
||||
}
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(clean); err != nil {
|
||||
return nil, warnings, fmt.Errorf("%s: parse: %w", label, err)
|
||||
}
|
||||
return doc, warnings, nil
|
||||
}
|
||||
|
||||
func safeRootTag(d *etree.Document) string {
|
||||
if r := d.Root(); r != nil {
|
||||
return r.Tag
|
||||
}
|
||||
return "(no-root)"
|
||||
}
|
||||
|
||||
// matchesEntry reports whether child is one of the entry tags this spec considers.
|
||||
func matchesEntry(child *etree.Element, spec Spec) bool {
|
||||
if len(spec.EntryTags) > 0 {
|
||||
for _, t := range spec.EntryTags {
|
||||
if child.Tag == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return spec.EntryTag != "" && child.Tag == spec.EntryTag
|
||||
}
|
||||
|
||||
// entryKey returns the merge key for a child element (empty string if no key).
|
||||
func entryKey(child *etree.Element, spec Spec) string {
|
||||
if spec.KeyAttr == "" {
|
||||
return ""
|
||||
}
|
||||
return child.SelectAttrValue(spec.KeyAttr, "")
|
||||
}
|
||||
|
||||
// indexEntries scans root and builds a name→element map of mergeable entries.
|
||||
// Also returns the insertion-order list (useful for deterministic diffs).
|
||||
func indexEntries(root *etree.Element, spec Spec) (map[string]*etree.Element, []string) {
|
||||
m := make(map[string]*etree.Element)
|
||||
order := make([]string, 0, 64)
|
||||
for _, child := range root.ChildElements() {
|
||||
if !matchesEntry(child, spec) {
|
||||
continue
|
||||
}
|
||||
k := entryKey(child, spec)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := m[k]; !dup {
|
||||
order = append(order, k)
|
||||
}
|
||||
m[k] = child
|
||||
}
|
||||
return m, order
|
||||
}
|
||||
|
||||
// replaceElement swaps old in-place with a copy of repl, preserving sibling order.
|
||||
func replaceElement(parent, old, repl *etree.Element) {
|
||||
newEl := repl.Copy()
|
||||
// etree doesn't have an "insert at index"; replicate by collecting children
|
||||
// around `old`, removing all, and re-adding.
|
||||
children := parent.ChildElements()
|
||||
idx := -1
|
||||
for i, c := range children {
|
||||
if c == old {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
parent.AddChild(newEl)
|
||||
return
|
||||
}
|
||||
// Remove old and rebuild tail ordering.
|
||||
parent.RemoveChild(old)
|
||||
// If we removed from the end, AddChild appends at the end — correct.
|
||||
// Otherwise we need to move the trailing children back after newEl. etree's
|
||||
// AddChild appends, so we re-add newEl then move the original tail past it.
|
||||
tail := children[idx+1:]
|
||||
for _, t := range tail {
|
||||
parent.RemoveChild(t)
|
||||
}
|
||||
parent.AddChild(newEl)
|
||||
for _, t := range tail {
|
||||
parent.AddChild(t)
|
||||
}
|
||||
}
|
||||
|
||||
// elementsEqualish is a lenient equality check: same tag, same attrs
|
||||
// (set-compare, order-insensitive), same text content, same child-element
|
||||
// tree by signature dump.
|
||||
func elementsEqualish(a, b *etree.Element) bool {
|
||||
if a.Tag != b.Tag {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(a.Text()) != strings.TrimSpace(b.Text()) {
|
||||
return false
|
||||
}
|
||||
if len(a.Attr) != len(b.Attr) {
|
||||
return false
|
||||
}
|
||||
aAttrs := attrMap(a)
|
||||
bAttrs := attrMap(b)
|
||||
for k, v := range aAttrs {
|
||||
if bAttrs[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return childrenSignature(a) == childrenSignature(b)
|
||||
}
|
||||
|
||||
func attrMap(e *etree.Element) map[string]string {
|
||||
m := make(map[string]string, len(e.Attr))
|
||||
for _, a := range e.Attr {
|
||||
m[a.Key] = a.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func childrenSignature(e *etree.Element) string {
|
||||
var b strings.Builder
|
||||
for _, c := range e.ChildElements() {
|
||||
b.WriteString(c.Tag)
|
||||
b.WriteByte('{')
|
||||
keys := make([]string, 0, len(c.Attr))
|
||||
am := attrMap(c)
|
||||
for k := range am {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(am[k])
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(childrenSignature(c))
|
||||
b.WriteByte('}')
|
||||
t := strings.TrimSpace(c.Text())
|
||||
if t != "" {
|
||||
b.WriteString("#")
|
||||
b.WriteString(t)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mergeEventChildren implements events.xml's special semantics: replace the
|
||||
// scalar attrs + non-children descendants of `base` with those of `snippet`,
|
||||
// then append any `<child type="...">` from snippet whose `type` isn't
|
||||
// already present in base. Returns (appended child types, changed?).
|
||||
func mergeEventChildren(base, snippet *etree.Element) ([]string, bool) {
|
||||
changed := false
|
||||
// 1. Overwrite attributes on the event element.
|
||||
if !sameAttrs(base, snippet) {
|
||||
for _, a := range base.Attr {
|
||||
base.RemoveAttr(a.Key)
|
||||
}
|
||||
for _, a := range snippet.Attr {
|
||||
base.CreateAttr(a.Key, a.Value)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
// 2. Replace scalar direct children (non-<children>) wholesale.
|
||||
for _, sChild := range snippet.ChildElements() {
|
||||
if sChild.Tag == "children" {
|
||||
continue
|
||||
}
|
||||
// find matching scalar in base (by tag)
|
||||
if bChild := base.SelectElement(sChild.Tag); bChild != nil {
|
||||
if !elementsEqualish(bChild, sChild) {
|
||||
base.RemoveChild(bChild)
|
||||
base.AddChild(sChild.Copy())
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
base.AddChild(sChild.Copy())
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// 3. Append new <child type=...> entries from snippet's <children>.
|
||||
var appended []string
|
||||
sChildren := snippet.SelectElement("children")
|
||||
if sChildren == nil {
|
||||
return appended, changed
|
||||
}
|
||||
bChildren := base.SelectElement("children")
|
||||
if bChildren == nil {
|
||||
bChildren = base.CreateElement("children")
|
||||
changed = true
|
||||
}
|
||||
existingTypes := map[string]bool{}
|
||||
for _, c := range bChildren.ChildElements() {
|
||||
if t := c.SelectAttrValue("type", ""); t != "" {
|
||||
existingTypes[t] = true
|
||||
}
|
||||
}
|
||||
for _, sChildEl := range sChildren.ChildElements() {
|
||||
t := sChildEl.SelectAttrValue("type", "")
|
||||
if t == "" || existingTypes[t] {
|
||||
continue
|
||||
}
|
||||
bChildren.AddChild(sChildEl.Copy())
|
||||
existingTypes[t] = true
|
||||
appended = append(appended, t)
|
||||
changed = true
|
||||
}
|
||||
return appended, changed
|
||||
}
|
||||
|
||||
func sameAttrs(a, b *etree.Element) bool {
|
||||
if len(a.Attr) != len(b.Attr) {
|
||||
return false
|
||||
}
|
||||
am := attrMap(a)
|
||||
for _, at := range b.Attr {
|
||||
if am[at.Key] != at.Value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stripBOM returns input with a leading UTF-8 BOM removed (if present).
|
||||
func stripBOM(b []byte) []byte {
|
||||
out, _ := stripBOMWithFlag(b)
|
||||
return out
|
||||
}
|
||||
|
||||
func stripBOMWithFlag(b []byte) ([]byte, bool) {
|
||||
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||
return b[3:], true
|
||||
}
|
||||
return b, false
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package dayzxml
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectKind(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want FileKind
|
||||
}{
|
||||
{`<types><type name="X"/></types>`, KindTypes},
|
||||
{`<events><event name="X"/></events>`, KindEvents},
|
||||
{`<spawnabletypes><type name="X"/></spawnabletypes>`, KindSpawnableTypes},
|
||||
{`<messages><message/></messages>`, KindMessages},
|
||||
{`<randompresets><cargo name="X"/></randompresets>`, KindRandomPresets},
|
||||
{`<eventposdef><event name="X"/></eventposdef>`, KindEventSpawns},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := DetectKind([]byte(c.in))
|
||||
if err != nil {
|
||||
t.Errorf("DetectKind(%q) err: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("DetectKind(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := DetectKind([]byte(`<foo/>`)); err == nil {
|
||||
t.Error("expected error for unknown root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeTypes_AddAndReplace(t *testing.T) {
|
||||
base := []byte(`<types>
|
||||
<type name="AKM"><nominal>5</nominal><min>2</min></type>
|
||||
<type name="M4A1"><nominal>3</nominal><min>1</min></type>
|
||||
</types>`)
|
||||
snippet := []byte(`<types>
|
||||
<type name="AKM"><nominal>8</nominal><min>4</min></type>
|
||||
<type name="NewItem"><nominal>1</nominal><min>1</min></type>
|
||||
</types>`)
|
||||
res, err := Merge(KindTypes, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Plan.Added) != 1 || res.Plan.Added[0] != "NewItem" {
|
||||
t.Errorf("Added = %v, want [NewItem]", res.Plan.Added)
|
||||
}
|
||||
if len(res.Plan.Replaced) != 1 || res.Plan.Replaced[0] != "AKM" {
|
||||
t.Errorf("Replaced = %v, want [AKM]", res.Plan.Replaced)
|
||||
}
|
||||
out := string(res.Bytes)
|
||||
if !strings.Contains(out, `name="NewItem"`) {
|
||||
t.Error("merged output missing NewItem")
|
||||
}
|
||||
if !strings.Contains(out, `<nominal>8</nominal>`) {
|
||||
t.Error("merged output missing updated AKM nominal value")
|
||||
}
|
||||
if !strings.Contains(out, `name="M4A1"`) {
|
||||
t.Error("merged output dropped untouched M4A1 entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeTypes_Unchanged(t *testing.T) {
|
||||
xml := []byte(`<types>
|
||||
<type name="AKM"><nominal>5</nominal></type>
|
||||
</types>`)
|
||||
res, err := Merge(KindTypes, xml, xml, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Plan.Unchanged) != 1 || res.Plan.Unchanged[0] != "AKM" {
|
||||
t.Errorf("Unchanged = %v, want [AKM]", res.Plan.Unchanged)
|
||||
}
|
||||
if len(res.Plan.Replaced) != 0 {
|
||||
t.Errorf("Replaced = %v, want []", res.Plan.Replaced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEvents_AppendsChildren(t *testing.T) {
|
||||
base := []byte(`<events>
|
||||
<event name="VehicleSpawn">
|
||||
<nominal>3</nominal>
|
||||
<children>
|
||||
<child type="UAZ_Base" min="1" max="1"/>
|
||||
</children>
|
||||
</event>
|
||||
</events>`)
|
||||
snippet := []byte(`<events>
|
||||
<event name="VehicleSpawn">
|
||||
<nominal>5</nominal>
|
||||
<children>
|
||||
<child type="UAZ_Mod1" min="1" max="1"/>
|
||||
<child type="UAZ_Base" min="1" max="1"/>
|
||||
</children>
|
||||
</event>
|
||||
</events>`)
|
||||
res, err := Merge(KindEvents, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(res.Bytes)
|
||||
// nominal overridden
|
||||
if !strings.Contains(out, `<nominal>5</nominal>`) {
|
||||
t.Error("event scalar child <nominal> not overridden")
|
||||
}
|
||||
// new child appended, existing child not duplicated
|
||||
if strings.Count(out, `type="UAZ_Base"`) != 1 {
|
||||
t.Errorf("existing child duplicated; output:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `type="UAZ_Mod1"`) {
|
||||
t.Error("new child not appended")
|
||||
}
|
||||
if got := res.Plan.AppendedChildren["VehicleSpawn"]; len(got) != 1 || got[0] != "UAZ_Mod1" {
|
||||
t.Errorf("AppendedChildren[VehicleSpawn] = %v, want [UAZ_Mod1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMessages_AppendOnly(t *testing.T) {
|
||||
base := []byte(`<messages>
|
||||
<message><id>1</id><text>Welcome</text></message>
|
||||
</messages>`)
|
||||
snippet := []byte(`<messages>
|
||||
<message><id>2</id><text>Restart in 10m</text></message>
|
||||
</messages>`)
|
||||
res, err := Merge(KindMessages, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Plan.Added) != 1 {
|
||||
t.Errorf("Added count = %d, want 1", len(res.Plan.Added))
|
||||
}
|
||||
if !strings.Contains(string(res.Bytes), "Restart in 10m") {
|
||||
t.Error("appended message not present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripBOM(t *testing.T) {
|
||||
bom := []byte{0xEF, 0xBB, 0xBF}
|
||||
raw := append(bom, []byte(`<types><type name="X"/></types>`)...)
|
||||
k, err := DetectKind(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k != KindTypes {
|
||||
t.Errorf("kind = %v, want %v", k, KindTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootMismatch(t *testing.T) {
|
||||
base := []byte(`<types></types>`)
|
||||
snippet := []byte(`<events></events>`)
|
||||
if _, err := Merge(KindTypes, base, snippet, true); err == nil {
|
||||
t.Error("expected error when snippet root doesn't match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveCRLF(t *testing.T) {
|
||||
base := []byte("<types>\r\n <type name=\"AKM\"><nominal>5</nominal></type>\r\n</types>")
|
||||
snippet := []byte(`<types><type name="NEW"><nominal>1</nominal></type></types>`)
|
||||
res, err := Merge(KindTypes, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(res.Bytes), "\r\n") {
|
||||
t.Error("output dropped CRLF line endings; base had CRLF")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user