Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0a192a7
fix strangemood crash on non-numeric --id value
Alistair-Afton Sep 17, 2026
1db1882
test: add coverage for autodump, filltraffic, export-world-map, stran…
Alistair-Afton Sep 17, 2026
1ae50a0
suspendmanager: fix out-of-bounds read on bare 'set'
Alistair-Afton Sep 17, 2026
d4e1c76
test: add coverage for liquids, tubefill, suspendmanager, 3dveins, cr…
Alistair-Afton Sep 17, 2026
079d8b8
test: add coverage for flows, showmood, deramp, changelayer, changeitem
Alistair-Afton Sep 17, 2026
cb0b86b
fix 3dveins crash when a tile's biome cannot be resolved
Alistair-Afton Sep 17, 2026
3c580f0
restore global state in test cleanup
Alistair-Afton Sep 17, 2026
05b8137
use dfhack.world pause API instead of raw pause_state
Alistair-Afton Sep 17, 2026
43a414a
strangemood test: validate mood job_items against vanilla request tables
Alistair-Afton Sep 17, 2026
9787a8b
export-world-map: fix crash on sites with an owner of undefined race
Alistair-Afton Sep 17, 2026
cc0ef10
strangemood test: mirror STRANGE_MOODS caste check in unit picker
Alistair-Afton Sep 17, 2026
f8351ea
autodump test: use gui.dwarfmode cursor helpers
Alistair-Afton Sep 17, 2026
d310a26
remove duplicated timestream changelog entry
Alistair-Afton Sep 18, 2026
ef1c305
strangemood test: allow THREAD requests and check their dimensions
Alistair-Afton Sep 19, 2026
1f64b0d
plugin tests: adopt dwarfmode cursor and maps/item APIs, extend coverage
Alistair-Afton Sep 19, 2026
85327b7
Merge branch 'develop' into test-plugin-coverage-10
ab9rf Sep 19, 2026
6d4be6f
Merge branch 'develop' into test-plugin-coverage-10
ab9rf Sep 19, 2026
4dfcb69
reorder headers per contribution standards
ab9rf Sep 20, 2026
275d09c
Merge branch 'develop' into test-plugin-coverage-10
ab9rf Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,30 @@ Template for new versions:

## Fixes
- Fix broken weather lookup in ``World::ReadCurrentWeather``
- Fix ``dfhack.items.createItem`` making glob items of an incorrect size
- Fixed a possible hang or assertion failure when pressing a hotkey while a DFHack GUI window was open but unfocused
- Fixed persistent site data API (``dfhack.persistent.saveSiteData``/``getSiteData``) failing on newly reclaimed fortresses until the first save
- In ``Screen`` module, Fix out-of-bounds color table access when Lua pens use ``COLOR_RESET``
- rename the ``stockpiles`` tool tag to ``stockpile`` so it no longer collides with the `stockpiles` plugin in `gui/launcher`
- ``Units::teleport``: update unit occupancy on the full 3x3 footprint of EQUIPMENT units (e.g. wagons) instead of only their center tile
- ``Units::teleport``: only clear tile unit occupancy flags when no other unit of the same kind remains on the tile
- `3dveins`: fix crash when a tile's biome cannot be resolved
- `3dveins`: fix failure on embarks that cross midmap tiles
- `aquifer`: make ``--skip-top`` and top-relative ``--levels`` take effect for the ``drain``, ``convert``, and ``add`` actions instead of being silently ignored
- `autodump`: ``destroy`` no longer leaves the contents of destroyed containers in limbo or crashes when destroying unit-held items
- `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
- `export-world-map`: fix crash exporting site data for a site whose owner has an undefined race
- `overlay`: overlay hotkeys no longer steal typed characters while the game is capturing text input, e.g. when renaming a building
- `sort`: fix the Places/Workshops and task searches missing workshops by their current display names (e.g. "Stoneworker's Workshop", "Screw Press")
- `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable
- `strangemood`: updated to match behavior of current DF version
- `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
- `gui/quickfort`: show smoothing designations immediately after applying a blueprint
- `timestream`: deal properly with units who have breathing difficulties
- Fixed persistent site data API (``dfhack.persistent.saveSiteData``/``getSiteData``) failing on newly reclaimed fortresses until the first save
- Fix ``dfhack.items.createItem`` making glob items of an incorrect size
- `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable
- `strangemood`: fix crash when ``--id`` is given a non-numeric unit id
- `suspendmanager`: fix out-of-bounds parameter read when ``set`` is given too few arguments

