11 Commits

Author SHA1 Message Date
969ccae25c Use diffs for tests 2025-08-22 10:10:37 +02:00
5b46ff0efd Fix broken test introduced in previous commit 2025-08-22 10:04:11 +02:00
d234616406 Add broken test 2025-08-22 09:53:00 +02:00
af3e55e518 Fix some retarded bullshit 2025-08-22 00:10:46 +02:00
13b48229ac Fix some bullshit (the re) 2025-08-22 00:05:22 +02:00
670f6ed7a0 Add tests for EvalRegex 2025-08-21 23:56:05 +02:00
bbc7c50fae Decringe 2025-08-21 23:17:36 +02:00
779d1e0a0e Fix some more shit I guess 2025-08-21 23:16:23 +02:00
54581f0216 Clean up the cringe 2025-08-21 23:10:18 +02:00
3d01822e77 Fix failing test 2025-08-21 23:05:57 +02:00
4e0ca92c77 Add failing test 2025-08-21 23:05:57 +02:00
8 changed files with 1120 additions and 113 deletions

13
.vscode/launch.json vendored
View File

@@ -98,6 +98,19 @@
"args": [
"cook_tacz.yml",
]
},
{
"name": "Launch Package (ICARUS)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "C:/Users/Administrator/Seafile/Games-ICARUS/Icarus/Saved/IME3/Mods",
"args": [
"-loglevel",
"trace",
"cook_processorrecipes.yml",
]
}
]
}

9
go.mod
View File

