From a0c5a5f18c2ca44c112dbb6588402fbc6f4476fd Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Fri, 19 Dec 2025 11:36:03 +0100 Subject: [PATCH] Hallucinate up an xml parser implementation Who knows if this will work... --- processor/xml.go | 447 +++++++++++++++++++++ processor/xml_integration_test.go | 346 +++++++++++++++++ processor/xml_test.go | 621 ++++++++++++++++++++++++++++++ 3 files changed, 1414 insertions(+) create mode 100644 processor/xml.go create mode 100644 processor/xml_integration_test.go create mode 100644 processor/xml_test.go diff --git a/processor/xml.go b/processor/xml.go new file mode 100644 index 0000000..aba001d --- /dev/null +++ b/processor/xml.go @@ -0,0 +1,447 @@ +package processor + +import ( + "cook/utils" + "encoding/xml" + "fmt" + "io" + "sort" + "strconv" + "strings" + + logger "git.site.quack-lab.dev/dave/cylogger" +) + +var xmlLogger = logger.Default.WithPrefix("processor/xml") + +// XMLElement represents a parsed XML element with position tracking +type XMLElement struct { + Tag string + Attributes map[string]XMLAttribute + Text string + Children []*XMLElement + StartPos int64 + EndPos int64 + TextStart int64 + TextEnd int64 +} + +// XMLAttribute represents an attribute with its position in the source +type XMLAttribute struct { + Value string + ValueStart int64 + ValueEnd int64 +} + +// parseXMLWithPositions parses XML while tracking byte positions of all elements and attributes +func parseXMLWithPositions(content string) (*XMLElement, error) { + decoder := xml.NewDecoder(strings.NewReader(content)) + var root *XMLElement + var stack []*XMLElement + var lastPos int64 + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to parse XML: %v", err) + } + + offset := decoder.InputOffset() + + switch t := token.(type) { + case xml.StartElement: + // Find the actual start position of this element by searching for " 0 { + tagEnd := offset + tagSection := content[startPos:tagEnd] + + for _, attr := range t.Attr { + // Find attribute in the tag section: attrname="value" + attrPattern := attr.Name.Local + `="` + attrIdx := strings.Index(tagSection, attrPattern) + if attrIdx >= 0 { + valueStart := startPos + int64(attrIdx) + int64(len(attrPattern)) + valueEnd := valueStart + int64(len(attr.Value)) + element.Attributes[attr.Name.Local] = XMLAttribute{ + Value: attr.Value, + ValueStart: valueStart, + ValueEnd: valueEnd, + } + } + } + } + + if len(stack) > 0 { + parent := stack[len(stack)-1] + parent.Children = append(parent.Children, element) + } else { + root = element + } + + stack = append(stack, element) + lastPos = offset + + case xml.CharData: + rawText := string(t) + text := strings.TrimSpace(rawText) + if len(stack) > 0 && text != "" { + current := stack[len(stack)-1] + current.Text = text + + // The text content is between lastPos (after >) and offset (before = 0 { + current.TextStart = lastPos + int64(trimmedStart) + current.TextEnd = current.TextStart + int64(len(text)) + } + } + lastPos = offset + + case xml.EndElement: + if len(stack) > 0 { + current := stack[len(stack)-1] + current.EndPos = offset + stack = stack[:len(stack)-1] + } + lastPos = offset + } + } + + return root, nil +} + +// xmlElementToMap converts XMLElement to a map for comparison +func xmlElementToMap(elem *XMLElement) map[string]interface{} { + result := make(map[string]interface{}) + result["_tag"] = elem.Tag + + if len(elem.Attributes) > 0 { + attrs := make(map[string]interface{}) + for k, v := range elem.Attributes { + attrs[k] = v.Value + } + result["_attr"] = attrs + } + + if elem.Text != "" { + result["_text"] = elem.Text + } + + if len(elem.Children) > 0 { + children := make([]interface{}, len(elem.Children)) + for i, child := range elem.Children { + children[i] = xmlElementToMap(child) + } + result["_children"] = children + } + + return result +} + +// XMLChange represents a detected difference between original and modified XML structures +type XMLChange struct { + Type string // "text", "attribute", "add_element", "remove_element" + Path string + OldValue string + NewValue string + StartPos int64 + EndPos int64 + InsertText string +} + +func findXMLChanges(original, modified *XMLElement, path string) []XMLChange { + var changes []XMLChange + + // Check text content changes + if original.Text != modified.Text { + changes = append(changes, XMLChange{ + Type: "text", + Path: path, + OldValue: original.Text, + NewValue: modified.Text, + StartPos: original.TextStart, + EndPos: original.TextEnd, + }) + } + + // Check attribute changes + for attrName, origAttr := range original.Attributes { + if modAttr, exists := modified.Attributes[attrName]; exists { + if origAttr.Value != modAttr.Value { + changes = append(changes, XMLChange{ + Type: "attribute", + Path: path + "/@" + attrName, + OldValue: origAttr.Value, + NewValue: modAttr.Value, + StartPos: origAttr.ValueStart, + EndPos: origAttr.ValueEnd, + }) + } + } else { + // Attribute removed + changes = append(changes, XMLChange{ + Type: "remove_attribute", + Path: path + "/@" + attrName, + OldValue: origAttr.Value, + StartPos: origAttr.ValueStart - int64(len(attrName)+2), // Include attr=" part + EndPos: origAttr.ValueEnd + 1, // Include closing " + }) + } + } + + // Check for added attributes + for attrName, modAttr := range modified.Attributes { + if _, exists := original.Attributes[attrName]; !exists { + changes = append(changes, XMLChange{ + Type: "add_attribute", + Path: path + "/@" + attrName, + NewValue: modAttr.Value, + StartPos: original.StartPos, // Will be adjusted to insert after tag name + InsertText: fmt.Sprintf(` %s="%s"`, attrName, modAttr.Value), + }) + } + } + + // Check children recursively + origChildMap := make(map[string][]*XMLElement) + for _, child := range original.Children { + origChildMap[child.Tag] = append(origChildMap[child.Tag], child) + } + + modChildMap := make(map[string][]*XMLElement) + for _, child := range modified.Children { + modChildMap[child.Tag] = append(modChildMap[child.Tag], child) + } + + // Compare children by tag name + processedTags := make(map[string]bool) + + for tag, origChildren := range origChildMap { + processedTags[tag] = true + modChildren := modChildMap[tag] + + // Match children by index + maxLen := len(origChildren) + if len(modChildren) > maxLen { + maxLen = len(modChildren) + } + + for i := 0; i < maxLen; i++ { + childPath := fmt.Sprintf("%s/%s[%d]", path, tag, i) + if i < len(origChildren) && i < len(modChildren) { + // Both exist, compare recursively + childChanges := findXMLChanges(origChildren[i], modChildren[i], childPath) + changes = append(changes, childChanges...) + } else if i < len(origChildren) { + // Child removed + changes = append(changes, XMLChange{ + Type: "remove_element", + Path: childPath, + StartPos: origChildren[i].StartPos, + EndPos: origChildren[i].EndPos, + }) + } + } + + // Handle added children + if len(modChildren) > len(origChildren) { + for i := len(origChildren); i < len(modChildren); i++ { + childPath := fmt.Sprintf("%s/%s[%d]", path, tag, i) + // Generate XML text for the new element + xmlText := serializeXMLElement(modChildren[i], " ") + changes = append(changes, XMLChange{ + Type: "add_element", + Path: childPath, + InsertText: xmlText, + StartPos: original.EndPos - int64(len(original.Tag)+3), // Before closing tag + }) + } + } + } + + // Handle completely new tag types + for tag, modChildren := range modChildMap { + if !processedTags[tag] { + for i, child := range modChildren { + childPath := fmt.Sprintf("%s/%s[%d]", path, tag, i) + xmlText := serializeXMLElement(child, " ") + changes = append(changes, XMLChange{ + Type: "add_element", + Path: childPath, + InsertText: xmlText, + StartPos: original.EndPos - int64(len(original.Tag)+3), + }) + } + } + } + + return changes +} + +// serializeXMLElement converts an XMLElement back to XML text +func serializeXMLElement(elem *XMLElement, indent string) string { + var sb strings.Builder + sb.WriteString(indent) + sb.WriteString("<") + sb.WriteString(elem.Tag) + + // Write attributes + attrNames := make([]string, 0, len(elem.Attributes)) + for name := range elem.Attributes { + attrNames = append(attrNames, name) + } + sort.Strings(attrNames) + + for _, name := range attrNames { + attr := elem.Attributes[name] + sb.WriteString(fmt.Sprintf(` %s="%s"`, name, attr.Value)) + } + + if elem.Text == "" && len(elem.Children) == 0 { + sb.WriteString(" />") + return sb.String() + } + + sb.WriteString(">") + + if elem.Text != "" { + sb.WriteString(elem.Text) + } + + if len(elem.Children) > 0 { + sb.WriteString("\n") + for _, child := range elem.Children { + sb.WriteString(serializeXMLElement(child, indent+" ")) + sb.WriteString("\n") + } + sb.WriteString(indent) + } + + sb.WriteString("") + + return sb.String() +} + +// applyXMLChanges generates ReplaceCommands from detected XML changes +func applyXMLChanges(changes []XMLChange) []utils.ReplaceCommand { + var commands []utils.ReplaceCommand + + for _, change := range changes { + switch change.Type { + case "text": + commands = append(commands, utils.ReplaceCommand{ + From: int(change.StartPos), + To: int(change.EndPos), + With: change.NewValue, + }) + + case "attribute": + commands = append(commands, utils.ReplaceCommand{ + From: int(change.StartPos), + To: int(change.EndPos), + With: change.NewValue, + }) + + case "add_attribute": + // Insert after tag name, before > or /> + commands = append(commands, utils.ReplaceCommand{ + From: int(change.StartPos), + To: int(change.StartPos), + With: change.InsertText, + }) + + case "remove_attribute": + commands = append(commands, utils.ReplaceCommand{ + From: int(change.StartPos), + To: int(change.EndPos), + With: "", + }) + + case "add_element": + commands = append(commands, utils.ReplaceCommand{ + From: int(change.StartPos), + To: int(change.StartPos), + With: "\n" + change.InsertText, + }) + + case "remove_element": + commands = append(commands, utils.ReplaceCommand{ + From: int(change.StartPos), + To: int(change.EndPos), + With: "", + }) + } + } + + return commands +} + +// modifyXMLElement applies modifications to an XMLElement based on a modification function +func modifyXMLElement(elem *XMLElement, modifyFunc func(*XMLElement)) *XMLElement { + // Deep copy the element + copied := deepCopyXMLElement(elem) + modifyFunc(copied) + return copied +} + +// deepCopyXMLElement creates a deep copy of an XMLElement +func deepCopyXMLElement(elem *XMLElement) *XMLElement { + if elem == nil { + return nil + } + + copied := &XMLElement{ + Tag: elem.Tag, + Text: elem.Text, + StartPos: elem.StartPos, + EndPos: elem.EndPos, + TextStart: elem.TextStart, + TextEnd: elem.TextEnd, + Attributes: make(map[string]XMLAttribute), + Children: make([]*XMLElement, len(elem.Children)), + } + + for k, v := range elem.Attributes { + copied.Attributes[k] = v + } + + for i, child := range elem.Children { + copied.Children[i] = deepCopyXMLElement(child) + } + + return copied +} + +// Helper function to parse numeric values +func parseNumeric(s string) (float64, bool) { + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f, true + } + return 0, false +} + +// Helper function to format numeric values +func formatNumeric(f float64) string { + if f == float64(int64(f)) { + return strconv.FormatInt(int64(f), 10) + } + return strconv.FormatFloat(f, 'f', -1, 64) +} diff --git a/processor/xml_integration_test.go b/processor/xml_integration_test.go new file mode 100644 index 0000000..41634a7 --- /dev/null +++ b/processor/xml_integration_test.go @@ -0,0 +1,346 @@ +package processor + +import ( + "strings" + "testing" + + "cook/utils" +) + +// TestRealWorldGameXML tests with game-like XML structure +func TestRealWorldGameXML(t *testing.T) { + original := ` + + + + + + + + + + + + + +` + + // Parse + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Modify: Double all MaxStack values and change Wood weight + modElem := deepCopyXMLElement(origElem) + + // Fiber MaxStack: 1000 → 2000 + fiberItem := modElem.Children[0] + fiberMaxStack := fiberItem.Children[2] + valueAttr := fiberMaxStack.Attributes["value"] + valueAttr.Value = "2000" + fiberMaxStack.Attributes["value"] = valueAttr + + // Wood MaxStack: 500 → 1000 + woodItem := modElem.Children[1] + woodMaxStack := woodItem.Children[2] + valueAttr2 := woodMaxStack.Attributes["value"] + valueAttr2.Value = "1000" + woodMaxStack.Attributes["value"] = valueAttr2 + + // Wood Weight: 0.05 → 0.10 + woodWeight := woodItem.Children[1] + weightAttr := woodWeight.Attributes["value"] + weightAttr.Value = "0.10" + woodWeight.Attributes["value"] = weightAttr + + // Generate changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 3 { + t.Fatalf("Expected 3 changes, got %d", len(changes)) + } + + // Apply + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Verify changes + if !strings.Contains(result, ``) { + t.Errorf("XML declaration not preserved") + } + if !strings.Contains(result, "\n + + + + +` + + // Parse + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Remove middle two items, add a new one + modElem := deepCopyXMLElement(origElem) + + // Remove shield and potion (indices 1 and 2) + modElem.Children = []*XMLElement{ + modElem.Children[0], // sword + modElem.Children[3], // scroll + } + + // Add a new item + newItem := &XMLElement{ + Tag: "item", + Attributes: map[string]XMLAttribute{ + "name": {Value: "helmet"}, + }, + Children: []*XMLElement{}, + } + modElem.Children = append(modElem.Children, newItem) + + // Generate changes + changes := findXMLChanges(origElem, modElem, "") + + // The algorithm compares by matching indices: + // orig[0]=sword vs mod[0]=sword (no change) + // orig[1]=shield vs mod[1]=scroll (treated as replace - shows as attribute changes) + // orig[2]=potion vs mod[2]=helmet (treated as replace) + // orig[3]=scroll (removed) + // This is fine - the actual edits will be correct + + if len(changes) == 0 { + t.Fatalf("Expected changes, got none") + } + + // Apply + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Verify + if strings.Contains(result, `name="shield"`) { + t.Errorf("Shield not removed") + } + if strings.Contains(result, `name="potion"`) { + t.Errorf("Potion not removed") + } + if !strings.Contains(result, `name="sword"`) { + t.Errorf("Sword incorrectly removed") + } + if !strings.Contains(result, `name="scroll"`) { + t.Errorf("Scroll incorrectly removed") + } + if !strings.Contains(result, `name="helmet"`) { + t.Errorf("Helmet not added") + } +} + +// TestModifyAttributesAndText tests changing both attributes and text content +func TestModifyAttributesAndText(t *testing.T) { + original := ` + Iron Sword + Battle Axe +` + + // Parse + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Modify both items + modElem := deepCopyXMLElement(origElem) + + // First item: change damage and text + item1 := modElem.Children[0] + dmgAttr := item1.Attributes["damage"] + dmgAttr.Value = "20" + item1.Attributes["damage"] = dmgAttr + item1.Text = "Steel Sword" + + // Second item: change damage and type + item2 := modElem.Children[1] + dmgAttr2 := item2.Attributes["damage"] + dmgAttr2.Value = "30" + item2.Attributes["damage"] = dmgAttr2 + typeAttr := item2.Attributes["type"] + typeAttr.Value = "greataxe" + item2.Attributes["type"] = typeAttr + + // Generate and apply changes + changes := findXMLChanges(origElem, modElem, "") + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Verify + if !strings.Contains(result, `damage="20"`) { + t.Errorf("First item damage not updated") + } + if !strings.Contains(result, "Steel Sword") { + t.Errorf("First item text not updated") + } + if !strings.Contains(result, `damage="30"`) { + t.Errorf("Second item damage not updated") + } + if !strings.Contains(result, `type="greataxe"`) { + t.Errorf("Second item type not updated") + } + if strings.Contains(result, "Iron Sword") { + t.Errorf("Old text still present") + } +} + +// TestSelfClosingTagPreservation tests that self-closing tags work correctly +func TestSelfClosingTagPreservation(t *testing.T) { + original := ` + + +` + + // Parse + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Modify first item's attribute + modElem := deepCopyXMLElement(origElem) + item := modElem.Children[0] + nameAttr := item.Attributes["name"] + nameAttr.Value = "modified" + item.Attributes["name"] = nameAttr + + // Generate and apply changes + changes := findXMLChanges(origElem, modElem, "") + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Verify the change was made + if !strings.Contains(result, `name="modified"`) { + t.Errorf("Attribute not updated: %s", result) + } +} + +// TestNumericAttributeModification tests numeric attribute changes +func TestNumericAttributeModification(t *testing.T) { + original := `` + + // Parse + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Double all numeric values + modElem := deepCopyXMLElement(origElem) + + // Helper to modify numeric attributes + modifyNumericAttr := func(attrName string, multiplier float64) { + if attr, exists := modElem.Attributes[attrName]; exists { + if val, ok := parseNumeric(attr.Value); ok { + attr.Value = formatNumeric(val * multiplier) + modElem.Attributes[attrName] = attr + } + } + } + + modifyNumericAttr("health", 2.0) + modifyNumericAttr("mana", 2.0) + modifyNumericAttr("stamina", 2.0) + + // Generate and apply changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 3 { + t.Fatalf("Expected 3 changes, got %d", len(changes)) + } + + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Verify numeric changes + if !strings.Contains(result, `health="200"`) { + t.Errorf("Health not doubled: %s", result) + } + if !strings.Contains(result, `mana="100"`) { + t.Errorf("Mana not doubled: %s", result) + } + if !strings.Contains(result, `stamina="151"`) { + t.Errorf("Stamina not doubled: %s", result) + } +} + +// TestMinimalGitDiff verifies that only changed parts are modified +func TestMinimalGitDiff(t *testing.T) { + original := ` + + + +` + + // Parse + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + // Change only brightness + modElem := deepCopyXMLElement(origElem) + brightnessItem := modElem.Children[1] + valueAttr := brightnessItem.Attributes["value"] + valueAttr.Value = "90" + brightnessItem.Attributes["value"] = valueAttr + + // Generate changes + changes := findXMLChanges(origElem, modElem, "") + + // Should be exactly 1 change + if len(changes) != 1 { + t.Fatalf("Expected exactly 1 change for minimal diff, got %d", len(changes)) + } + + if changes[0].OldValue != "75" || changes[0].NewValue != "90" { + t.Errorf("Wrong change detected: %v", changes[0]) + } + + // Apply + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Calculate diff size (rough approximation) + diffChars := len(changes[0].OldValue) + len(changes[0].NewValue) + if diffChars > 10 { + t.Errorf("Diff too large: %d characters changed (expected < 10)", diffChars) + } + + // Verify only brightness changed + if !strings.Contains(result, `value="50"`) { + t.Errorf("Volume incorrectly modified") + } + if !strings.Contains(result, `value="90"`) { + t.Errorf("Brightness not modified") + } + if !strings.Contains(result, `value="100"`) { + t.Errorf("Contrast incorrectly modified") + } +} diff --git a/processor/xml_test.go b/processor/xml_test.go new file mode 100644 index 0000000..c4bc03c --- /dev/null +++ b/processor/xml_test.go @@ -0,0 +1,621 @@ +package processor + +import ( + "strings" + "testing" + + "cook/utils" +) + +func TestParseXMLWithPositions(t *testing.T) { + xml := `Hello` + + elem, err := parseXMLWithPositions(xml) + if err != nil { + t.Fatalf("Failed to parse XML: %v", err) + } + + if elem.Tag != "root" { + t.Errorf("Expected root tag 'root', got '%s'", elem.Tag) + } + + if len(elem.Children) != 1 { + t.Fatalf("Expected 1 child, got %d", len(elem.Children)) + } + + child := elem.Children[0] + if child.Tag != "item" { + t.Errorf("Expected child tag 'item', got '%s'", child.Tag) + } + + if child.Attributes["name"].Value != "test" { + t.Errorf("Expected attribute 'name' to be 'test', got '%s'", child.Attributes["name"].Value) + } + + if child.Text != "Hello" { + t.Errorf("Expected text 'Hello', got '%s'", child.Text) + } +} + +func TestSurgicalTextChange(t *testing.T) { + original := ` + A sword +` + + expected := ` + A modified sword +` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version + modElem := deepCopyXMLElement(origElem) + modElem.Children[0].Text = "A modified sword" + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 1 { + t.Fatalf("Expected 1 change, got %d", len(changes)) + } + + if changes[0].Type != "text" { + t.Errorf("Expected change type 'text', got '%s'", changes[0].Type) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + if result != expected { + t.Errorf("Text change failed.\nExpected:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestSurgicalAttributeChange(t *testing.T) { + original := ` + +` + + expected := ` + +` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version + modElem := deepCopyXMLElement(origElem) + attr := modElem.Children[0].Attributes["weight"] + attr.Value = "20" + modElem.Children[0].Attributes["weight"] = attr + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 1 { + t.Fatalf("Expected 1 change, got %d", len(changes)) + } + + if changes[0].Type != "attribute" { + t.Errorf("Expected change type 'attribute', got '%s'", changes[0].Type) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + if result != expected { + t.Errorf("Attribute change failed.\nExpected:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestSurgicalMultipleAttributeChanges(t *testing.T) { + original := `` + + expected := `` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version + modElem := deepCopyXMLElement(origElem) + + nameAttr := modElem.Attributes["name"] + nameAttr.Value = "greatsword" + modElem.Attributes["name"] = nameAttr + + weightAttr := modElem.Attributes["weight"] + weightAttr.Value = "20" + modElem.Attributes["weight"] = weightAttr + + damageAttr := modElem.Attributes["damage"] + damageAttr.Value = "15" + modElem.Attributes["damage"] = damageAttr + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 3 { + t.Fatalf("Expected 3 changes, got %d", len(changes)) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + if result != expected { + t.Errorf("Multiple attribute changes failed.\nExpected:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestSurgicalAddAttribute(t *testing.T) { + original := `` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version with new attribute + modElem := deepCopyXMLElement(origElem) + modElem.Attributes["weight"] = XMLAttribute{ + Value: "10", + } + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 1 { + t.Fatalf("Expected 1 change, got %d", len(changes)) + } + + if changes[0].Type != "add_attribute" { + t.Errorf("Expected change type 'add_attribute', got '%s'", changes[0].Type) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Should contain the new attribute + if !strings.Contains(result, `weight="10"`) { + t.Errorf("Add attribute failed. Result doesn't contain weight=\"10\":\n%s", result) + } +} + +func TestSurgicalRemoveAttribute(t *testing.T) { + original := `` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version without weight attribute + modElem := deepCopyXMLElement(origElem) + delete(modElem.Attributes, "weight") + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 1 { + t.Fatalf("Expected 1 change, got %d", len(changes)) + } + + if changes[0].Type != "remove_attribute" { + t.Errorf("Expected change type 'remove_attribute', got '%s'", changes[0].Type) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Should not contain weight attribute + if strings.Contains(result, "weight=") { + t.Errorf("Remove attribute failed. Result still contains 'weight=':\n%s", result) + } + + // Should still contain other attributes + if !strings.Contains(result, `name="sword"`) { + t.Errorf("Remove attribute incorrectly removed other attributes:\n%s", result) + } +} + +func TestSurgicalAddElement(t *testing.T) { + original := ` + +` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version with new child + modElem := deepCopyXMLElement(origElem) + newChild := &XMLElement{ + Tag: "item", + Attributes: map[string]XMLAttribute{ + "name": {Value: "shield"}, + }, + Children: []*XMLElement{}, + } + modElem.Children = append(modElem.Children, newChild) + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 1 { + t.Fatalf("Expected 1 change, got %d", len(changes)) + } + + if changes[0].Type != "add_element" { + t.Errorf("Expected change type 'add_element', got '%s'", changes[0].Type) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Should contain the new element + if !strings.Contains(result, ` + + +` + + expected := ` + + +` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version without second child + modElem := deepCopyXMLElement(origElem) + modElem.Children = modElem.Children[:1] + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + if len(changes) != 1 { + t.Fatalf("Expected 1 change, got %d", len(changes)) + } + + if changes[0].Type != "remove_element" { + t.Errorf("Expected change type 'remove_element', got '%s'", changes[0].Type) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Should not contain shield + if strings.Contains(result, "shield") { + t.Errorf("Remove element failed. Result still contains 'shield':\n%s", result) + } + + // Should still contain sword + if !strings.Contains(result, "sword") { + t.Errorf("Remove element incorrectly removed other elements:\n%s", result) + } + + // Normalize whitespace for comparison + resultNorm := strings.TrimSpace(result) + expectedNorm := strings.TrimSpace(expected) + + if resultNorm != expectedNorm { + t.Errorf("Remove element result mismatch.\nExpected:\n%s\n\nGot:\n%s", expectedNorm, resultNorm) + } +} + +func TestComplexNestedChanges(t *testing.T) { + original := ` + + + + + + + + +` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Create modified version with multiple changes + modElem := deepCopyXMLElement(origElem) + + // Change first item's weight + inventory := modElem.Children[0] + item1 := inventory.Children[0] + weightAttr := item1.Attributes["weight"] + weightAttr.Value = "20" + item1.Attributes["weight"] = weightAttr + + // Change nested stats damage + stats := item1.Children[0] + damageAttr := stats.Attributes["damage"] + damageAttr.Value = "10" + stats.Attributes["damage"] = damageAttr + + // Change second item's name + item2 := inventory.Children[1] + nameAttr := item2.Attributes["name"] + nameAttr.Value = "buckler" + item2.Attributes["name"] = nameAttr + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + // Should have 3 changes: weight, damage, name + if len(changes) != 3 { + t.Fatalf("Expected 3 changes, got %d: %+v", len(changes), changes) + } + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Verify all changes were applied + if !strings.Contains(result, `weight="20"`) { + t.Errorf("Failed to update weight to 20:\n%s", result) + } + if !strings.Contains(result, `damage="10"`) { + t.Errorf("Failed to update damage to 10:\n%s", result) + } + if !strings.Contains(result, `name="buckler"`) { + t.Errorf("Failed to update name to buckler:\n%s", result) + } + + // Verify unchanged elements remain + if !strings.Contains(result, `speed="3"`) { + t.Errorf("Incorrectly modified speed attribute:\n%s", result) + } + if !strings.Contains(result, `defense="7"`) { + t.Errorf("Incorrectly modified defense attribute:\n%s", result) + } +} + +func TestFormattingPreservation(t *testing.T) { + original := ` + + A sharp blade + + +` + + expected := ` + + A sharp blade + + +` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Modify only weight + modElem := deepCopyXMLElement(origElem) + item := modElem.Children[0] + weightAttr := item.Attributes["weight"] + weightAttr.Value = "20" + item.Attributes["weight"] = weightAttr + + // Find changes + changes := findXMLChanges(origElem, modElem, "") + + // Apply changes + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + if result != expected { + t.Errorf("Formatting preservation failed.\nExpected:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestNumericHelpers(t *testing.T) { + tests := []struct { + input string + expected float64 + isNum bool + }{ + {"42", 42.0, true}, + {"3.14", 3.14, true}, + {"0", 0.0, true}, + {"-5", -5.0, true}, + {"abc", 0.0, false}, + {"", 0.0, false}, + } + + for _, tt := range tests { + val, ok := parseNumeric(tt.input) + if ok != tt.isNum { + t.Errorf("parseNumeric(%q) isNum = %v, expected %v", tt.input, ok, tt.isNum) + } + if ok && val != tt.expected { + t.Errorf("parseNumeric(%q) = %v, expected %v", tt.input, val, tt.expected) + } + } + + // Test formatting + formatTests := []struct { + input float64 + expected string + }{ + {42.0, "42"}, + {3.14, "3.14"}, + {0.0, "0"}, + {-5.0, "-5"}, + {100.5, "100.5"}, + } + + for _, tt := range formatTests { + result := formatNumeric(tt.input) + if result != tt.expected { + t.Errorf("formatNumeric(%v) = %q, expected %q", tt.input, result, tt.expected) + } + } +} + +func TestDeepCopyXMLElement(t *testing.T) { + original := &XMLElement{ + Tag: "item", + Text: "content", + Attributes: map[string]XMLAttribute{ + "name": {Value: "sword"}, + }, + Children: []*XMLElement{ + {Tag: "child", Text: "text"}, + }, + } + + copied := deepCopyXMLElement(original) + + // Verify copy is equal + if copied.Tag != original.Tag { + t.Errorf("Tag not copied correctly") + } + if copied.Text != original.Text { + t.Errorf("Text not copied correctly") + } + + // Modify copy + copied.Tag = "modified" + copied.Attributes["name"] = XMLAttribute{Value: "shield"} + copied.Children[0].Text = "modified text" + + // Verify original unchanged + if original.Tag != "item" { + t.Errorf("Original was modified") + } + if original.Attributes["name"].Value != "sword" { + t.Errorf("Original attributes were modified") + } + if original.Children[0].Text != "text" { + t.Errorf("Original children were modified") + } +} + +func TestSerializeXMLElement(t *testing.T) { + elem := &XMLElement{ + Tag: "item", + Attributes: map[string]XMLAttribute{ + "name": {Value: "sword"}, + "weight": {Value: "10"}, + }, + Children: []*XMLElement{ + { + Tag: "stats", + Attributes: map[string]XMLAttribute{ + "damage": {Value: "5"}, + }, + Children: []*XMLElement{}, + }, + }, + } + + result := serializeXMLElement(elem, "") + + // Check it contains expected parts + if !strings.Contains(result, "") { + t.Errorf("Missing closing tag") + } + if !strings.Contains(result, `name="sword"`) { + t.Errorf("Missing name attribute") + } + if !strings.Contains(result, `weight="10"`) { + t.Errorf("Missing weight attribute") + } + if !strings.Contains(result, " + + +` + + // Parse + elem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse XML: %v", err) + } + + if len(elem.Children) != 2 { + t.Errorf("Expected 2 children, got %d", len(elem.Children)) + } + + // Both should be parsed correctly + if elem.Children[0].Tag != "item" { + t.Errorf("First child tag incorrect") + } + if elem.Children[1].Tag != "item" { + t.Errorf("Second child tag incorrect") + } +} + +func TestAttributeOrderPreservation(t *testing.T) { + original := `` + + // Parse original + origElem, err := parseXMLWithPositions(original) + if err != nil { + t.Fatalf("Failed to parse original XML: %v", err) + } + + // Modify just weight + modElem := deepCopyXMLElement(origElem) + weightAttr := modElem.Attributes["weight"] + weightAttr.Value = "20" + modElem.Attributes["weight"] = weightAttr + + // Find and apply changes + changes := findXMLChanges(origElem, modElem, "") + commands := applyXMLChanges(changes) + result, _ := utils.ExecuteModifications(commands, original) + + // Verify attribute order is preserved (weight comes before damage and speed) + weightIdx := strings.Index(result, "weight=") + damageIdx := strings.Index(result, "damage=") + speedIdx := strings.Index(result, "speed=") + + if weightIdx > damageIdx || damageIdx > speedIdx { + t.Errorf("Attribute order not preserved:\n%s", result) + } +}