From 0ed635c3772ec3e22bd719537aa7c5d28214c4ac Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 30 Mar 2026 09:57:59 +0200 Subject: [PATCH] Add Config Set import/export feature --- spec/System/TestConfigSetCodec_spec.lua | 177 ++++++++++++++++++++++++ src/Classes/ConfigTab.lua | 114 ++++++++++++++- 2 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 spec/System/TestConfigSetCodec_spec.lua diff --git a/spec/System/TestConfigSetCodec_spec.lua b/spec/System/TestConfigSetCodec_spec.lua new file mode 100644 index 0000000000..4514a61603 --- /dev/null +++ b/spec/System/TestConfigSetCodec_spec.lua @@ -0,0 +1,177 @@ +describe("TestConfigSetCodec", function() + local savedDeflate, savedInflate + + before_each(function() + newBuild() + -- Headless stubs for Deflate/Inflate return "", + -- which would break encode/decode. + -- Replaced them with identity functions, + -- so that the base64 + XML layer is tested in isolation. + savedDeflate = Deflate + savedInflate = Inflate + _G.Deflate = function(data) return data end + _G.Inflate = function(data) return data end + end) + + after_each(function() + _G.Deflate = savedDeflate + _G.Inflate = savedInflate + end) + + -- Mirrors the serialisation logic in ConfigTabClass:OpenExportConfigSetPopup. + local function encodeConfigSet(configSet, configTab) + local xmlNode = { elem = "ConfigSet", attrib = { title = configSet.title } } + for k, v in pairs(configSet.input) do + if v ~= configTab:GetDefaultState(k, type(v)) then + local node = { elem = "Input", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + elseif type(v) == "boolean" then + node.attrib.boolean = tostring(v) + else + node.attrib.string = tostring(v) + end + table.insert(xmlNode, node) + end + end + for k, v in pairs(configSet.placeholder) do + local node = { elem = "Placeholder", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + else + node.attrib.string = tostring(v) + end + table.insert(xmlNode, node) + end + local xmlText = common.xml.ComposeXML(xmlNode) + return common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") + end + + -- Mirrors the deserialisation logic in ConfigTabClass:OpenImportConfigSetPopup. + local function decodeConfigSet(code, configTab, name) + local xmlText = Inflate(common.base64.decode(code:gsub("-", "+"):gsub("_", "/"))) + if not xmlText or #xmlText == 0 then + return nil, "decode failed" + end + local parsedXML, errMsg = common.xml.ParseXML(xmlText) + if errMsg or not parsedXML or not parsedXML[1] or parsedXML[1].elem ~= "ConfigSet" then + return nil, errMsg or "invalid config set code" + end + local xmlConfigSet = parsedXML[1] + local newConfigSet = configTab:NewConfigSet(nil, name or xmlConfigSet.attrib.title or "Imported") + for _, child in ipairs(xmlConfigSet) do + if child.elem == "Input" and child.attrib.name then + if child.attrib.number then + newConfigSet.input[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.boolean then + newConfigSet.input[child.attrib.name] = child.attrib.boolean == "true" + elseif child.attrib.string then + newConfigSet.input[child.attrib.name] = child.attrib.string + end + elseif child.elem == "Placeholder" and child.attrib.name then + if child.attrib.number then + newConfigSet.placeholder[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.string then + newConfigSet.placeholder[child.attrib.name] = child.attrib.string + end + end + end + return newConfigSet, nil + end + + it("export produces a non-empty base64 code", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.input["usePowerCharges"] = true + local code = encodeConfigSet(configSet, configTab) + assert.is_not_nil(code) + assert.is_true(#code > 0) + end) + + it("roundtrip preserves boolean inputs", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.input["usePowerCharges"] = true + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Test") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal(true, imported.input["usePowerCharges"]) + end) + + it("roundtrip preserves numeric inputs", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + -- Use 75 to avoid matching any boss-level placeholder (83/84/85), + -- which would cause GetDefaultState skip export. + configSet.input["enemyLevel"] = 75 + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Test") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal(75, imported.input["enemyLevel"]) + end) + + it("roundtrip preserves string inputs", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + -- "Uber" is non-default (default is "Pinnacle" from defaultIndex=3) + configSet.input["enemyIsBoss"] = "Uber" + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Test") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal("Uber", imported.input["enemyIsBoss"]) + end) + + it("roundtrip preserves multiple values simultaneously", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.input["usePowerCharges"] = true + configSet.input["enemyLevel"] = 75 + configSet.input["enemyIsBoss"] = "Uber" + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Multi") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal(true, imported.input["usePowerCharges"]) + assert.are.equal(75, imported.input["enemyLevel"]) + assert.are.equal("Uber", imported.input["enemyIsBoss"]) + end) + + it("import uses the provided name, not the exported title", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.title = "Original Title" + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Custom Name") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal("Custom Name", imported.title) + end) + + it("export and re-import of an unmodified config set succeeds", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + local code = encodeConfigSet(configSet, configTab) + assert.is_true(#code > 0) + local imported, err = decodeConfigSet(code, configTab, "Imported") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal("Imported", imported.title) + end) + + it("returns error for garbage input", function() + local configTab = build.configTab + local _, err = decodeConfigSet("!!!not_valid!!!", configTab, "Test") + assert.is_not_nil(err) + end) + + it("returns error when decoded XML root is not a ConfigSet", function() + local configTab = build.configTab + local xmlText = "" + local code = common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") + local _, err = decodeConfigSet(code, configTab, "Test") + assert.is_not_nil(err) + end) +end) diff --git a/src/Classes/ConfigTab.lua b/src/Classes/ConfigTab.lua index da28d4e3f6..df2b33b99e 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -946,14 +946,124 @@ function ConfigTabClass:RestoreUndoState(state) end function ConfigTabClass:OpenConfigSetManagePopup() + local listControl = new("ConfigSetListControl", nil, {0, 50, 350, 200}, self) + local importConfig = new("ButtonControl", nil, {-99, 259, 90, 20}, "Import Config", function() + self:OpenImportConfigSetPopup() + end) + local exportConfig = new("ButtonControl", {"LEFT", importConfig, "RIGHT"}, {8, 0, 90, 20}, "Export Config", function() + if listControl.selValue then + self:OpenExportConfigSetPopup(self.configSets[listControl.selValue]) + end + end) + exportConfig.enabled = function() + return listControl.selValue ~= nil + end main:OpenPopup(370, 290, "Manage Config Sets", { - new("ConfigSetListControl", nil, {0, 50, 350, 200}, self), - new("ButtonControl", nil, {0, 260, 90, 20}, "Done", function() + listControl, + importConfig, + exportConfig, + new("ButtonControl", {"LEFT", exportConfig, "RIGHT"}, {8, 0, 90, 20}, "Done", function() main:ClosePopup() end), }) end +function ConfigTabClass:OpenExportConfigSetPopup(configSet) + local xmlNode = { elem = "ConfigSet", attrib = { title = configSet.title } } + for k, v in pairs(configSet.input) do + if v ~= self:GetDefaultState(k, type(v)) then + local node = { elem = "Input", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + elseif type(v) == "boolean" then + node.attrib.boolean = tostring(v) + else + node.attrib.string = tostring(v) + end + t_insert(xmlNode, node) + end + end + for k, v in pairs(configSet.placeholder) do + local node = { elem = "Placeholder", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + else + node.attrib.string = tostring(v) + end + t_insert(xmlNode, node) + end + local xmlText = common.xml.ComposeXML(xmlNode) + local code = common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") + local controls = { } + controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "Config set code:") + controls.edit = new("EditControl", nil, {0, 40, 350, 18}, code, nil, "%Z") + controls.copy = new("ButtonControl", nil, {-45, 70, 80, 20}, "Copy", function() + Copy(code) + end) + controls.done = new("ButtonControl", nil, {45, 70, 80, 20}, "Done", function() + main:ClosePopup() + end) + main:OpenPopup(380, 100, "Export Config Set", controls, "done", "edit") +end + +function ConfigTabClass:OpenImportConfigSetPopup() + local controls = { } + controls.nameLabel = new("LabelControl", nil, {-180, 20, 0, 16}, "Enter name for this config set:") + controls.name = new("EditControl", nil, {100, 20, 350, 18}, "", nil, nil, nil, function(buf) + controls.msg.label = "" + controls.import.enabled = buf:match("%S") and controls.edit.buf:match("%S") + end) + controls.editLabel = new("LabelControl", nil, {-150, 45, 0, 16}, "Enter config set code:") + controls.edit = new("EditControl", nil, {100, 45, 350, 18}, "", nil, nil, nil, function(buf) + controls.msg.label = "" + controls.import.enabled = buf:match("%S") and controls.name.buf:match("%S") + end) + controls.msg = new("LabelControl", nil, {0, 65, 0, 16}, "") + controls.import = new("ButtonControl", nil, {-45, 85, 80, 20}, "Import", function() + local buf = controls.edit.buf + if #buf == 0 then return end + local xmlText = Inflate(common.base64.decode(buf:gsub("-", "+"):gsub("_", "/"))) + if not xmlText then + controls.msg.label = "^1Invalid code" + return + end + local parsedXML, errMsg = common.xml.ParseXML(xmlText) + if errMsg or not parsedXML or not parsedXML[1] or parsedXML[1].elem ~= "ConfigSet" then + controls.msg.label = "^1Invalid config set code" + return + end + local xmlConfigSet = parsedXML[1] + local newConfigSet = self:NewConfigSet(nil, controls.name.buf) + for _, child in ipairs(xmlConfigSet) do + if child.elem == "Input" and child.attrib.name then + if child.attrib.number then + newConfigSet.input[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.boolean then + newConfigSet.input[child.attrib.name] = child.attrib.boolean == "true" + elseif child.attrib.string then + newConfigSet.input[child.attrib.name] = child.attrib.string + end + elseif child.elem == "Placeholder" and child.attrib.name then + if child.attrib.number then + newConfigSet.placeholder[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.string then + newConfigSet.placeholder[child.attrib.name] = child.attrib.string + end + end + end + t_insert(self.configSetOrderList, newConfigSet.id) + self.modFlag = true + self:AddUndoState() + self.build:SyncLoadouts() + main:ClosePopup() + end) + controls.import.enabled = false + controls.cancel = new("ButtonControl", nil, {45, 85, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(580, 115, "Import Config Set", controls, "import", "name") +end + -- Creates a new config set function ConfigTabClass:NewConfigSet(configSetId, title) local configSet = { id = configSetId, title = title, input = { }, placeholder = { } }