diff --git a/docs/changelog.txt b/docs/changelog.txt index be0b51c69e..645bcc4b12 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -74,6 +74,7 @@ Template for new versions: - `buildingplan`: fix roller material estimate asking for one chain per tile instead of one chain total - `buildingplan`: fix "Unlink all" only unlinking some mechanisms (or crashing) when freeing mechanisms from the building - `tiletypes`: remove plants (including trees) rooted on a tile that is painted into something that cannot host them, so cleared ground no longer regrows floating trees +- `eventful`: unhook all registered Lua callbacks on world unload; previously ``onReactionCompleting`` and ``onWorkshopFillSidebarMenu`` hooks leaked into newly loaded worlds - `timestream`: deal properly with units who have breathing difficulties - `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable - Fixed persistent site data API (``dfhack.persistent.saveSiteData``/``getSiteData``) failing on newly reclaimed fortresses until the first save diff --git a/plugins/lua/eventful.lua b/plugins/lua/eventful.lua index 4cbcbeef20..7b7e6352a9 100644 --- a/plugins/lua/eventful.lua +++ b/plugins/lua/eventful.lua @@ -38,7 +38,9 @@ end _registeredStuff={} local function unregall(state) if state==SC_WORLD_UNLOADED then + onReactionCompleting._library=nil onReactionComplete._library=nil + onWorkshopFillSidebarMenu._library=nil postWorkshopFillSidebarMenu._library=nil dfhack.onStateChange.eventful= nil _registeredStuff={} diff --git a/test/plugins/dig.lua b/test/plugins/dig.lua new file mode 100644 index 0000000000..116a0a9a2a --- /dev/null +++ b/test/plugins/dig.lua @@ -0,0 +1,101 @@ +config.mode = 'fortress' +config.target = 'dig' + +local saved_cursor + +config.wrapper = function(test_fn) + saved_cursor = xyz2pos(df.global.cursor.x, df.global.cursor.y, + df.global.cursor.z) + return dfhack.with_finalize(function() + df.global.cursor.x = saved_cursor.x + df.global.cursor.y = saved_cursor.y + df.global.cursor.z = saved_cursor.z + end, test_fn) +end + +local function set_cursor(pos) + df.global.cursor.x = pos.x + df.global.cursor.y = pos.y + df.global.cursor.z = pos.z +end + +local function designation_at(pos) + local block = dfhack.maps.getTileBlock(pos) + return block.designation[pos.x % 16][pos.y % 16].dig +end + +-- find a solid, undesignated block-center wall tile to dig in. +-- a diameter-5 circle around the block center stays within the block +local function find_diggable_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + local attrs = df.tiletype.attrs[block.tiletype[8][8]] + local mat = attrs.material + local basic = df.tiletype_shape.attrs[attrs.shape].basic_shape + if (mat == df.tiletype_material.SOIL + or mat == df.tiletype_material.STONE) + and basic == df.tiletype_shape_basic.Wall + and block.designation[8][8].dig + == df.tile_dig_designation.No then + return xyz2pos(block.map_pos.x + 8, + block.map_pos.y + 8, + block.map_pos.z) + end + end + error('no diggable tile found') +end + +function test.digcircle_help() + local output, status = dfhack.run_command_silent('digcircle', 'help') + expect.true_(output:find('designation of filled and hollow circles', 1, true) + ~= nil) +end + +function test.digcircle_requires_cursor() + set_cursor(xyz2pos(-30000, -30000, -30000)) + local output, status = dfhack.run_command_silent('digcircle', 'filled', '5') + expect.true_(output:find("Can't get the cursor coords", 1, true) ~= nil) +end + +function test.digcircle_designates_and_unsets() + -- digcircle remembers set/unset, diameter, and fill across calls + -- ("do it again" feature), so always pass them explicitly + local pos = find_diggable_pos() + set_cursor(pos) + dfhack.run_command_silent('digcircle', 'set', 'filled', '5', 'dig') + expect.eq(df.tile_dig_designation.Default, designation_at(pos)) + dfhack.run_command_silent('digcircle', 'unset', 'filled', '5', 'dig') + expect.eq(df.tile_dig_designation.No, designation_at(pos)) +end + +-- channels apply to floors/stairs, not walls (see dig.cpp dig() checks) +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + local tt = block.tiletype[8][8] + local attrs = df.tiletype.attrs[tt] + if attrs.shape == df.tiletype_shape.FLOOR + and attrs.material ~= df.tiletype_material.CONSTRUCTION + and block.designation[8][8].dig + == df.tile_dig_designation.No + and not block.designation[8][8].hidden then + return xyz2pos(block.map_pos.x + 8, + block.map_pos.y + 8, + block.map_pos.z) + end + end + error('no channelable floor tile found') +end + +function test.digcircle_channel_designation() + -- channels designate on floors/stairs; this floor tile is a valid target + local pos = find_floor_pos() + set_cursor(pos) + dfhack.run_command_silent('digcircle', 'set', 'filled', '3', 'chan') + expect.eq(df.tile_dig_designation.Channel, designation_at(pos)) + dfhack.run_command_silent('digcircle', 'unset', 'filled', '3', 'chan') + expect.eq(df.tile_dig_designation.No, designation_at(pos)) +end + +function test.digexp_help() + local output, status = dfhack.run_command_silent('digexp', 'help') + expect.true_(output:find('pattern', 1, true) ~= nil) +end diff --git a/test/plugins/eventful.lua b/test/plugins/eventful.lua new file mode 100644 index 0000000000..94ef60103d --- /dev/null +++ b/test/plugins/eventful.lua @@ -0,0 +1,82 @@ +config.mode = 'fortress' +config.target = 'eventful' + +local eventful = require('plugins.eventful') + +local function cleanup() + eventful._registeredStuff = {} + eventful.onReactionCompleting._library = nil + eventful.onReactionComplete._library = nil + eventful.onWorkshopFillSidebarMenu._library = nil + eventful.postWorkshopFillSidebarMenu._library = nil + dfhack.onStateChange.eventful = nil +end + +config.wrapper = function(test_fn) + cleanup() + return dfhack.with_finalize(cleanup, test_fn) +end + +function test.eventType_table() + expect.eq(0, eventful.eventType.TICK) + expect.eq(3, eventful.eventType.JOB_COMPLETED) + expect.eq(16, eventful.eventType.EVENT_MAX) +end + +function test.registerReaction() + local cb = function() end + eventful.registerReaction('TEST_REACTION', cb) + expect.eq(cb, eventful._registeredStuff.reactionCallbacks.TEST_REACTION) + expect.ne(nil, eventful.onReactionCompleting._library) + expect.ne(nil, dfhack.onStateChange.eventful) +end + +function test.reaction_callback_dispatches() + local got + eventful.registerReaction('TEST_REACTION', + function(reaction) got = reaction.code end) + eventful.onReactionCompleting._library({code='TEST_REACTION'}) + expect.eq('TEST_REACTION', got) +end + +function test.reaction_callback_ignores_unregistered() + local called = false + eventful.registerReaction('TEST_REACTION', function() called = true end) + eventful.onReactionCompleting._library({code='OTHER_REACTION'}) + expect.false_(called) +end + +function test.registerSidebar() + local cb = function() end + eventful.registerSidebar('CARPENTERS', cb) + expect.eq(cb, eventful._registeredStuff.customSidebar.CARPENTERS) + expect.ne(nil, eventful.onWorkshopFillSidebarMenu._library) +end + +function test.addReactionToShop() + eventful.addReactionToShop('TEST_REACTION', 'MASONS') + expect.table_eq({'TEST_REACTION'}, + eventful._registeredStuff.reactionToShop.MASONS) + expect.ne(nil, eventful.postWorkshopFillSidebarMenu._library) +end + +function test.removeNative() + eventful.removeNative('STILL', 'COOK_FOOD') + expect.table_eq({'COOK_FOOD'}, + eventful._registeredStuff.shopNonNative.STILL) + eventful.removeNative('STILL') + expect.true_(eventful._registeredStuff.shopNonNative.STILL.all) +end + +function test.world_unload_clears_registration() + eventful.registerReaction('TEST_REACTION', function() end) + eventful.registerSidebar('CARPENTERS', function() end) + eventful.addReactionToShop('TEST_REACTION', 'MASONS') + eventful.removeNative('STILL') + dfhack.onStateChange.eventful(SC_WORLD_UNLOADED) + expect.table_eq({}, eventful._registeredStuff) + expect.nil_(eventful.onReactionCompleting._library) + expect.nil_(eventful.onWorkshopFillSidebarMenu._library) + expect.nil_(eventful.postWorkshopFillSidebarMenu._library) + expect.nil_(dfhack.onStateChange.eventful) +end diff --git a/test/plugins/hotkeys.lua b/test/plugins/hotkeys.lua new file mode 100644 index 0000000000..620c0f3fd2 --- /dev/null +++ b/test/plugins/hotkeys.lua @@ -0,0 +1,77 @@ +config.mode = 'fortress' +config.target = 'hotkeys' + +local hotkeys = require('plugins.hotkeys') + +local BINDING_SPEC = 'Ctrl-Shift-Alt-F11@dwarfmode' +local BINDING_CMD = 'echo dfhack-test-binding' + +config.wrapper = function(test_fn) + dfhack.run_command_silent{'keybinding', 'clear', BINDING_SPEC} + return dfhack.with_finalize(function() + dfhack.run_command_silent{'keybinding', 'clear', BINDING_SPEC} + hotkeys.cleanupHotkeys() + end, test_fn) +end + +-- key symbol strings get normalized by the keybinding manager (e.g. +-- modifier order), so match added bindings by their command instead +local function find_binding(cmdline) + local keys, bindings = hotkeys.getHotkeys() + for _, sym in ipairs(keys) do + if bindings[sym] == cmdline then return sym end + end +end + +local function with_mortal_mode(test_fn) + local saved = dfhack.getMortalMode() + dfhack.setMortalMode(true) + return dfhack.with_finalize(function() + dfhack.setMortalMode(saved) + end, test_fn) +end + +function test.should_hide_armok() + -- createitem is tagged 'armok' in helpdb + with_mortal_mode(function() + expect.true_(hotkeys.should_hide_armok('createitem')) + expect.false_(hotkeys.should_hide_armok('ls')) + expect.false_(hotkeys.should_hide_armok('bogus-command')) + end) +end + +function test.should_hide_armok_strips_prefix() + with_mortal_mode(function() + expect.true_(hotkeys.should_hide_armok(':createitem')) + expect.true_(hotkeys.should_hide_armok(' createitem --flags')) + expect.false_(hotkeys.should_hide_armok(':')) + end) +end + +function test.should_hide_armok_not_mortal() + -- armok commands stay visible when mortal mode is off + with_mortal_mode(function() + dfhack.setMortalMode(false) + expect.false_(hotkeys.should_hide_armok('createitem')) + end) +end + +function test.getHotkeys_returns_state() + local keys, bindings = hotkeys.getHotkeys() + expect.ne(nil, keys) + expect.ne(nil, bindings) +end + +function test.getHotkeys_finds_added_binding() + dfhack.run_command_silent{'keybinding', 'add', BINDING_SPEC, BINDING_CMD} + expect.ne(nil, find_binding(BINDING_CMD)) +end + +function test.getHotkeys_filters_menu_bindings() + -- stub keybindings that invoke the hotkeys menu itself must not be listed + dfhack.run_command_silent{'keybinding', 'add', BINDING_SPEC, + 'overlay trigger hotkeys.foo'} + expect.nil_(find_binding('overlay trigger hotkeys.foo')) + dfhack.run_command_silent{'keybinding', 'add', BINDING_SPEC, 'hotkeys'} + expect.nil_(find_binding('hotkeys')) +end diff --git a/test/plugins/stockpiles.lua b/test/plugins/stockpiles.lua new file mode 100644 index 0000000000..21a04e5411 --- /dev/null +++ b/test/plugins/stockpiles.lua @@ -0,0 +1,90 @@ +config.mode = 'fortress' +config.target = 'stockpiles' + +local stockpiles = require('plugins.stockpiles') + +local TEST_NAME = 'test_dfstock' + +local function test_file_path() + return ('%s/stockpiles/%s.dfstock'):format(dfhack.getConfigPath(), + TEST_NAME) +end + +local function rm_test_file() + os.remove(test_file_path()) +end + +config.wrapper = function(test_fn) + rm_test_file() + return dfhack.with_finalize(rm_test_file, test_fn) +end + +local function make_stockpile() + -- a 2x2 stockpile on any free floor tile + for _, block in ipairs(df.global.world.map.map_blocks) do + local tt = df.tiletype.attrs[block.tiletype[8][8]] + if tt.shape == df.tiletype_shape.FLOOR + and block.occupancy[8][8].building + == df.tile_building_occ.None then + local pos = xyz2pos(block.map_pos.x + 7, block.map_pos.y + 7, + block.map_pos.z) + local bld, err = dfhack.buildings.constructBuilding{ + pos=pos, type=df.building_type.Stockpile, + width=2, height=2, abstract=true} + if bld then return bld end + end + end + error('could not place a test stockpile') +end + +function test.status_and_help() + expect.true_(stockpiles.parse_commandline({'status'})) + expect.false_(stockpiles.parse_commandline({'help'})) + expect.false_(stockpiles.parse_commandline({'bogus'})) +end + +function test.export_requires_name() + expect.error_match('name missing or empty', function() + stockpiles.parse_commandline({'export'}) + end) +end + +function test.export_rejects_unsafe_name() + expect.error_match('numbers, letters', function() + stockpiles.parse_commandline({'export', 'bad/name'}) + end) + expect.error_match('numbers, letters', function() + stockpiles.parse_commandline({'export', 'bad name'}) + end) +end + +function test.export_requires_stockpile() + -- valid name, but no stockpile selected or specified + expect.error_match('select a stockpile', function() + stockpiles.parse_commandline({'export', TEST_NAME}) + end) +end + +function test.stockpile_opt_unknown_name() + expect.error_match('could not find stockpile', function() + stockpiles.parse_commandline({'status', '-s', 'no_such_pile'}) + end) +end + +function test.list_settings_files() + -- the library dir ships stock settings files, so list must succeed + expect.true_(stockpiles.parse_commandline({'list'})) +end + +function test.export_import_roundtrip() + local sp = make_stockpile() + local ok, err = pcall(function() + expect.true_(stockpiles.parse_commandline( + {'export', TEST_NAME, '-s', tostring(sp.id)})) + expect.true_(dfhack.filesystem.isfile(test_file_path())) + expect.true_(stockpiles.parse_commandline( + {'import', TEST_NAME, '-s', tostring(sp.id)})) + end) + dfhack.buildings.deconstruct(sp) + if not ok then error(err, 0) end +end diff --git a/test/plugins/tailor.lua b/test/plugins/tailor.lua new file mode 100644 index 0000000000..3f21c9062f --- /dev/null +++ b/test/plugins/tailor.lua @@ -0,0 +1,68 @@ +config.mode = 'fortress' +config.target = 'tailor' + +local tailor = require('plugins.tailor') + +local saved_confiscate, saved_dye, saved_mats + +config.wrapper = function(test_fn) + saved_confiscate = tailor.tailor_getConfiscate() + saved_dye = tailor.tailor_getAutomateDye() + saved_mats = tailor.tailor_getMaterialPreferences() + return dfhack.with_finalize(function() + tailor.tailor_setConfiscate(saved_confiscate) + tailor.tailor_setAutomateDye(saved_dye) + tailor.parse_commandline('materials', table.unpack(saved_mats)) + end, test_fn) +end + +function test.status_and_unknown_command() + expect.true_(tailor.parse_commandline()) + expect.true_(tailor.parse_commandline('status')) + expect.true_(tailor.parse_commandline('now')) + expect.false_(tailor.parse_commandline('help')) + expect.false_(tailor.parse_commandline('--help')) + expect.false_(tailor.parse_commandline('bogus')) +end + +function test.confiscate_on_off() + expect.true_(tailor.parse_commandline('confiscate', 'on')) + expect.true_(tailor.tailor_getConfiscate()) + expect.true_(tailor.parse_commandline('confiscate', 'off')) + expect.false_(tailor.tailor_getConfiscate()) +end + +function test.confiscate_rejects_bad_arg() + expect.error(function() + tailor.parse_commandline('confiscate', 'bogus') + end) +end + +function test.dye_on_off() + expect.true_(tailor.parse_commandline('dye', 'on')) + expect.true_(tailor.tailor_getAutomateDye()) + expect.true_(tailor.parse_commandline('dye', 'off')) + expect.false_(tailor.tailor_getAutomateDye()) +end + +function test.dye_rejects_bad_arg() + expect.error(function() + tailor.parse_commandline('dye', 'bogus') + end) +end + +function test.materials_ordering() + expect.true_(tailor.parse_commandline('materials', 'silk', 'cloth')) + expect.table_eq({'silk', 'cloth'}, tailor.tailor_getMaterialPreferences()) + expect.true_(tailor.parse_commandline('materials', 'adamantine')) + expect.table_eq({'adamantine'}, tailor.tailor_getMaterialPreferences()) +end + +function test.materials_no_args_restores_default() + -- bare 'materials' zeroes the order, which the C++ side treats as + -- "restore defaults" (adamantine is not a default material) + tailor.parse_commandline('materials', 'adamantine') + expect.true_(tailor.parse_commandline('materials')) + expect.table_eq({'silk', 'cloth', 'yarn', 'leather'}, + tailor.tailor_getMaterialPreferences()) +end