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
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
--- 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")
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.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)
game.player = players.alice
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)
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.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(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation")
end)
@@ -113,7 +122,7 @@ test("reject_assignment() rolls the assignment back", function(env)
},
}, "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")
end)
@@ -124,7 +133,7 @@ test(".assign() with local_only never reaches the controller", function(env)
empty(env.sent, "the assignment is not sent")
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")
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.gui.readme"), "the default role is lost")
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")
empty(env.events[1].unassigned, "the suppressed roles are not listed as unassigned")
eq(env.events, {
{
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()
jail:unassign(players.alice, { by_player_name = "<server>", silent = true })
eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" },
"unjail restores the roles")
empty(env.events[1].assigned, "the restored roles are not listed as assigned")
eq(env.events[1].unassigned, { jail.id }, "the unjail event lists the jail role")
eq(env.events, {
{
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)
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
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)
--- @cast env ExpRoles.TestEnv
env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpRoles
env.Roles.on_server_startup()
@@ -70,6 +80,12 @@ return Framework.suite(function(env)
return { name = player_name, role_ids = ids }
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
function env.initialise(assignments)
env.Roles.initialise{
+2 -2
View File
@@ -1,5 +1,5 @@
--- 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
--- 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.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")
eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once")
end)
+1 -1
View File
@@ -1,5 +1,5 @@
--- 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
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
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
--- 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
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
--- 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
local function setup(env)
local players = {
alice = env.add_player("alice"),
carol = env.add_player("carol"),
}
env.admin_state = {}
admin_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)
env.initialise{
env.assignment("alice", { "Moderator" }),
@@ -22,10 +25,12 @@ end
test("initialise() applies triggers and raises empty events", function(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")
empty(env.events[1].assigned, "the events assign nothing")
empty(env.events[1].unassigned, "the events unassign nothing")
for _, event in ipairs(env.events) do
empty(event.assigned, "the events assign nothing")
empty(event.unassigned, "the events unassign nothing")
end
empty(env.printed, "initialise is silent")
empty(env.sent, "initialise sends nothing")
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("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")
env.reset_log()
@@ -73,13 +78,22 @@ test("receive_assignment_updates() applies controller changes", function(env)
eq(env.printed, {
{ "exp-roles.game-message-assign", "carol", "Moderator", "<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.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
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")
check(env.admin_state.carol == false, "triggers follow the removal")
eq(env.events, {
{
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)
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")
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{
{ 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)
local players = setup(env)
env.admin_state.alice = nil
env.Roles.on_player_joined_game{ player_index = players.alice.index }
check(env.admin_state.alice ~= nil, "triggers run when a player joins")
admin_state.alice = nil
env.Roles.on_player_joined_game{
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)
return Suite.run()
+7 -5
View File
@@ -26,19 +26,21 @@ local function repr(value, depth)
end
--- 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)
--- @class Suite
--- @class Suite<T>
local Suite = {
tests = {}, -- { name, fn } in declaration order
results = {}, -- { name, ok, detail? } accumulated across tests
tests = {}, --- @type { name: string, fn: fun(env: T) }[] In declaration order
results = {}, --- @type { name: string, ok: boolean, detail: string? }[] Accumulated across tests
}
local current_test --- @type string?
--- Declare a named test, its function receives a fresh environment
--- @param name string
--- @param fn fun(env: table)
--- @param fn fun(env: T)
function Suite.test(name, fn)
Suite.tests[#Suite.tests + 1] = { name = name, fn = fn }
end
+13 -4
View File
@@ -51,11 +51,15 @@ end
--- Create an independent set of stubs, installed as the lua globals
function Stubs.new()
--- @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 = {
events = {}, -- events raised through script.raise_event
printed = {}, -- game.print and player.print messages
sent = {}, -- payloads sent through the clusterio api
sounds = {}, -- sounds played to players
events = {},
printed = {},
sent = {},
sounds = {},
}
local next_event_id = 100
@@ -97,8 +101,12 @@ function Stubs.new()
}, { "player" })
--- 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)
local index = #players_by_index + 1
--- @class Stubs.Player
local player = Stubs.strict("LuaPlayer " .. name, {
name = name,
index = index,
@@ -118,6 +126,7 @@ function Stubs.new()
end
--- A player object which represents the server
--- @type Stubs.Player
stubs.server = Stubs.strict("LuaPlayer <server>", { index = 0, name = "<server>" })
--- Modules resolved by the stubbed require, add to them with extend_requires