## Misc Improvements
- Added ``Coord2d`` and ``Coord3d`` C++ templates, providing a standard set operations for 2-tuples and 3-tuples of any numeric type
Expand Down
4 changes: 3 additions & 1 deletion plugins/3dveins.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,9 @@ bool GeoBiome::init_layers()
GeoLayer *VeinGenerator::mapLayer(Block *pb, df::coord2d tile)
{
int idx = pb->biomeIndexAt(tile);
GeoBiome *biome = biome_by_idx.at(idx);
if (idx < 0)
return NULL;
GeoBiome *biome = biome_by_idx[idx];

int lidx = pb->layerIndexAt(tile);
if (unsigned(lidx) >= biome->layers.size())
Expand Down
5 changes: 3 additions & 2 deletions plugins/export-world-map.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,10 @@ static command_result export_sites(color_ostream &out)
df::creature_raw *race = nullptr;
if (owner){
race = df::creature_raw::find(owner->race);
DEBUG(warning, out).print("owner ({}) of site ({}) has undefined race ({})", owner->id, site->id, owner->race);
if (!race) {
df::creature_raw::find(civ->race);
DEBUG(warning, out).print("owner ({}) of site ({}) has undefined race ({})\n", owner->id, site->id, owner->race);
if (civ)
race = df::creature_raw::find(civ->race);
}
}

Expand Down
9 changes: 8 additions & 1 deletion plugins/strangemood.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "Console.h"
#include "DataDefs.h"
#include "Export.h"
#include "MiscUtils.h"
#include "PluginManager.h"

#include "modules/Gui.h"
Expand Down Expand Up @@ -174,7 +175,13 @@ command_result df_strangemood (color_ostream &out, vector <string> & parameters)
out.printerr("No unit id specified!\n");
return CR_WRONG_USAGE;
}
unit = df::unit::find(std::stoi(parameters[i]));
int id = string_to_int(parameters[i], -1);
if (id < 0)
{
out.printerr("Invalid unit id: '{}'\n", parameters[i]);
return CR_WRONG_USAGE;
}
unit = df::unit::find(id);
if (!unit)
return CR_FAILURE;
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/suspendmanager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ static command_result do_command(color_ostream &out, vector<string> &parameters)
return plugin_enable(out,true);
} else if (parameters[0] == "disable") {
return plugin_enable(out,false);
} else if (parameters[0] == "set" && parameters[1] == "preventblocking") {
} else if (parameters.size() == 3 && parameters[0] == "set" && parameters[1] == "preventblocking") {
if (parameters[2] == "true") {
suspendmanager_instance->prevent_blocking = true;
config.set_bool(CONFIG_PREVENT_BLOCKING, true);
Expand Down
18 changes: 18 additions & 0 deletions test/plugins/3dveins.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
config.mode = 'fortress'
config.target = '3dveins'

function test.rewrites_veins()
local output, status = dfhack.run_command_silent('3dveins')
expect.eq(CR_OK, status)
expect.str_find('Writing tiles', output)
end

function test.verbose_option()
local _, status = dfhack.run_command_silent('3dveins', 'verbose')
expect.eq(CR_OK, status)
end

function test.bad_option_is_wrong_usage()
local _, status = dfhack.run_command_silent('3dveins', 'bogus')
expect.eq(CR_WRONG_USAGE, status)
end
147 changes: 147 additions & 0 deletions test/plugins/autodump.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
config.mode = 'fortress'
config.target = 'autodump'

local dwarfmode = require('gui.dwarfmode')

local function find_floor_pos()
for _, block in ipairs(df.global.world.map.map_blocks) do
for x = 0, 15 do
for y = 0, 15 do
local tt = block.tiletype[x][y]
local des = block.designation[x][y]
local occ = block.occupancy[x][y]
if df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR
and not des.hidden and occ.building == 0 then
return block.map_pos.x + x, block.map_pos.y + y,
block.map_pos.z
end
end
end
end
end

local function set_cursor(x, y, z)
dwarfmode.setCursorPos(xyz2pos(x, y, z))
end

local function clear_cursor()
dwarfmode.clearCursorPos()
end
Comment thread
SilasD marked this conversation as resolved.

local function find_dumpable_item()
for _, item in ipairs(df.global.world.items.other.IN_PLAY) do
if not item.flags.dump and not item.flags.construction
and not item.flags.in_building and not item.flags.artifact
and not item.flags.in_job and not item.flags.forbid
and not item.flags.owned and not item.flags.in_inventory then
return item
end
end
end

local function block_has_item(block, id)
for _, bid in ipairs(block.items) do
if bid == id then return true end
end
return false
end

local function items_sorted(block)
local prev = -1
for _, id in ipairs(block.items) do
if id <= prev then return false end
prev = id
end
return true
end

function test.dump_moves_item_to_cursor()
local x, y, z = find_floor_pos()
local item = find_dumpable_item()
expect.ne(nil, x, 'test needs a revealed floor tile')
expect.ne(nil, item, 'test needs a dumpable item')
local ix, iy, iz = dfhack.items.getPosition(item)
expect.ne(nil, ix, 'test item needs a real position')
local old_block = dfhack.maps.getTileBlock(ix, iy, iz)
local new_block = dfhack.maps.getTileBlock(x, y, z)

return dfhack.with_finalize(function()
item.flags.dump = false
clear_cursor()
end, function()
set_cursor(x, y, z)
item.flags.dump = true

local _, status = dfhack.run_command_silent('autodump')
expect.eq(CR_OK, status)
-- item was teleported to the cursor and marked as dumped
expect.eq(x, item.pos.x)
expect.eq(y, item.pos.y)
expect.eq(z, item.pos.z)
expect.false_(item.flags.dump)
expect.true_(item.flags.forbid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TO BE CLEAR, I am NOT insisting that you exercise all or any of these possibliities.

I will NOT hold up a merge over this. any test suite is better than none at all, and I am grateful to have these tests.

this all looks good. another place to test is the map_block the item is in; map_block.items is a sorted vector of item.ids.

it would be best to check that the item ids are removed from the old map_block and inserted into the new map_block on item teleport. including, I suppose, the special case of teleporting to another tile in the same map_block (or even to the item's current tile), which shouldn't change that vector at all.

edit: verifying removal of an item from a map_block is covered below.

edit: it would be good to verify that the map_block.items list remains sorted.

to test this properly, you probably need to find a map_block with a lot of items in it, i.e. #map_block.items has a lot of entries.

edit: there is a tile_occupancy.item flag that should be set on the items' new tile.

edit: item.flags.on_ground should be set, item.flags.in_inventory should be clear, item.flags.in_building should be clear.

expect.true_(item.flags.on_ground)
expect.false_(item.flags.in_inventory)
expect.false_(item.flags.in_building)

-- the destination tile's occupancy and the map_block item
-- vectors must reflect the move
local _, occ = dfhack.maps.getTileFlags(x, y, z)
expect.true_(occ.item)
expect.true_(block_has_item(new_block, item.id),
'item id should be in the new map_block.items')
if old_block ~= new_block then
expect.false_(block_has_item(old_block, item.id),
'item id should be removed from the old map_block.items')
end
expect.true_(items_sorted(new_block),
'map_block.items should remain sorted')
end)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TO BE CLEAR, I am NOT insisting that you exercise all or any of these possibliities.

I will NOT hold up a merge over this. any test suite is better than none at all, and I am grateful to have these tests.

this test suite doesn't have anything that verifies the item is removed from the old location.

fully exercising that will be rather hard, as there are three four common cases:

the item was on the floor.

  • items with item.flags.construction set should not be teleported.
  • items with item.flags.artifact set should not be teleported. (I am in favor of changing the code to allow it, but that's a different discussion.)
  • items with items.flag.forbid set should only be teleported if the forbidden option is given. and conversely, items with items.flags.forbid clear should not be teleported if the forbidden option is given.
  • the item id should be removed from map_block.items unless the item's new tile is in the same map_block.
  • the tile's tile_occupancy.item flag should be cleared if there are no more items on that tile. I think the best way to determine this is by walking all item ids still in the map_block.items vector, getting each item, and testing if the item.pos matches the tile's pos. if there are any that match, that flag should remain set.
  • the map_block.items vector should still be sorted.

the item was in a building.

  • item.pos may not be valid for items in buildings, use dfhack.items.getPosition(item).
  • items which are in a building and can be removed from that building have
    • item.flags.on_ground == false
    • item.flags.in_inventory == false
    • item.flags.in_building == false
  • the item has a general_ref_building_holderst with the building's id.
  • items which are part of a building, i.e. item.flags.in_building == true, should not be teleported.
  • a building's contained_items vector is an unsorted list of buildingitemst which point to the items inside it.
    after the teleport:
  • all item data should be set per your current tests and the previous comment.
  • the item should no longer have a general_ref_building_holderst.
  • the relevant element of the building.contained_items vector should have been removed.
  • cages are a special case:
    • items in a cage are contained in the cage item, not the cage building.
    • the cage building only has the cage item and and attached mechanisms.
  • I think animal traps work the same way for both the bait item and the vermin item, but I am not certain.

the item was in a container.

  • item.pos may not be valid for items in containers, use dfhack.items.getPosition(item).
  • items in a container have item.flags.in_inventory == true, item.flags.on_ground == false, item.flags.in_building == false.
  • items in a container have a general_ref_contained_in_itemst with the container's item id.
  • the container has a general_ref_contains_itemst with the item's id.
  • general_refs vectors are unsorted.
    after the teleport:
  • all item data should be set per your current tests and the previous comment.
  • the item should no longer have a general_ref_contained_in_itemst.
  • the container should no longer have a general_ref_contains_itemst with that item's id.
  • either the container weight should have changed or flags.weight_computed should be clear? don't bother.

the item was in a unit's inventory.

  • item.pos may not be valid for items in in a unit's inventory, use dfhack.items.getPosition(item).
  • items in a unit's inventory have item.flags.in_inventory == true, item.flags.on_ground == false, item.flags.in_building == false, exactly like items in a container.
  • items in a unit's inventory have a general_ref_unit_holderst with the unit's unit id.
  • the unit.inventory vector has a unit_inventory_item that points to the item.
    after the teleport:
  • all item data should be set per your current tests and the previous comment.
  • the item should no longer have a general_ref_unit_holderst.
  • the relevant element of the unit.inventory vector should have been removed.
  • the unit's weight should somehow be flagged as need-to-recompute? don't bother.

there are also rare cases: the item is/was a projectile, the item is uncategorized, possibly others I can't think of. I think these don't need to be tested, as I am not convinced that autodump should even attempt to handle them.

this is all really messy and may not need to be verified for autodump, because autodump uses library calls that handle the messiness. OTOH I don't think we have anything else that verifies those calls.

all of these tests will not be necessary for the destroy-item case, because that relies on the game doing the work.

this got big. please do not feel any obligation here.

end

function test.conflicting_filters_is_wrong_usage()
local output, status = dfhack.run_command_silent('autodump', 'visible', 'hidden')
expect.eq(CR_WRONG_USAGE, status)
expect.str_find("both hidden and visible", output)
end

function test.bad_option_is_wrong_usage()
local _, status = dfhack.run_command_silent('autodump', 'bogus')
expect.eq(CR_WRONG_USAGE, status)
end

function test.destroy_and_undestroy()
local item = find_dumpable_item()
expect.ne(nil, item, 'test needs a dumpable item')

local was_paused = dfhack.world.ReadPauseState()
return dfhack.with_finalize(function()
dfhack.world.SetPauseState(was_paused)
dfhack.run_command_silent('autodump', 'undestroy')
item.flags.dump = false
item.flags.garbage_collect = false
item.flags.forbid = false
item.flags.hidden = false
end, function()
item.flags.dump = true
-- undestroy only restores marks made in the same frame, so the
-- game must stay paused between destroy and undestroy
dfhack.world.SetPauseState(true)

local _, status = dfhack.run_command_silent('autodump', 'destroy')
expect.eq(CR_OK, status)
expect.true_(item.flags.garbage_collect)
expect.true_(item.flags.forbid)
expect.true_(item.flags.hidden)

local output, status2 = dfhack.run_command_silent('autodump', 'undestroy')
expect.eq(CR_OK, status2)
expect.str_find('unmarked for destruction', output)
expect.false_(item.flags.garbage_collect)
expect.false_(item.flags.forbid)
expect.false_(item.flags.hidden)
-- undestroy restores the pre-destroy flags, including the dump
-- flag we set ourselves
expect.true_(item.flags.dump)
end)
end
85 changes: 85 additions & 0 deletions test/plugins/changeitem.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
config.mode = 'fortress'
config.target = 'changeitem'

local dwarfmode = require('gui.dwarfmode')

local function find_floor_item_pos()
for _, item in ipairs(df.global.world.items.other.IN_PLAY) do
if not item.flags.hidden and not item.flags.in_inventory
and not item.flags.in_job and not item.flags.construction then
local x, y, z = dfhack.items.getPosition(item)
if x then return item, x, y, z end
end
end
end

local function set_cursor(x, y, z)
dwarfmode.setCursorPos(xyz2pos(x, y, z))
end

local function clear_cursor()
dwarfmode.clearCursorPos()
end
Comment on lines +16 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API dwarfmode.setCursorPos and friends per the updates for test/plugins/autodump.lua.

updating is preferable but not necessary.


function test.here_quality_changes_item()
local item, x, y, z = find_floor_item_pos()
expect.ne(nil, item, 'test needs an item on the ground')
local orig_quality = item.quality

return dfhack.with_finalize(function()
item.quality = orig_quality
clear_cursor()
end, function()
set_cursor(x, y, z)
local output, status = dfhack.run_command_silent('changeitem',
'here', 'q', '4')
expect.eq(CR_OK, status)
expect.str_find('items processed', output)
expect.eq(4, item.quality)
end)
end

function test.here_empty_tile_processes_nothing()
-- find a floor tile and put the cursor on it; if it happens to hold
-- items the count is still reported
local block = df.global.world.map.map_blocks[0]
local x, y, z = block.map_pos.x, block.map_pos.y, block.map_pos.z

return dfhack.with_finalize(clear_cursor, function()
set_cursor(x, y, z)
local output, status = dfhack.run_command_silent('changeitem', 'here')
expect.eq(CR_OK, status)
expect.str_find('items processed', output)
end)
end

function test.here_no_cursor_is_failure()
return dfhack.with_finalize(clear_cursor, function()
clear_cursor()
local output, status = dfhack.run_command_silent('changeitem', 'here')
expect.eq(CR_FAILURE, status)
expect.str_find('Cursor position not found', output)
end)
end

function test.no_selection_is_failure()
local output, status = dfhack.run_command_silent('changeitem')
expect.eq(CR_FAILURE, status)
expect.str_find('No item selected', output)
end

function test.material_missing_arg_is_wrong_usage()
local output, status = dfhack.run_command_silent('changeitem', 'm')
expect.eq(CR_WRONG_USAGE, status)
expect.str_find('no material specified', output)
end

function test.bad_quality_is_wrong_usage()
local _, status = dfhack.run_command_silent('changeitem', 'q', '9')
expect.eq(CR_WRONG_USAGE, status)
end

function test.bad_option_is_wrong_usage()
local _, status = dfhack.run_command_silent('changeitem', 'bogus')
expect.eq(CR_WRONG_USAGE, status)
end
33 changes: 33 additions & 0 deletions test/plugins/changelayer.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
config.mode = 'fortress'
config.target = 'changelayer'

local dwarfmode = require('gui.dwarfmode')

function test.no_material_is_wrong_usage()
local output, status = dfhack.run_command_silent('changelayer')
expect.eq(CR_WRONG_USAGE, status)
expect.str_find('specify a material', output)
end

function test.bad_material_is_failure()
local output, status = dfhack.run_command_silent('changelayer', 'BOGUSMAT')
expect.eq(CR_FAILURE, status)
expect.str_find('No such material', output)
end

function test.no_cursor_is_failure()
return dfhack.with_finalize(function()
dwarfmode.clearCursorPos()
end, function()
dwarfmode.clearCursorPos()
local output, status = dfhack.run_command_silent('changelayer',
'GRANITE')
expect.eq(CR_FAILURE, status)
expect.str_find('No cursor', output)
end)
end

function test.help_is_wrong_usage()
local _, status = dfhack.run_command_silent('changelayer', '?')
expect.eq(CR_WRONG_USAGE, status)
end
Loading
Loading