From 3d5427128172d8f9be61a371b3325a2eaa615b6c Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Wed, 16 Sep 2026 20:01:46 +0200 Subject: [PATCH] test: add coverage for autobutcher, misery, seedwatch, logistics, suspendmanager Exercise plugin command parsing, config read/write paths, and overlay state logic in-engine. Tests that mutate state (seed targets, stockpile configs, job suspend flags, onStateChange hooks) snapshot and restore it in wrappers so the fort is left untouched. --- test/plugins/autobutcher.lua | 83 +++++++++++++++ test/plugins/logistics.lua | 125 +++++++++++++++++++++++ test/plugins/misery.lua | 60 +++++++++++ test/plugins/seedwatch.lua | 111 ++++++++++++++++++++ test/plugins/suspendmanager.lua | 175 ++++++++++++++++++++++++++++++++ 5 files changed, 554 insertions(+) create mode 100644 test/plugins/autobutcher.lua create mode 100644 test/plugins/logistics.lua create mode 100644 test/plugins/misery.lua create mode 100644 test/plugins/seedwatch.lua create mode 100644 test/plugins/suspendmanager.lua diff --git a/test/plugins/autobutcher.lua b/test/plugins/autobutcher.lua new file mode 100644 index 0000000000..236272a58c --- /dev/null +++ b/test/plugins/autobutcher.lua @@ -0,0 +1,83 @@ +config.mode = 'fortress' +config.target = 'autobutcher' + +local autobutcher = require('plugins.autobutcher') + +local function parse(args) + -- emulate the autobutcher_options struct passed by the C++ command handler + local opts = {races={insert=function(self, pos, v) table.insert(self, v) end}} + autobutcher.parse_commandline(opts, args) + return opts +end + +function test.help() + expect.true_(parse{'help'}.help) + expect.true_(parse{'-h'}.help) + expect.true_(parse{'--help'}.help) +end + +function test.simple_commands() + for _,cmd in ipairs{'now', 'autowatch', 'noautowatch', 'list', + 'list_export'} do + expect.eq(cmd, parse{cmd}.command) + end +end + +function test.watch_races() + local opts = parse{'watch', 'CAT', 'BIRD_CROW'} + expect.eq('watch', opts.command) + expect.eq(2, #opts.races) + expect.eq('CAT', opts.races[1].value) + expect.eq('BIRD_CROW', opts.races[2].value) +end + +function test.watch_all_and_new() + expect.true_(parse{'watch', 'all'}.races_all) + expect.true_(parse{'unwatch', 'new'}.races_new) + expect.true_(parse{'forget', 'all'}.races_all) +end + +function test.watch_requires_races() + expect.error_match('missing list of races', function() + parse{'watch'} end) +end + +function test.target() + local opts = parse{'target', '1', '2', '3', '4', 'CAT'} + expect.eq('target', opts.command) + expect.eq(1, opts.fk) + expect.eq(2, opts.mk) + expect.eq(3, opts.fa) + expect.eq(4, opts.ma) + expect.eq(1, #opts.races) + expect.eq('CAT', opts.races[1].value) +end + +function test.target_zero_allowed() + local opts = parse{'target', '0', '0', '0', '0', 'all'} + expect.eq(0, opts.fk) + expect.true_(opts.races_all) +end + +function test.target_rejects_bad_numbers() + expect.error_match('non%-negative integer', function() + parse{'target', '-1', '0', '0', '0', 'all'} end) + expect.error_match('non%-negative integer', function() + parse{'target', '1.5', '0', '0', '0', 'all'} end) + expect.error_match('non%-negative integer', function() + parse{'target', 'x', '0', '0', '0', 'all'} end) +end + +function test.target_requires_all_counts() + expect.error(function() parse{'target', '1', '2', '3'} end) +end + +function test.target_requires_races() + expect.error_match('missing list of races', function() + parse{'target', '1', '2', '3', '4'} end) +end + +function test.unrecognized_command() + expect.error_match('unrecognized command', function() + parse{'bogus'} end) +end diff --git a/test/plugins/logistics.lua b/test/plugins/logistics.lua new file mode 100644 index 0000000000..cb942ed10e --- /dev/null +++ b/test/plugins/logistics.lua @@ -0,0 +1,125 @@ +config.mode = 'fortress' +config.target = 'logistics' + +local logistics = require('plugins.logistics') + +local saved_autoretrain = logistics.logistics_getFeature('autoretrain') +local saved_configs = {} + +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + logistics.logistics_setFeature(saved_autoretrain, 'autoretrain') + for sp_number, configs in pairs(saved_configs) do + for _,c in ipairs(configs) do + logistics.logistics_setStockpileConfig( + c.stockpile_number, c.melt == 1, c.trade == 1, + c.dump == 1, c.train == 1, c.forbid, + c.melt_masterworks == 1) + end + end + end, test_fn) +end + +local function snapshot_config(sp_number) + if saved_configs[sp_number] then return end + saved_configs[sp_number] = + logistics.logistics_getStockpileConfigs(sp_number) or {} +end + +local function get_first_stockpile() + local stockpiles = df.global.world.buildings.other.STOCKPILE + if #stockpiles == 0 then return nil end + return stockpiles[0].stockpile_number +end + +local function get_stats_for(sp_number) + for _,sp in ipairs(logistics.getStockpileData().sp_stats) do + if sp.sp_number == sp_number then return sp end + end +end + +function test.help() + expect.false_(logistics.parse_commandline{'help'}) + expect.false_(logistics.parse_commandline{'-h'}) +end + +function test.status() + expect.true_(logistics.parse_commandline{}) + expect.true_(logistics.parse_commandline{'status'}) +end + +function test.unrecognized_command() + expect.false_(logistics.parse_commandline{'bogus'}) +end + +function test.autoretrain_toggle() + expect.true_(logistics.parse_commandline{'enable', 'autoretrain'}) + expect.true_(logistics.logistics_getFeature('autoretrain')) + expect.true_(logistics.parse_commandline{'disable', 'autoretrain'}) + expect.false_(logistics.logistics_getFeature('autoretrain')) +end + +function test.unknown_feature() + expect.error_match('unknown feature', function() + logistics.parse_commandline{'enable', 'bogus_feature'} end) +end + +function test.missing_feature() + expect.error(function() + logistics.parse_commandline{'enable'} end) +end + +function test.getStockpileData() + local data = logistics.getStockpileData() + expect.eq(#df.global.world.buildings.other.STOCKPILE, + #data.sp_stats) + -- totals are the sum of the per-stockpile stats + for _,desig in ipairs{'melt', 'trade', 'dump', 'train', 'forbid'} do + local designated, designatable = 0, 0 + for _,sp in ipairs(data.sp_stats) do + designated = designated + sp[desig].designated + designatable = designatable + sp[desig].designatable + end + expect.eq(designated, data.sp_totals[desig].designated) + expect.eq(designatable, data.sp_totals[desig].designatable) + end +end + +function test.add_and_clear_config() + local sp_number = get_first_stockpile() + if not sp_number then return end + snapshot_config(sp_number) + expect.true_(logistics.parse_commandline{ + 'add', 'melt', '-s', tostring(sp_number)}) + local sp = get_stats_for(sp_number) + expect.true_(sp.melt.enabled) + expect.false_(sp.trade.enabled) + expect.true_(logistics.parse_commandline{ + 'add', 'trade', 'dump', '-s', tostring(sp_number)}) + sp = get_stats_for(sp_number) + expect.true_(sp.melt.enabled) + expect.true_(sp.trade.enabled) + expect.true_(sp.dump.enabled) + expect.true_(logistics.parse_commandline{ + 'clear', '-s', tostring(sp_number)}) + sp = get_stats_for(sp_number) + expect.false_(sp.melt.enabled) + expect.false_(sp.trade.enabled) + expect.false_(sp.dump.enabled) +end + +function test.add_invalid_stockpile() + expect.printerr_match('invalid stockpile', function() + logistics.parse_commandline{'add', 'melt', '-s', '999999'} end) +end + +function test.cycle() + -- a cycle can designate items if any stockpile configs are enabled; + -- only run it when the fort has none so no state is mutated + for _,desig in ipairs{'melt', 'trade', 'dump', 'train', 'forbid', 'claim'} do + if logistics.getStockpileData().sp_totals[desig].enabled_count > 0 then + return + end + end + expect.true_(logistics.parse_commandline{'now'}) +end diff --git a/test/plugins/misery.lua b/test/plugins/misery.lua new file mode 100644 index 0000000000..82b38d3621 --- /dev/null +++ b/test/plugins/misery.lua @@ -0,0 +1,60 @@ +config.mode = 'fortress' +config.target = 'misery' + +local misery = require('plugins.misery') + +local saved_factor = misery.misery_getFactor() + +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + if saved_factor >= 2 then + misery.misery_setFactor(saved_factor) + end + end, test_fn) +end + +function test.status() + expect.true_(misery.parse_commandline('status')) + expect.true_(misery.parse_commandline()) +end + +function test.help() + expect.false_(misery.parse_commandline('help')) + expect.false_(misery.parse_commandline('-h')) + expect.false_(misery.parse_commandline('--help')) +end + +function test.set_factor() + expect.true_(misery.parse_commandline('5')) + expect.eq(5, misery.misery_getFactor()) + expect.true_(misery.parse_commandline('2')) + expect.eq(2, misery.misery_getFactor()) +end + +function test.set_factor_below_minimum() + misery.misery_setFactor(5) + -- the C++ layer rejects the value with a console error message rather + -- than failing the command + misery.misery_setFactor(1) + expect.eq(5, misery.misery_getFactor()) + expect.true_(misery.parse_commandline('1')) + expect.eq(5, misery.misery_getFactor()) +end + +function test.clear() + expect.true_(misery.parse_commandline('clear')) +end + +function test.unrecognized_command() + expect.false_(misery.parse_commandline('bogus')) +end + +function test.status_output() + misery.misery_setFactor(5) + local lines = {} + mock.patch({{misery, 'print', function(line) + table.insert(lines, line) end}}, function() + misery.status() + end) + expect.str_find('misery factor is: 5', table.concat(lines, '\n')) +end diff --git a/test/plugins/seedwatch.lua b/test/plugins/seedwatch.lua new file mode 100644 index 0000000000..8af3e7ebab --- /dev/null +++ b/test/plugins/seedwatch.lua @@ -0,0 +1,111 @@ +config.mode = 'fortress' +config.target = 'seedwatch' + +local seedwatch = require('plugins.seedwatch') + +local saved_targets = {} +do + local watch_map = seedwatch.seedwatch_getData() + for k, v in pairs(watch_map) do + saved_targets[k] = v + end +end + +config.wrapper = function(test_fn) + return dfhack.with_finalize(function() + -- restore original targets and zero out any watch entries that the + -- test created for previously unwatched plants (there is no API to + -- remove a watch entry entirely) + local watch_map = seedwatch.seedwatch_getData() + for k in pairs(watch_map) do + if saved_targets[k] == nil then saved_targets[k] = 0 end + end + for k, v in pairs(saved_targets) do + local plant = df.global.world.raws.plants.all[k] + if plant then + seedwatch.seedwatch_setTarget(plant.id, v) + end + end + end, test_fn) +end + +-- finds a seed-bearing non-tree plant, matching the set of plant ids that +-- seedwatch manages (see plugin_load_world_data in seedwatch.cpp) +local function get_seed_plant() + local plants = df.global.world.raws.plants.all + for i = 0, #plants - 1 do + local plant = plants[i] + if plant.material_defs.type[df.plant_material_def.seed] ~= -1 and + not plant.flags.TREE then + return i, plant.id + end + end +end + +local function all_targets() + local watch_map = seedwatch.seedwatch_getData() + return watch_map +end + +function test.status() + expect.true_(seedwatch.parse_commandline('status')) + expect.true_(seedwatch.parse_commandline()) +end + +function test.help() + expect.false_(seedwatch.parse_commandline('help')) + expect.false_(seedwatch.parse_commandline('-h')) +end + +function test.set_all_targets() + expect.true_(seedwatch.parse_commandline('all', '50')) + local targets = all_targets() + local count = 0 + for _ in pairs(targets) do count = count + 1 end + expect.gt(count, 0) + for _, v in pairs(targets) do + expect.eq(50, v) + end +end + +function test.clear() + expect.true_(seedwatch.parse_commandline('clear')) + for _, v in pairs(all_targets()) do + expect.eq(0, v) + end +end + +function test.set_single_plant() + local idx, id = get_seed_plant() + if not idx then return end + expect.true_(seedwatch.parse_commandline(id, '17')) + expect.eq(17, all_targets()[idx]) +end + +function test.target_floored() + local idx, id = get_seed_plant() + if not idx then return end + expect.true_(seedwatch.parse_commandline(id, '9.9')) + expect.eq(9, all_targets()[idx]) +end + +function test.unknown_plant() + local idx = get_seed_plant() + -- the C++ layer prints an error message but the command does not fail + expect.true_(seedwatch.parse_commandline( + 'THIS_PLANT_DOES_NOT_EXIST', '5')) + if idx then + expect.ne(5, all_targets()[idx]) + end +end + +function test.negative_target() + local _, id = get_seed_plant() + if not id then return end + expect.error_match('non%-negative integer', function() + seedwatch.parse_commandline(id, '-5') end) +end + +function test.missing_target() + expect.false_(seedwatch.parse_commandline('all')) +end diff --git a/test/plugins/suspendmanager.lua b/test/plugins/suspendmanager.lua new file mode 100644 index 0000000000..a9f5d3bec5 --- /dev/null +++ b/test/plugins/suspendmanager.lua @@ -0,0 +1,175 @@ +config.mode = 'fortress' +config.target = 'suspendmanager' + +local suspendmanager = require('plugins.suspendmanager') + +local function save_suspend_flags() + local flags = {} + suspendmanager.foreach_construction_job(function(job) + flags[job.id] = job.flags.suspend + end) + return flags +end + +local created_buildings = {} + +config.wrapper = function(test_fn) + local flags = save_suspend_flags() + local saved_onstatechange = dfhack.onStateChange.unsuspend + return dfhack.with_finalize(function() + dfhack.onStateChange.unsuspend = saved_onstatechange + for _,bld in ipairs(created_buildings) do + dfhack.buildings.deconstruct(bld) + end + created_buildings = {} + suspendmanager.foreach_construction_job(function(job) + if flags[job.id] ~= nil then + job.flags.suspend = flags[job.id] + end + end) + end, test_fn) +end + +-- scans the map for a tile where a 1x1 building can be placed +local function construct_test_building() + for _,block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local attrs = df.tiletype.attrs[block.tiletype[x][y]] + if attrs.shape == df.tiletype_shape.FLOOR and + block.occupancy[x][y].building == 0 then + local bld = dfhack.buildings.constructBuilding{ + type=df.building_type.Chair, + pos={x=block.map_pos.x+x, y=block.map_pos.y+y, + z=block.map_pos.z}} + if bld then + table.insert(created_buildings, bld) + return bld + end + end + end + end + end +end + +local function find_job_for(bld) + local found = nil + suspendmanager.foreach_construction_job(function(job) + if dfhack.job.getHolder(job) == bld then found = job end + end) + return found +end + +function test.foreach_construction_job() + local seen = {} + suspendmanager.foreach_construction_job(function(job) + expect.eq(df.job_type.ConstructBuilding, job.job_type) + seen[job.id] = true + end) + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + expect.ne(nil, job) + if not job then return end + expect.nil_(seen[job.id]) + expect.eq(df.job_type.ConstructBuilding, job.job_type) +end + +function test.run_once_unsuspends_everything() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + expect.ne(nil, job) + if not job then return end + job.flags.suspend = true + suspendmanager.runOnce(false, true, true) + expect.false_(job.flags.suspend) +end + +function test.unsuspend_command_args() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + expect.ne(nil, job) + if not job then return end + job.flags.suspend = true + -- -q is quiet, -f forces unsuspension of everything + suspendmanager.unsuspend_command('-q', '-f') + expect.false_(job.flags.suspend) +end + +function test.is_buildingplan_job() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + if not job then return end + -- buildings created without a material selection have mat_type == -1, + -- which is how suspendmanager identifies buildingplan-placed jobs + expect.true_(suspendmanager.isBuildingPlanJob(job)) +end + +function test.is_kept_suspended() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + if not job then return end + -- clearing all suspension reasons must synchronously unmark the job; + -- this is only deterministic within a single frame since an enabled + -- suspendmanager instance re-populates reasons on its own cycle + suspendmanager.runOnce(false, true, true) + expect.false_(suspendmanager.isKeptSuspended(job)) +end + +function test.overlay_update_building() + local overlay = suspendmanager.SuspendOverlay{} + local job = {flags={suspend=true}} + overlay:update_building(42, job) + local data = overlay.in_progress_buildings[42] + expect.true_(data.suspended) + expect.eq(1, data.suspend_count) + -- re-suspending while already suspended doesn't bump the count + overlay:update_building(42, job) + expect.eq(1, overlay.in_progress_buildings[42].suspend_count) + -- unsuspend then suspend again -> repeat offender + job.flags.suspend = false + overlay:update_building(42, job) + job.flags.suspend = true + overlay:update_building(42, job) + expect.eq(2, overlay.in_progress_buildings[42].suspend_count) +end + +function test.overlay_update_viewport() + local overlay = suspendmanager.SuspendOverlay{} + local viewport = {z=1, x1=0, y1=0, x2=10, y2=10} + expect.true_(overlay:update_viewport(viewport)) + expect.false_(overlay:update_viewport(viewport)) + expect.true_(overlay:update_viewport( + {z=2, x1=0, y1=0, x2=10, y2=10})) +end + +function test.overlay_onupdate() + local bld = construct_test_building() + if not bld then return end + local job = find_job_for(bld) + if not job then return end + job.flags.suspend = true + local overlay = suspendmanager.SuspendOverlay{} + overlay:overlay_onupdate() + local data = overlay.in_progress_buildings[bld.id] + expect.ne(nil, data) + expect.true_(data.suspended) + -- a second update clears entries whose version wasn't refreshed + overlay.in_progress_buildings[999999] = {version=0} + overlay:overlay_onupdate() + expect.nil_(overlay.in_progress_buildings[999999]) +end + +function test.status_overlay_no_selection() + local overlay = suspendmanager.StatusOverlay{} + expect.eq('Not suspended.', overlay:get_status_string()) +end + +function test.toggle_overlay_no_selection() + local overlay = suspendmanager.ToggleOverlay{} + expect.false_(overlay:shouldRender()) +end