Type the test environments for autocomplete

Building on the class annotations from the smoke test changes:

- The suite is generic over its environment: Framework.suite is
  @generic T : Stubs with the extension returning T, and Suite<T> passes T to
  every test function. A test file declares
  `local Suite = ... --- @type Suite<ExpRoles.TestEnv>` and env autocompletes
  from there.
- The stubs declare their recorded fields, add_player returns Stubs.Player,
  and the server is one.
- ExpRoles.TestEnv : Stubs declares the extension fields, so env.R returns
  ExpRoles.Role and env.Roles is the module.

The typing immediately paid for itself by flagging real drift in the tests,
now fixed: _script_data is package private so the env exposes script_data()
instead, the join event carries its name and tick, the assignment record type
admits is_deleted, admin_state is a file local rather than an undeclared
field, and the event assertions which indexed [1] without a nil check compare
the whole event list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bbassie
2026-09-01 06:09:40 +00:00
co-authored by Claude Fable 5
parent 59df8bdff3
commit 08fe6b688e
8 changed files with 109 additions and 37 deletions
+35 -10
View File
@@ -1,5 +1,5 @@
--- Tests for the assignment methods of module/control.lua --- Tests for the assignment methods of module/control.lua
local Suite = ... --- @type Suite The suite this file adds its tests to, see env.lua local Suite = ... --- @type Suite<ExpRoles.TestEnv> The suite this file adds its tests to, see env.lua
local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty
--- alice is a moderator and bob a regular, with changes sent to the controller --- alice is a moderator and bob a regular, with changes sent to the controller
@@ -40,7 +40,7 @@ test(".assign() applies locally and is sent to the controller", function(env)
}, "the change is announced") }, "the change is announced")
eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays") eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays")
local sd = env.Roles._script_data() local sd = env.script_data()
eq(sd.local_players.bob, { moderator.id }, "the role is held locally") eq(sd.local_players.bob, { moderator.id }, "the role is held locally")
eq(sd.pending.bob, { moderator.id }, "the role is pending") eq(sd.pending.bob, { moderator.id }, "the role is pending")
@@ -54,7 +54,16 @@ test(".assign() defaults the by player to game.player", function(env)
local players = setup(env) local players = setup(env)
game.player = players.alice game.player = players.alice
env.R("Jail"):assign(players.bob, { silent = true }) env.R("Jail"):assign(players.bob, { silent = true })
eq(env.events[1].by_player_index, players.alice.index, "the acting player is game.player") eq(env.events, {
{
name = env.Roles.events.on_player_roles_changed,
tick = 1,
player_index = players.bob.index,
by_player_index = players.alice.index,
assigned = { env.R("Jail").id },
unassigned = {},
},
}, "the acting player is game.player")
end) end)
test("receive_assignment_updates() releases the local hold once confirmed", function(env) test("receive_assignment_updates() releases the local hold once confirmed", function(env)
@@ -65,7 +74,7 @@ test("receive_assignment_updates() releases the local hold once confirmed", func
empty(env.events, "a confirmation raises no events") empty(env.events, "a confirmation raises no events")
empty(env.printed, "a confirmation announces nothing") empty(env.printed, "a confirmation announces nothing")
local sd = env.Roles._script_data() local sd = env.script_data()
check(sd.local_players.bob == nil and sd.pending.bob == nil, "the role is no longer held locally") check(sd.local_players.bob == nil and sd.pending.bob == nil, "the role is no longer held locally")
check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation") check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation")
end) end)
@@ -113,7 +122,7 @@ test("reject_assignment() rolls the assignment back", function(env)
}, },
}, "the rollback raises the event") }, "the rollback raises the event")
local sd = env.Roles._script_data() local sd = env.script_data()
check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state")
end) end)
@@ -124,7 +133,7 @@ test(".assign() with local_only never reaches the controller", function(env)
empty(env.sent, "the assignment is not sent") empty(env.sent, "the assignment is not sent")
check(regular:has_player(players.alice), "the role applies") check(regular:has_player(players.alice), "the role applies")
local sd = env.Roles._script_data() local sd = env.script_data()
check(sd.pending.alice == nil, "the role is not pending") check(sd.pending.alice == nil, "the role is not pending")
eq(sd.local_players.alice, { regular.id }, "the role is held locally") eq(sd.local_players.alice, { regular.id }, "the role is held locally")
@@ -146,15 +155,31 @@ test(".assign() of a higher priority role suppresses the rest", function(env)
check(not env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permissions are lost") check(not env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permissions are lost")
check(not env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "the default role is lost") check(not env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "the default role is lost")
check(not env.Roles.player_outranks(players.alice, players.bob), "a jailed player no longer outranks") check(not env.Roles.player_outranks(players.alice, players.bob), "a jailed player no longer outranks")
eq(env.events[1].assigned, { jail.id }, "the event lists the jail role") eq(env.events, {
empty(env.events[1].unassigned, "the suppressed roles are not listed as unassigned") {
name = env.Roles.events.on_player_roles_changed,
tick = 1,
player_index = players.alice.index,
by_player_index = 0,
assigned = { jail.id },
unassigned = {},
},
}, "the event lists only the jail role")
env.reset_log() env.reset_log()
jail:unassign(players.alice, { by_player_name = "<server>", silent = true }) jail:unassign(players.alice, { by_player_name = "<server>", silent = true })
eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" },
"unjail restores the roles") "unjail restores the roles")
empty(env.events[1].assigned, "the restored roles are not listed as assigned") eq(env.events, {
eq(env.events[1].unassigned, { jail.id }, "the unjail event lists the jail role") {
name = env.Roles.events.on_player_roles_changed,
tick = 1,
player_index = players.alice.index,
by_player_index = 0,
assigned = {},
unassigned = { jail.id },
},
}, "the unjail event lists only the jail role")
end) end)
test(".assign() ignores the server", function(env) test(".assign() ignores the server", function(env)
+16
View File
@@ -47,7 +47,17 @@ for _, record in ipairs(role_records()) do
role_ids[record.name] = record.id role_ids[record.name] = record.id
end end
--- The environment given to each test: the stubs extended with a fresh copy
--- of the roles module and the fixture helpers
--- @class ExpRoles.TestEnv : Stubs
--- @field Roles ExpRoles A fresh copy of the roles module
--- @field R fun(name: string): ExpRoles.Role Get a fixture role by name
--- @field assignment fun(player_name: string, role_names: string[]): { name: string, role_ids: number[], is_deleted: boolean? }
--- @field script_data fun(): ExpRoles.ScriptData The module's script data, for asserting on internal state
--- @field initialise fun(assignments: table[]?) Load the standard fixture and the given assignments
return Framework.suite(function(env) return Framework.suite(function(env)
--- @cast env ExpRoles.TestEnv
env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpRoles env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpRoles
env.Roles.on_server_startup() env.Roles.on_server_startup()
@@ -70,6 +80,12 @@ return Framework.suite(function(env)
return { name = player_name, role_ids = ids } return { name = player_name, role_ids = ids }
end end
--- The module's script data, for asserting on internal state
function env.script_data()
--- @diagnostic disable-next-line: access-invisible
return env.Roles._script_data()
end
--- Load the standard fixture and the given assignments --- Load the standard fixture and the given assignments
function env.initialise(assignments) function env.initialise(assignments)
env.Roles.initialise{ env.Roles.initialise{
+2 -2
View File
@@ -1,5 +1,5 @@
--- Tests for listing and printing to the players who hold a role --- Tests for listing and printing to the players who hold a role
local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local Suite = ... --- @type Suite<ExpRoles.TestEnv> The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq local test, check, eq = Suite.test, Suite.check, Suite.eq
--- alice and dave are moderators with dave offline, zed has never joined --- alice and dave are moderators with dave offline, zed has never joined
@@ -30,7 +30,7 @@ test(".get_player_names() counts a player in both lists once", function(env)
env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator", "Regular" }) } env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator", "Regular" }) }
local sd = env.Roles._script_data() local sd = env.script_data()
check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists") check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists")
eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once") eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once")
end) end)
+1 -1
View File
@@ -1,5 +1,5 @@
--- Tests for the role lookup functions of module/control.lua --- Tests for the role lookup functions of module/control.lua
local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local Suite = ... --- @type Suite<ExpRoles.TestEnv> The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq local test, check, eq = Suite.test, Suite.check, Suite.eq
test("get_role() returns the role with the given clusterio id", function(env) test("get_role() returns the role with the given clusterio id", function(env)
+1 -1
View File
@@ -1,5 +1,5 @@
--- Tests for the player checks of module/control.lua --- Tests for the player checks of module/control.lua
local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local Suite = ... --- @type Suite<ExpRoles.TestEnv> The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq local test, check, eq = Suite.test, Suite.check, Suite.eq
--- alice is a moderator, bob is a regular, and carol has only the default role --- alice is a moderator, bob is a regular, and carol has only the default role
+34 -14
View File
@@ -1,16 +1,19 @@
--- Tests for the sync entry points of module/control.lua --- Tests for the sync entry points of module/control.lua
local Suite = ... --- @type Suite The suite this file adds its tests to, see test/lua/framework.lua local Suite = ... --- @type Suite<ExpRoles.TestEnv> The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty
--- The state given by the admin trigger, reset by setup for every test
local admin_state = {} --- @type table<string, boolean>
--- alice is a moderator and carol has only the default role, both connected --- alice is a moderator and carol has only the default role, both connected
local function setup(env) local function setup(env)
local players = { local players = {
alice = env.add_player("alice"), alice = env.add_player("alice"),
carol = env.add_player("carol"), carol = env.add_player("carol"),
} }
env.admin_state = {} admin_state = {}
env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state)
env.admin_state[player.name] = state admin_state[player.name] = state
end) end)
env.initialise{ env.initialise{
env.assignment("alice", { "Moderator" }), env.assignment("alice", { "Moderator" }),
@@ -22,10 +25,12 @@ end
test("initialise() applies triggers and raises empty events", function(env) test("initialise() applies triggers and raises empty events", function(env)
setup(env) setup(env)
eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") eq(admin_state, { alice = true, carol = false }, "triggers run for connected players")
eq(#env.events, 2, "the event is raised once per connected player") eq(#env.events, 2, "the event is raised once per connected player")
empty(env.events[1].assigned, "the events assign nothing") for _, event in ipairs(env.events) do
empty(env.events[1].unassigned, "the events unassign nothing") empty(event.assigned, "the events assign nothing")
empty(event.unassigned, "the events unassign nothing")
end
empty(env.printed, "initialise is silent") empty(env.printed, "initialise is silent")
empty(env.sent, "initialise sends nothing") empty(env.sent, "initialise sends nothing")
empty(env.sounds, "initialise plays no sounds") empty(env.sounds, "initialise plays no sounds")
@@ -44,7 +49,7 @@ test("initialise() is authoritative for pending roles", function(env)
env.R("Moderator"):assign(players.carol) env.R("Moderator"):assign(players.carol)
env.R("Jail"):assign(players.carol, { silent = true, local_only = true }) env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
local sd = env.Roles._script_data() local sd = env.script_data()
eq(sd.pending.carol, { env.R("Moderator").id }, "the role is pending before initialise") eq(sd.pending.carol, { env.R("Moderator").id }, "the role is pending before initialise")
env.reset_log() env.reset_log()
@@ -73,13 +78,22 @@ test("receive_assignment_updates() applies controller changes", function(env)
eq(env.printed, { eq(env.printed, {
{ "exp-roles.game-message-assign", "carol", "Moderator", "<server>" }, { "exp-roles.game-message-assign", "carol", "Moderator", "<server>" },
}, "the change is announced by the server") }, "the change is announced by the server")
check(env.admin_state.carol == true, "triggers follow the assignment") check(admin_state.carol == true, "triggers follow the assignment")
env.reset_log() env.reset_log()
env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } } env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
check(not env.R("Moderator"):has_player(players.carol), "a removal applies") check(not env.R("Moderator"):has_player(players.carol), "a removal applies")
eq(env.events[1].unassigned, { env.R("Moderator").id }, "the removal raises the event") eq(env.events, {
check(env.admin_state.carol == false, "triggers follow the removal") {
name = env.Roles.events.on_player_roles_changed,
tick = 1,
player_index = players.carol.index,
by_player_index = 0,
assigned = {},
unassigned = { env.R("Moderator").id },
},
}, "the removal raises the event")
check(admin_state.carol == false, "triggers follow the removal")
end) end)
test("receive_assignment_updates() for players not on this map raises nothing", function(env) test("receive_assignment_updates() for players not on this map raises nothing", function(env)
@@ -99,7 +113,9 @@ test("receive_role_updates() applies to the holders", function(env)
} }
check(env.R("Regular"):has_permission("exp_scenario.command.jail"), "the permission change applies") check(env.R("Regular"):has_permission("exp_scenario.command.jail"), "the permission change applies")
eq(#env.events, 2, "a role change raises for every connected player") eq(#env.events, 2, "a role change raises for every connected player")
empty(env.events[1].assigned, "role change events are empty") for _, event in ipairs(env.events) do
empty(event.assigned, "role change events are empty")
end
env.Roles.receive_role_updates{ env.Roles.receive_role_updates{
{ id = regular_id, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, { id = regular_id, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true },
@@ -146,9 +162,13 @@ end)
test("on_player_joined_game() runs the triggers", function(env) test("on_player_joined_game() runs the triggers", function(env)
local players = setup(env) local players = setup(env)
env.admin_state.alice = nil admin_state.alice = nil
env.Roles.on_player_joined_game{ player_index = players.alice.index } env.Roles.on_player_joined_game{
check(env.admin_state.alice ~= nil, "triggers run when a player joins") name = defines.events.on_player_joined_game,
tick = game.tick,
player_index = players.alice.index,
}
check(admin_state.alice ~= nil, "triggers run when a player joins")
end) end)
return Suite.run() return Suite.run()
+7 -5
View File
@@ -26,19 +26,21 @@ local function repr(value, depth)
end end
--- Create a suite of tests, one is made per test file --- Create a suite of tests, one is made per test file
--- @param extend_env fun(env: table): table Extends the stubs given to each test --- @generic T : Stubs
--- @param extend_env fun(env: Stubs): T Extends the stubs given to each test
--- @return Suite<T>
function Framework.suite(extend_env) function Framework.suite(extend_env)
--- @class Suite --- @class Suite<T>
local Suite = { local Suite = {
tests = {}, -- { name, fn } in declaration order tests = {}, --- @type { name: string, fn: fun(env: T) }[] In declaration order
results = {}, -- { name, ok, detail? } accumulated across tests results = {}, --- @type { name: string, ok: boolean, detail: string? }[] Accumulated across tests
} }
local current_test --- @type string? local current_test --- @type string?
--- Declare a named test, its function receives a fresh environment --- Declare a named test, its function receives a fresh environment
--- @param name string --- @param name string
--- @param fn fun(env: table) --- @param fn fun(env: T)
function Suite.test(name, fn) function Suite.test(name, fn)
Suite.tests[#Suite.tests + 1] = { name = name, fn = fn } Suite.tests[#Suite.tests + 1] = { name = name, fn = fn }
end end
+13 -4
View File
@@ -51,11 +51,15 @@ end
--- Create an independent set of stubs, installed as the lua globals --- Create an independent set of stubs, installed as the lua globals
function Stubs.new() function Stubs.new()
--- @class Stubs --- @class Stubs
--- @field events table[] Events raised through script.raise_event, name and tick filled in
--- @field printed (LocalisedString | { to: string, [1]: LocalisedString })[] Messages from game.print and player.print
--- @field sent { channel: string, data: table }[] Payloads sent through the clusterio api
--- @field sounds string[] Sounds played to players, as "player:path"
local stubs = { local stubs = {
events = {}, -- events raised through script.raise_event events = {},
printed = {}, -- game.print and player.print messages printed = {},
sent = {}, -- payloads sent through the clusterio api sent = {},
sounds = {}, -- sounds played to players sounds = {},
} }
local next_event_id = 100 local next_event_id = 100
@@ -97,8 +101,12 @@ function Stubs.new()
}, { "player" }) }, { "player" })
--- Add a player to the stubbed game, indexes are assigned in join order --- Add a player to the stubbed game, indexes are assigned in join order
--- @param name string
--- @param is_connected boolean? Defaults to connected
--- @return Stubs.Player
function stubs.add_player(name, is_connected) function stubs.add_player(name, is_connected)
local index = #players_by_index + 1 local index = #players_by_index + 1
--- @class Stubs.Player
local player = Stubs.strict("LuaPlayer " .. name, { local player = Stubs.strict("LuaPlayer " .. name, {
name = name, name = name,
index = index, index = index,
@@ -118,6 +126,7 @@ function Stubs.new()
end end
--- A player object which represents the server --- A player object which represents the server
--- @type Stubs.Player
stubs.server = Stubs.strict("LuaPlayer <server>", { index = 0, name = "<server>" }) stubs.server = Stubs.strict("LuaPlayer <server>", { index = 0, name = "<server>" })
--- Modules resolved by the stubbed require, add to them with extend_requires --- Modules resolved by the stubbed require, add to them with extend_requires