@@ -13,7 +13,6 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hexops/valast v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -21,10 +20,8 @@ require (
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect
@@ -33,4 +30,8 @@ require (
mvdan.cc/gofumpt v0.4.0 // indirect
)
require gorm.io/driver/sqlite v1.6.0
require (
github.com/google/go-cmp v0.6.0
github.com/tidwall/gjson v1.18.0
gorm.io/driver/sqlite v1.6.0
)

3
go.sum
View File

@@ -36,15 +36,12 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=

View File

@@ -4,10 +4,13 @@ import (
"cook/utils"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/tidwall/sjson"
"github.com/tidwall/gjson"
lua "github.com/yuin/gopher-lua"
)
@@ -86,8 +89,8 @@ func ProcessJSON(content string, command utils.ModifyCommand, filename string) (
return commands, fmt.Errorf("failed to convert Lua table back to Go: %v", err)
}
// Use surgical JSON editing instead of full replacement
commands, err = applySurgicalJSONChanges(content, jsonData, goData)
processJsonLogger.Debug("About to call applyChanges with original data and modified data")
commands, err = applyChanges(content, jsonData, goData)
if err != nil {
processJsonLogger.Error("Failed to apply surgical JSON changes: %v", err)
return commands, fmt.Errorf("failed to apply surgical JSON changes: %v", err)
@@ -98,80 +101,288 @@ func ProcessJSON(content string, command utils.ModifyCommand, filename string) (
return commands, nil
}
// applySurgicalJSONChanges compares original and modified data and applies changes surgically
func applySurgicalJSONChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
// applyJSONChanges compares original and modified data and applies changes surgically
func applyJSONChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
var commands []utils.ReplaceCommand
// Convert both to JSON for comparison
originalJSON, err := json.Marshal(originalData)
if err != nil {
return commands, fmt.Errorf("failed to marshal original data: %v", err)
appliedCommands, err := applyChanges(content, originalData, modifiedData)
if err == nil && len(appliedCommands) > 0 {
return appliedCommands, nil
}
modifiedJSON, err := json.Marshal(modifiedData)
if err != nil {
return commands, fmt.Errorf("failed to marshal modified data: %v", err)
return commands, fmt.Errorf("failed to make any changes to the json")
}
// If no changes, return empty commands
if string(originalJSON) == string(modifiedJSON) {
return commands, nil
}
// Try true surgical approach that preserves formatting
surgicalCommands, err := applyTrueSurgicalChanges(content, originalData, modifiedData)
if err == nil && len(surgicalCommands) > 0 {
return surgicalCommands, nil
}
// Fall back to full replacement with proper formatting
modifiedJSONIndented, err := json.MarshalIndent(modifiedData, "", " ")
if err != nil {
return commands, fmt.Errorf("failed to marshal modified data with indentation: %v", err)
}
commands = append(commands, utils.ReplaceCommand{
From: 0,
To: len(content),
With: string(modifiedJSONIndented),
})
return commands, nil
}
// applyTrueSurgicalChanges attempts to make surgical changes while preserving exact formatting
func applyTrueSurgicalChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
// applyChanges attempts to make surgical changes while preserving exact formatting
func applyChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
var commands []utils.ReplaceCommand
// Find changes by comparing the data structures
// Find all changes between original and modified data
changes := findDeepChanges("", originalData, modifiedData)
jsonLogger.Debug("applyChanges: Found %d changes: %v", len(changes), changes)
if len(changes) == 0 {
return commands, nil
}
// Apply changes surgically using sjson.Set() to preserve formatting
modifiedContent := content
for path, newValue := range changes {
var err error
modifiedContent, err = sjson.Set(modifiedContent, path, newValue)
if err != nil {
return nil, fmt.Errorf("failed to apply surgical change at path %s: %v", path, err)
// Sort removal operations by index in descending order to avoid index shifting
var removals []string
var additions []string
var valueChanges []string
for path := range changes {
if strings.HasSuffix(path, "@remove") {
removals = append(removals, path)
} else if strings.HasSuffix(path, "@add") {
additions = append(additions, path)
} else {
valueChanges = append(valueChanges, path)
}
}
// If we successfully made changes, create a replacement command
if modifiedContent != content {
jsonLogger.Debug("applyChanges: %d removals, %d additions, %d value changes", len(removals), len(additions), len(valueChanges))
// Apply removals first (from end to beginning to avoid index shifting)
for _, removalPath := range removals {
actualPath := strings.TrimSuffix(removalPath, "@remove")
elementIndex := extractIndexFromRemovalPath(actualPath)
arrayPath := getArrayPathFromElementPath(actualPath)
jsonLogger.Debug("Processing removal: path=%s, index=%d, arrayPath=%s", actualPath, elementIndex, arrayPath)
// Find the exact byte range to remove
from, to := findArrayElementRemovalRange(content, arrayPath, elementIndex)
jsonLogger.Debug("Removing bytes %d-%d", from, to)
commands = append(commands, utils.ReplaceCommand{
From: 0,
To: len(content),
With: modifiedContent,
From: from,
To: to,
With: "",
})
jsonLogger.Debug("Added removal command: From=%d, To=%d, With=\"\"", from, to)
}
// Apply additions (new fields)
for _, additionPath := range additions {
actualPath := strings.TrimSuffix(additionPath, "@add")
newValue := changes[additionPath]
jsonLogger.Debug("Processing addition: path=%s, value=%v", actualPath, newValue)
// Find the parent object to add the field to
parentPath := getParentPath(actualPath)
fieldName := getFieldName(actualPath)
jsonLogger.Debug("Parent path: %s, field name: %s", parentPath, fieldName)
// Get the parent object
var parentResult gjson.Result
if parentPath == "" {
// Adding to root object - get the entire JSON
parentResult = gjson.Parse(content)
} else {
parentResult = gjson.Get(content, parentPath)
}
if !parentResult.Exists() {
jsonLogger.Debug("Parent path %s does not exist, skipping", parentPath)
continue
}
// Find where to insert the new field (at the end of the object)
startPos := int(parentResult.Index + len(parentResult.Raw) - 1) // Before closing brace
jsonLogger.Debug("Inserting at pos %d", startPos)
// Convert the new value to JSON string
newValueStr := convertValueToJSONString(newValue)
// Insert the new field with pretty-printed formatting
// Format: ,"fieldName": { ... }
insertText := fmt.Sprintf(`,"%s": %s`, fieldName, newValueStr)
commands = append(commands, utils.ReplaceCommand{
From: startPos,
To: startPos,
With: insertText,
})
jsonLogger.Debug("Added addition command: From=%d, To=%d, With=%q", startPos, startPos, insertText)
}
// Apply value changes (in reverse order to avoid position shifting)
sort.Slice(valueChanges, func(i, j int) bool {
// Get positions for comparison
resultI := gjson.Get(content, valueChanges[i])
resultJ := gjson.Get(content, valueChanges[j])
return resultI.Index > resultJ.Index // Descending order
})
for _, path := range valueChanges {
newValue := changes[path]
jsonLogger.Debug("Processing value change: path=%s, value=%v", path, newValue)
// Get the current value and its position in the original JSON
result := gjson.Get(content, path)
if !result.Exists() {
jsonLogger.Debug("Path %s does not exist, skipping", path)
continue // Skip if path doesn't exist
}
// Get the exact byte positions of this value
startPos := result.Index
endPos := startPos + len(result.Raw)
jsonLogger.Debug("Found value at pos %d-%d: %q", startPos, endPos, result.Raw)
// Convert the new value to JSON string
newValueStr := convertValueToJSONString(newValue)
jsonLogger.Debug("Converting to: %q", newValueStr)
// Create a replacement command for this specific value
commands = append(commands, utils.ReplaceCommand{
From: int(startPos),
To: int(endPos),
With: newValueStr,
})
jsonLogger.Debug("Added command: From=%d, To=%d, With=%q", int(startPos), int(endPos), newValueStr)
}
return commands, nil
}
// extractIndexFromRemovalPath extracts the array index from a removal path like "Rows.0.Inputs.1@remove"
func extractIndexFromRemovalPath(path string) int {
parts := strings.Split(strings.TrimSuffix(path, "@remove"), ".")
if len(parts) > 0 {
lastPart := parts[len(parts)-1]
if index, err := strconv.Atoi(lastPart); err == nil {
return index
}
}
return -1
}
// getArrayPathFromElementPath converts "Rows.0.Inputs.1" to "Rows.0.Inputs"
func getArrayPathFromElementPath(elementPath string) string {
parts := strings.Split(elementPath, ".")
if len(parts) > 0 {
return strings.Join(parts[:len(parts)-1], ".")
}
return ""
}
// getParentPath extracts the parent path from a full path like "Rows.0.Inputs.1"
func getParentPath(fullPath string) string {
parts := strings.Split(fullPath, ".")
if len(parts) > 0 {
return strings.Join(parts[:len(parts)-1], ".")
}
return ""
}
// getFieldName extracts the field name from a full path like "Rows.0.Inputs.1"
func getFieldName(fullPath string) string {
parts := strings.Split(fullPath, ".")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}
// convertValueToJSONString converts a Go interface{} to a JSON string representation
func convertValueToJSONString(value interface{}) string {
switch v := value.(type) {
case string:
return `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
case float64:
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
case nil:
return "null"
case map[string]interface{}:
// Handle maps specially to avoid double-escaping of keys
var pairs []string
for key, val := range v {
// The key might already have escaped quotes from Lua, so we need to be careful
// If the key already contains escaped quotes, we need to unescape them first
keyStr := key
if strings.Contains(key, `\"`) {
// Key already has escaped quotes, use it as-is
keyStr = `"` + key + `"`
} else {
// Normal key, escape quotes
keyStr = `"` + strings.ReplaceAll(key, `"`, `\"`) + `"`
}
valStr := convertValueToJSONString(val)
pairs = append(pairs, keyStr+":"+valStr)
}
return "{" + strings.Join(pairs, ",") + "}"
default:
// For other complex types (arrays), we need to use json.Marshal
jsonBytes, err := json.Marshal(v)
if err != nil {
return "null" // Fallback to null if marshaling fails
}
return string(jsonBytes)
}
}
// findArrayElementRemovalRange finds the exact byte range to remove for an array element
func findArrayElementRemovalRange(content, arrayPath string, elementIndex int) (int, int) {
// Get the array using gjson
arrayResult := gjson.Get(content, arrayPath)
if !arrayResult.Exists() || !arrayResult.IsArray() {
return -1, -1
}
// Get all array elements
elements := arrayResult.Array()
if elementIndex >= len(elements) {
return -1, -1
}
// Get the target element
elementResult := elements[elementIndex]
startPos := int(elementResult.Index)
endPos := int(elementResult.Index + len(elementResult.Raw))
// Handle comma removal properly
if elementIndex == 0 && len(elements) > 1 {
// First element but not the only one - remove comma after
for i := endPos; i < len(content) && i < endPos+50; i++ {
if content[i] == ',' {
endPos = i + 1
break
}
}
} else if elementIndex == len(elements)-1 && len(elements) > 1 {
// Last element and not the only one - remove comma before
prevElementEnd := int(elements[elementIndex-1].Index + len(elements[elementIndex-1].Raw))
for i := prevElementEnd; i < startPos && i < len(content); i++ {
if content[i] == ',' {
startPos = i
break
}
}
}
// If it's the only element, don't remove any commas
return startPos, endPos
}
// findDeepChanges recursively finds all paths that need to be changed
func findDeepChanges(basePath string, original, modified interface{}) map[string]interface{} {
changes := make(map[string]interface{})
@@ -179,7 +390,7 @@ func findDeepChanges(basePath string, original, modified interface{}) map[string
switch orig := original.(type) {
case map[string]interface{}:
if mod, ok := modified.(map[string]interface{}); ok {
// Check each key in the modified data
// Check for new keys added in modified data
for key, modValue := range mod {
var currentPath string
if basePath == "" {
@@ -190,54 +401,71 @@ func findDeepChanges(basePath string, original, modified interface{}) map[string
if origValue, exists := orig[key]; exists {
// Key exists in both, check if value changed
if !deepEqual(origValue, modValue) {
// If it's a nested object/array, recurse
switch modValue.(type) {
case map[string]interface{}, []interface{}:
// Recursively check nested structures
nestedChanges := findDeepChanges(currentPath, origValue, modValue)
for nestedPath, nestedValue := range nestedChanges {
changes[nestedPath] = nestedValue
}
default:
// Primitive value changed
// Primitive value - check if changed
if !deepEqual(origValue, modValue) {
changes[currentPath] = modValue
}
}
} else {
// New key added
changes[currentPath] = modValue
// New key added - mark for addition
changes[currentPath+"@add"] = modValue
}
}
}
case []interface{}:
if mod, ok := modified.([]interface{}); ok {
// For arrays, check each index
// Handle array changes by detecting specific element operations
if len(orig) != len(mod) {
// Array length changed - detect if it's element removal
if len(orig) > len(mod) {
// Element(s) removed - find which ones by comparing content
removedIndices := findRemovedArrayElements(orig, mod)
for _, removedIndex := range removedIndices {
var currentPath string
if basePath == "" {
currentPath = fmt.Sprintf("%d@remove", removedIndex)
} else {
currentPath = fmt.Sprintf("%s.%d@remove", basePath, removedIndex)
}
changes[currentPath] = nil // Mark for removal
}
} else {
// Elements added - more complex, skip for now
}
} else {
// Same length - check individual elements for value changes
for i, modValue := range mod {
var currentPath string
if basePath == "" {
currentPath = fmt.Sprintf("%d", i)
currentPath = strconv.Itoa(i)
} else {
currentPath = fmt.Sprintf("%s.%d", basePath, i)
currentPath = basePath + "." + strconv.Itoa(i)
}
if i < len(orig) {
// Index exists in both, check if value changed
if !deepEqual(orig[i], modValue) {
// If it's a nested object/array, recurse
switch modValue.(type) {
case map[string]interface{}, []interface{}:
// Recursively check nested structures
nestedChanges := findDeepChanges(currentPath, orig[i], modValue)
for nestedPath, nestedValue := range nestedChanges {
changes[nestedPath] = nestedValue
}
default:
// Primitive value changed
// Primitive value - check if changed
if !deepEqual(orig[i], modValue) {
changes[currentPath] = modValue
}
}
} else {
// New array element added
changes[currentPath] = modValue
}
}
}
}
@@ -255,6 +483,27 @@ func findDeepChanges(basePath string, original, modified interface{}) map[string
return changes
}
// findRemovedArrayElements compares two arrays and returns indices of removed elements
func findRemovedArrayElements(original, modified []interface{}) []int {
var removedIndices []int
// Simple approach: find elements in original that don't exist in modified
for i, origElement := range original {
found := false
for _, modElement := range modified {
if deepEqual(origElement, modElement) {
found = true
break
}
}
if !found {
removedIndices = append(removedIndices, i)
}
}
return removedIndices
}
// deepEqual performs deep comparison of two values
func deepEqual(a, b interface{}) bool {
if a == nil && b == nil {

View File

@@ -19,22 +19,22 @@ func TestProcessJSON(t *testing.T) {
name: "Basic JSON object modification",
input: `{"name": "test", "value": 42}`,
luaExpression: `data.value = data.value * 2; return true`,
expectedOutput: `{"name": "test", "value": 84}`, // Surgical editing preserves compact format
expectedOutput: `{"name": "test", "value": 84}`,
expectedMods: 1,
},
{
name: "JSON array modification",
input: `{"items": [{"id": 1, "value": 10}, {"id": 2, "value": 20}]}`,
luaExpression: `for i, item in ipairs(data.items) do data.items[i].value = item.value * 1.5 end; return true`,
expectedOutput: `{"items": [{"id": 1, "value": 15}, {"id": 2, "value": 30}]}`, // Surgical editing preserves compact format
expectedMods: 1,
input: `{"items": [{"name": "item1", "value": 10}, {"name": "item2", "value": 20}]}`,
luaExpression: `for i, item in ipairs(data.items) do item.value = item.value * 2 end modified = true`,
expectedOutput: `{"items": [{"name": "item1", "value": 20}, {"name": "item2", "value": 40}]}`,
expectedMods: 2,
},
{
name: "JSON nested object modification",
input: `{"config": {"settings": {"enabled": false, "timeout": 30}}}`,
luaExpression: `data.config.settings.enabled = true; data.config.settings.timeout = 60; return true`,
expectedOutput: `{"config": {"settings": {"enabled": true, "timeout": 60}}}`, // Surgical editing preserves compact format
expectedMods: 1,
input: `{"config": {"setting1": {"enabled": true, "value": 5}, "setting2": {"enabled": false, "value": 10}}}`,
luaExpression: `data.config.setting1.enabled = false data.config.setting2.value = 15 modified = true`,
expectedOutput: `{"config": {"setting1": {"enabled": false, "value": 5}, "setting2": {"enabled": false, "value": 15}}}`,
expectedMods: 2,
},
{
name: "JSON no modification",

View File

@@ -247,7 +247,7 @@ modified = false
initLuaHelpersLogger.Debug("Setting up Lua print function to Go")
L.SetGlobal("print", L.NewFunction(printToGo))
L.SetGlobal("fetch", L.NewFunction(fetch))
L.SetGlobal("re", L.NewFunction(evalRegex))
L.SetGlobal("re", L.NewFunction(EvalRegex))
initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go")
return nil
}
@@ -483,24 +483,39 @@ func fetch(L *lua.LState) int {
return 1
}
func evalRegex(L *lua.LState) int {
func EvalRegex(L *lua.LState) int {
evalRegexLogger := processorLogger.WithPrefix("evalRegex")
evalRegexLogger.Debug("Lua evalRegex function called")
defer func() {
if r := recover(); r != nil {
evalRegexLogger.Error("Panic in EvalRegex: %v", r)
// Push empty table on panic
emptyTable := L.NewTable()
L.Push(emptyTable)
}
}()
pattern := L.ToString(1)
input := L.ToString(2)
evalRegexLogger.Debug("Pattern: %q, Input: %q", pattern, input)
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(input)
evalRegexLogger.Debug("Go regex matches: %v (count: %d)", matches, len(matches))
matchesTable := L.NewTable()
for i, match := range matches {
matchesTable.RawSetString(fmt.Sprintf("%d", i), lua.LString(match))
matchesTable.RawSetInt(i, lua.LString(match))
evalRegexLogger.Debug("Set table[%d] = %q", i, match)
}
L.Push(matchesTable)
evalRegexLogger.Debug("Pushed matches table to Lua stack")
return 0
return 1
}
// GetLuaFunctionsHelp returns a comprehensive help string for all available Lua functions

162
processor/processor_test.go Normal file
View File

@@ -0,0 +1,162 @@
package processor_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
lua "github.com/yuin/gopher-lua"
"cook/processor"
)
// Happy Path: Function correctly returns all regex capture groups as Lua table when given valid pattern and input.
func TestEvalRegex_CaptureGroupsReturned(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `(\w+)-(\d+)`
input := "test-42"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result, "Expected return value to be 0")
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
expected := []string{"test-42", "test", "42"}
for i, v := range expected {
val := tbl.RawGetString(fmt.Sprintf("%d", i))
assert.Equal(t, lua.LString(v), val, "Expected index %d to be %q", i, v)
}
}
// Happy Path: Function returns an empty Lua table when regex pattern does not match input string.
func TestEvalRegex_NoMatchReturnsEmptyTable(t *testing.T) {
L := lua.NewState()
defer L.Close()
L.Push(lua.LString(`(foo)(bar)`))
L.Push(lua.LString("no-match-here"))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Zero(t, count, "Expected no items in the table for non-matching input")
}
// Happy Path: Function handles patterns with no capture groups by returning the full match in the Lua table.
func TestEvalRegex_NoCaptureGroups(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `foo\d+`
input := "foo123"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
fullMatch := tbl.RawGetString("0")
assert.Equal(t, lua.LString("foo123"), fullMatch)
// There should be only the full match (index 0)
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Equal(t, 1, count)
}
// Edge Case: Function panics or errors when given an invalid regex pattern.
func TestEvalRegex_InvalidPattern(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `([a-z` // invalid regex
L.Push(lua.LString(pattern))
L.Push(lua.LString("someinput"))
defer func() {
if r := recover(); r == nil {
t.Error("Expected panic for invalid regex pattern, but did not panic")
}
}()
processor.EvalRegex(L)
}
// Edge Case: Function returns an empty Lua table when input string is empty.
func TestEvalRegex_EmptyInputString(t *testing.T) {
L := lua.NewState()
defer L.Close()
L.Push(lua.LString(`(foo)`))
L.Push(lua.LString(""))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
// Should be empty
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Zero(t, count, "Expected empty table when input is empty")
}
// Edge Case: Function handles nil or missing arguments gracefully without causing a runtime panic.
func TestEvalRegex_MissingArguments(t *testing.T) {
L := lua.NewState()
defer L.Close()
defer func() {
if r := recover(); r != nil {
t.Errorf("Did not expect panic when arguments are missing, got: %v", r)
}
}()
// No arguments pushed at all
processor.EvalRegex(L)
// Should just not match anything or produce empty table, but must not panic
}
func TestEvalComplexRegex(t *testing.T) {
// 23:47:35.567068 processor.go:369 [g:22 ] [LUA] Pistol_Round ^((Bulk_)?(Pistol|Rifle).*?Round.*?)$
L := lua.NewState()
defer L.Close()
pattern := `^((Bulk_)?(Pistol|Rifle).*?Round.*?)$`
input := "Pistol_Round"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
processor.EvalRegex(L)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
count := 0
tbl.ForEach(func(k, v lua.LValue) {
fmt.Println(k, v)
count++
})
assert.Equal(t, 1, count)
}

View File

@@ -3,6 +3,8 @@ package processor
import (
"cook/utils"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestSurgicalJSONEditing(t *testing.T) {
@@ -92,6 +94,11 @@ modified = true
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, tt.expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check the actual result matches expected
if result != tt.expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", tt.expected, result)
@@ -178,6 +185,338 @@ modified = true
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the result matches expected (preserves formatting and changes weight)
if result != expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestSurgicalJSONPreservesFormatting2(t *testing.T) {
// Test that surgical editing preserves the original formatting structure
content := `
{
"RowStruct": "/Script/Icarus.ProcessorRecipe",
"Defaults": {
"bForceDisableRecipe": false,
"Requirement": {
"RowName": "None",
"DataTableName": "D_Talents"
},
"SessionRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"CharacterRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"RequiredMillijoules": 2500,
"RecipeSets": [],
"ResourceCostMultipliers": [],
"Inputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Container": {
"Value": "None"
},
"ResourceInputs": [],
"bSelectOutputItemRandomly": false,
"bContainsContainer": false,
"ItemIconOverride": {
"ItemStaticData": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"ItemDynamicData": [],
"ItemCustomStats": [],
"CustomProperties": {
"StaticWorldStats": [],
"StaticWorldHeldStats": [],
"Stats": [],
"Alterations": [],
"LivingItemSlots": []
},
"DatabaseGUID": "",
"ItemOwnerLookupId": -1,
"RuntimeTags": {
"GameplayTags": []
}
},
"Outputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemTemplate"
},
"Count": 1,
"DynamicProperties": []
}
],
"ResourceOutputs": [],
"Refundable": "Inherit",
"ExperienceMultiplier": 1,
"Audio": {
"RowName": "None",
"DataTableName": "D_CraftingAudioData"
}
},
"Rows": [
{
"Name": "Biofuel1",
"RecipeSets": [
{
"RowName": "Composter",
"DataTableName": "D_RecipeSets"
}
],
"Inputs": [
{
"Element": {
"RowName": "Raw_Meat",
"DataTableName": "D_ItemsStatic"
},
"Count": 2,
"DynamicProperties": []
},
{
"Element": {
"RowName": "Tree_Sap",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Outputs": [],
"Audio": {
"RowName": "Composter"
},
"ResourceOutputs": [
{
"Type": {
"Value": "Biofuel"
},
"RequiredUnits": 100
}
]
}
]
}
`
expected := `
{
"RowStruct": "/Script/Icarus.ProcessorRecipe",
"Defaults": {
"bForceDisableRecipe": false,
"Requirement": {
"RowName": "None",
"DataTableName": "D_Talents"
},
"SessionRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"CharacterRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"RequiredMillijoules": 2500,
"RecipeSets": [],
"ResourceCostMultipliers": [],
"Inputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Container": {
"Value": "None"
},
"ResourceInputs": [],
"bSelectOutputItemRandomly": false,
"bContainsContainer": false,
"ItemIconOverride": {
"ItemStaticData": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"ItemDynamicData": [],
"ItemCustomStats": [],
"CustomProperties": {
"StaticWorldStats": [],
"StaticWorldHeldStats": [],
"Stats": [],
"Alterations": [],
"LivingItemSlots": []
},
"DatabaseGUID": "",
"ItemOwnerLookupId": -1,
"RuntimeTags": {
"GameplayTags": []
}
},
"Outputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemTemplate"
},
"Count": 1,
"DynamicProperties": []
}
],
"ResourceOutputs": [],
"Refundable": "Inherit",
"ExperienceMultiplier": 1,
"Audio": {
"RowName": "None",
"DataTableName": "D_CraftingAudioData"
}
},
"Rows": [
{
"Name": "Biofuel1",
"RecipeSets": [
{
"RowName": "Composter",
"DataTableName": "D_RecipeSets"
}
],
"Inputs": [
{
"Element": {
"RowName": "Raw_Meat",
"DataTableName": "D_ItemsStatic"
},
"Count": 2,
"DynamicProperties": []
}
],
"Outputs": [],
"Audio": {
"RowName": "Composter"
},
"ResourceOutputs": [
{
"Type": {
"Value": "Biofuel"
},
"RequiredUnits": 100
}
]
}
]
}
`
command := utils.ModifyCommand{
Name: "test",
Lua: `
-- Define regex patterns for matching recipe names
local function matchesPattern(name, pattern)
local matches = re(pattern, name)
-- Check if matches table has any content (index 0 or 1 should exist if there's a match)
return matches and (matches[0] or matches[1])
end
-- Selection pattern for recipes that get multiplied
local selectionPattern = "(?-s)(Bulk_)?(Pistol|Rifle).*?Round.*?|(Carbon|Composite)_Paste.*|(Gold|Copper)_Wire|(Ironw|Copper)_Nail|(Platinum|Steel|Cold_Steel|Titanium)_Ingot|.*?Shotgun_Shell.*?|.*_Arrow|.*_Bolt|.*_Fertilizer_?\\d*|.*_Grenade|.*_Pill|.*_Tonic|Aluminum|Ammo_Casing|Animal_Fat|Carbon_Fiber|Composites|Concrete_Mix|Cured_Leather_?\\d?|Electronics|Epoxy_?\\d?|Glass\\d?|Gunpowder\\w*|Health_.*|Titanium_Plate|Organic_Resin|Platinum_Sheath|Refined_[a-zA-Z]+|Rope|Shotgun_Casing|Steel_Bloom\\d?|Tree_Sap\\w*"
-- Ingot pattern for recipes that get count set to 1
local ingotPattern = "(?-s)(Platinum|Steel|Cold_Steel|Titanium)_Ingot|Aluminum|Refined_[a-zA-Z]+|Glass\\d?"
local factor = 16
local bonus = 0.5
for _, row in ipairs(data.Rows) do
local recipeName = row.Name
-- Special case: Biofuel recipes - remove Tree_Sap input
if string.find(recipeName, "Biofuel") then
if row.Inputs then
for i = #row.Inputs, 1, -1 do
local input = row.Inputs[i]
if input.Element and input.Element.RowName and string.find(input.Element.RowName, "Tree_Sap") then
table.remove(row.Inputs, i)
print("Removing input 'Tree_Sap' from processor recipe '" .. recipeName .. "'")
end
end
end
end
-- Ingot recipes: set input and output counts to 1
if matchesPattern(recipeName, ingotPattern) then
if row.Inputs then
for _, input in ipairs(row.Inputs) do
input.Count = 1
end
end
if row.Outputs then
for _, output in ipairs(row.Outputs) do
output.Count = 1
end
end
end
-- Selected recipes: multiply inputs by factor, outputs by factor * (1 + bonus)
if matchesPattern(recipeName, selectionPattern) then
if row.Inputs then
for _, input in ipairs(row.Inputs) do
local oldCount = input.Count
input.Count = input.Count * factor
print("Recipe " .. recipeName .. " Input.Count: " .. oldCount .. " -> " .. input.Count)
end
end
if row.Outputs then
for _, output in ipairs(row.Outputs) do
local oldCount = output.Count
output.Count = math.floor(output.Count * factor * (1 + bonus))
print("Recipe " .. recipeName .. " Output.Count: " .. oldCount .. " -> " .. output.Count)
end
end
end
end
`,
}
commands, err := ProcessJSON(content, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := content
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the result matches expected (preserves formatting and changes weight)
if result != expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result)
@@ -270,8 +609,239 @@ func TestRetardedJSONEditing(t *testing.T) {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the weight was changed
if result != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, result)
}
}
func TestRetardedJSONEditing2(t *testing.T) {
original := `
{
"Rows": [
{
"Name": "Deep_Mining_Drill_Biofuel",
"Meshable": {
"RowName": "Mesh_Deep_Mining_Drill_Biofuel"
},
"Itemable": {
"RowName": "Item_Deep_Mining_Drill_Biofuel"
},
"Interactable": {
"RowName": "Deployable"
},
"Focusable": {
"RowName": "Focusable_1H"
},
"Highlightable": {
"RowName": "Generic"
},
"Actionable": {
"RowName": "Deployable"
},
"Usable": {
"RowName": "Place"
},
"Deployable": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Durable": {
"RowName": "Deployable_750"
},
"Inventory": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Decayable": {
"RowName": "Decay_MetaItem"
},
"Generator": {
"RowName": "Deep_Mining_Biofuel_Drill"
},
"Resource": {
"RowName": "Simple_Internal_Flow_Only"
},
"Manual_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
}
]
},
"Generated_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
},
{
"TagName": "Traits.Meshable"
},
{
"TagName": "Traits.Itemable"
},
{
"TagName": "Traits.Interactable"
},
{
"TagName": "Traits.Highlightable"
},
{
"TagName": "Traits.Actionable"
},
{
"TagName": "Traits.Usable"
},
{
"TagName": "Traits.Deployable"
},
{
"TagName": "Traits.Durable"
},
{
"TagName": "Traits.Inventory"
}
],
"ParentTags": []
}
}
]
}
`
expected := `
{
"Rows": [
{
"Name": "Deep_Mining_Drill_Biofuel",
"Meshable": {
"RowName": "Mesh_Deep_Mining_Drill_Biofuel"
},
"Itemable": {
"RowName": "Item_Deep_Mining_Drill_Biofuel"
},
"Interactable": {
"RowName": "Deployable"
},
"Focusable": {
"RowName": "Focusable_1H"
},
"Highlightable": {
"RowName": "Generic"
},
"Actionable": {
"RowName": "Deployable"
},
"Usable": {
"RowName": "Place"
},
"Deployable": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Durable": {
"RowName": "Deployable_750"
},
"Inventory": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Decayable": {
"RowName": "Decay_MetaItem"
},
"Generator": {
"RowName": "Deep_Mining_Biofuel_Drill"
},
"Resource": {
"RowName": "Simple_Internal_Flow_Only"
},
"Manual_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
}
]
},
"Generated_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
},
{
"TagName": "Traits.Meshable"
},
{
"TagName": "Traits.Itemable"
},
{
"TagName": "Traits.Interactable"
},
{
"TagName": "Traits.Highlightable"
},
{
"TagName": "Traits.Actionable"
},
{
"TagName": "Traits.Usable"
},
{
"TagName": "Traits.Deployable"
},
{
"TagName": "Traits.Durable"
},
{
"TagName": "Traits.Inventory"
}
],
"ParentTags": []
}
,"AdditionalStats": {"(Value=\"BaseDeepMiningDrillSpeed_+%\")":4000}}
]
}
`
command := utils.ModifyCommand{
Name: "test",
Lua: `
for i, row in ipairs(data.Rows) do
-- Special case: Deep_Mining_Drill_Biofuel
if string.find(row.Name, "Deep_Mining_Drill_Biofuel") then
print("[DEBUG] Special case: Deep_Mining_Drill_Biofuel")
if not row.AdditionalStats then
print("[DEBUG] Creating AdditionalStats table for Deep_Mining_Drill_Biofuel")
row.AdditionalStats = {}
end
print("[DEBUG] Setting BaseDeepMiningDrillSpeed_+% to 4000")
row.AdditionalStats["(Value=\\\"BaseDeepMiningDrillSpeed_+%\\\")"] = 4000
end
end
`,
}
commands, err := ProcessJSON(original, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := original
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
if result != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, result)
}
}