mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"),
|
||||
check(position("onHostDestroy();") < position("super.onDestroy();"),
|
||||
"destroy hook runs before SDL destruction")
|
||||
|
||||
check(source:find("DisplayManager.DisplayListener", 1, true)
|
||||
and source:find("registerDisplayListener", 1, true)
|
||||
and source:find("unregisterDisplayListener", 1, true),
|
||||
"secondary displays are monitored while the activity is active")
|
||||
check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
|
||||
position("setupSecondaryDisplay();"),
|
||||
"secondary display monitoring starts before initial discovery")
|
||||
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
||||
"a disconnected active display is rebound without replacing a live one")
|
||||
|
||||
check(not source:lower():find("openxr", 1, true),
|
||||
"generic Android activity must not require OpenXR")
|
||||
check(not source:find("QuestActivity", 1, true) and
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen.
|
||||
--
|
||||
-- io.popen accepts "r" or "w", not "rw". POST needs both a request body
|
||||
-- and a response status, so the body is staged in a temporary file and curl
|
||||
-- is opened read-only for its response.
|
||||
-- luajit tests/engine/host_shell_postlog.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local HostShell = require("src.core.HostShell")
|
||||
|
||||
local MARK = "\n__gen1recomp_http__"
|
||||
local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
|
||||
local URL = "https://logs.example.com/logs"
|
||||
local BODY = "debug log body\n"
|
||||
|
||||
local realOpen = io.open
|
||||
local realPopen = io.popen
|
||||
local realGetenv = os.getenv
|
||||
local realRemove = os.remove
|
||||
local realHaveCurl = HostShell.haveCurl
|
||||
|
||||
local openedPath, openedMode, writtenBody
|
||||
local popenCommand, popenMode, removedPath
|
||||
|
||||
HostShell.haveCurl = function() return true end
|
||||
os.getenv = function(name)
|
||||
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
|
||||
return STAGE_DIR
|
||||
end
|
||||
return realGetenv(name)
|
||||
end
|
||||
os.remove = function(path)
|
||||
removedPath = path
|
||||
return true
|
||||
end
|
||||
|
||||
io.open = function(path, mode)
|
||||
openedPath, openedMode = path, mode
|
||||
return {
|
||||
write = function(_, value)
|
||||
writtenBody = value
|
||||
return true
|
||||
end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
io.popen = function(command, mode)
|
||||
popenCommand, popenMode = command, mode
|
||||
return {
|
||||
read = function() return MARK .. "200" end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10)
|
||||
|
||||
io.open = realOpen
|
||||
io.popen = realPopen
|
||||
os.getenv = realGetenv
|
||||
os.remove = realRemove
|
||||
HostShell.haveCurl = realHaveCurl
|
||||
|
||||
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
|
||||
check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
|
||||
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
|
||||
eq(openedMode, "wb", "the temporary request body is opened for binary writing")
|
||||
eq(writtenBody, BODY, "the complete log body is staged")
|
||||
eq(popenMode, "r", "curl is opened in the supported read-only mode")
|
||||
check(popenCommand:find("--data-binary", 1, true) ~= nil,
|
||||
"curl reads the staged body with --data-binary")
|
||||
check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
|
||||
"curl receives the temporary body path")
|
||||
check(popenCommand:find(BODY, 1, true) == nil,
|
||||
"the log body is not placed directly in the command line")
|
||||
eq(removedPath, openedPath, "the staged request body is removed")
|
||||
|
||||
T.finish("host shell postlog")
|
||||
@@ -0,0 +1,246 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local S = require("tests.harness").suite("mod battle snapshot")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Gen1BattleState = require("src.battle.BattleState")
|
||||
check(Gen1BattleState.isBattleState == true,
|
||||
"Gen 1 battle states carry the discovery marker")
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load({ type_chart = {
|
||||
types = { NORMAL = { name = "NORMAL", category = "physical" } },
|
||||
matchups = {},
|
||||
} })
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local attacker, defender = { stages = {} }, { stages = {} }
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 },
|
||||
attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss")
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 },
|
||||
attacker, defender), 256, "clean accuracy exposes a certain hit")
|
||||
local Catching = require("src.battle.Catching")
|
||||
eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }), 100, "Master Ball preview is certain")
|
||||
check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil,
|
||||
"custom ball logic does not receive a guessed preview")
|
||||
|
||||
local mon = { species = "TESTMON", level = 5, hp = 18,
|
||||
stats = { hp = 20 }, moves = {} }
|
||||
local game = {
|
||||
data = {
|
||||
pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35,
|
||||
accuracy = 95, pp = 35 } },
|
||||
items = { POTION = { name = "POTION" },
|
||||
POKE_BALL = { name = "POKE BALL" } },
|
||||
},
|
||||
save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } },
|
||||
stack = { states = {} },
|
||||
}
|
||||
local battle = {
|
||||
isBattleState = true, phase = "menu", queue = {},
|
||||
ruleset = { oneIn256Miss = true },
|
||||
player = { mon = mon, curTypes = { "NORMAL" }, stages = {},
|
||||
curMoves = { { id = "TACKLE", pp = 35 } } },
|
||||
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
|
||||
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
|
||||
}
|
||||
function battle:battleKind() return "wild" end
|
||||
function battle:effectRecord() return { accuracyChecked = true } end
|
||||
function battle:visibleText() return { "Wild TESTMON appeared!" } end
|
||||
function battle:menuLockedAction() return nil end
|
||||
function battle:chooseMenu(choice)
|
||||
self.chosenMenu = choice
|
||||
if choice == "fight" then self.phase = "moveSelect" end
|
||||
return true
|
||||
end
|
||||
function battle:chooseMove(slot)
|
||||
self.chosenMove = slot
|
||||
self.phase = "messages"
|
||||
return true
|
||||
end
|
||||
function battle:cancelMove() self.phase = "menu" return true end
|
||||
function battle:catchChance(ball)
|
||||
return require("src.battle.Catching").chance(ball, self.enemy.mon,
|
||||
game.data.pokemon[self.enemy.mon.species])
|
||||
end
|
||||
game.stack.states = { battle }
|
||||
|
||||
local api = require("src.battle.BattleAPI").new(game)
|
||||
local snapshot = api:snapshot()
|
||||
check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu",
|
||||
"Gen 1 battle is exposed")
|
||||
eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats")
|
||||
eq(snapshot.moves[1].name, "TACKLE", "move records are copied")
|
||||
eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied")
|
||||
eq(#snapshot.items, 2, "medicine and balls are exposed")
|
||||
check(type(snapshot.items[1].catchChance) == "number"
|
||||
or type(snapshot.items[2].catchChance) == "number",
|
||||
"stock catch chance is available")
|
||||
snapshot.player.hp = 0
|
||||
snapshot.moves[1].pp = 0
|
||||
eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon")
|
||||
eq(battle.player.curMoves[1].pp, 35,
|
||||
"changing a snapshot cannot change a move")
|
||||
local same = api:snapshot()
|
||||
eq(same.revision, snapshot.revision, "unchanged battle keeps its revision")
|
||||
battle.enemy.mon.hp = 5
|
||||
check(api:snapshot().revision > same.revision,
|
||||
"observable battle changes advance the revision")
|
||||
game.stack.states = {}
|
||||
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
|
||||
game.stack.states = { battle }
|
||||
|
||||
local menu = api:snapshot()
|
||||
local ok, err = api:submit({ id = 1, revision = menu.revision - 1,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "stale battle context",
|
||||
"Gen 1 rejects a stale intent")
|
||||
ok, err = api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "missing" })
|
||||
check(not ok and err == "unknown battle menu choice",
|
||||
"Gen 1 rejects an unknown menu choice")
|
||||
check(api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent")
|
||||
eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path")
|
||||
ok, err = api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent")
|
||||
local moveMenu = api:snapshot()
|
||||
ok, err = api:submit({ id = 2, revision = moveMenu.revision,
|
||||
kind = "move", slot = 9 })
|
||||
check(not ok and err == "invalid move slot",
|
||||
"Gen 1 rejects an invalid move slot")
|
||||
check(api:submit({ id = 2, revision = moveMenu.revision,
|
||||
kind = "move", slot = 1 }), "Gen 1 accepts a valid move")
|
||||
eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path")
|
||||
battle.phase = "moveSelect"
|
||||
local back = api:snapshot()
|
||||
check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
|
||||
"Gen 1 accepts move-menu back")
|
||||
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
|
||||
|
||||
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
|
||||
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
|
||||
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
|
||||
moves = {} }
|
||||
local battle2 = { player = player2, enemy = enemy2, party = { player2 },
|
||||
wild = true, turn = 0 }
|
||||
function battle2:moveDisabled() return false end
|
||||
local screen2 = { screenId = "Gen2BattleState", battle = battle2,
|
||||
phase = "menu", menuIndex = 1, moveIndex = 1 }
|
||||
function screen2:chooseMenu(choice)
|
||||
self.chosenMenu = choice
|
||||
if choice == "fight" then self.phase = "moves" end
|
||||
return true
|
||||
end
|
||||
function screen2:chooseMove(slot)
|
||||
self.chosenMove = slot
|
||||
self.phase = "resolving"
|
||||
return true
|
||||
end
|
||||
function screen2:cancelMove() self.phase = "menu" return true end
|
||||
local game2 = {
|
||||
data = {
|
||||
pokemon = { CHIKORITA = { name = "CHIKORITA" },
|
||||
RATTATA = { name = "RATTATA" } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
|
||||
power = 35, accuracy = 95, pp = 35 } },
|
||||
},
|
||||
save = { party = { player2 } }, stack = { states = { screen2 } },
|
||||
}
|
||||
|
||||
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
|
||||
local snapshot2 = api2:snapshot()
|
||||
check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
|
||||
"Gold battle is discovered through its screen id")
|
||||
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
|
||||
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
|
||||
snapshot2.player.hp = 0
|
||||
snapshot2.moves[1].pp = 0
|
||||
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
|
||||
eq(player2.moves[1].pp, 35,
|
||||
"changing a snapshot cannot change a Gold move")
|
||||
screen2.message = "A wild RATTATA appeared!"
|
||||
screen2.phase = "resolving"
|
||||
local message2 = api2:snapshot()
|
||||
eq(message2.prompt, "advance", "Gold message state is exposed")
|
||||
check(message2.revision > snapshot2.revision,
|
||||
"Gold battle changes advance the revision")
|
||||
game2.stack.states = {}
|
||||
check(api2:snapshot() == nil, "Gold returns nil outside a battle")
|
||||
game2.stack.states = { screen2 }
|
||||
|
||||
screen2.message = nil
|
||||
screen2.phase = "menu"
|
||||
local menu2 = api2:snapshot()
|
||||
ok, err = api2:submit({ id = 1, revision = menu2.revision - 1,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "stale battle context",
|
||||
"Gold rejects a stale intent")
|
||||
ok, err = api2:submit({ id = 1, revision = menu2.revision,
|
||||
kind = "menu", choice = "missing" })
|
||||
check(not ok and err == "unknown battle menu choice",
|
||||
"Gold rejects an unknown menu choice")
|
||||
check(api2:submit({ id = 1, revision = menu2.revision,
|
||||
kind = "menu", choice = "fight" }), "Gold accepts a menu intent")
|
||||
eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path")
|
||||
local moveMenu2 = api2:snapshot()
|
||||
ok, err = api2:submit({ id = 2, revision = moveMenu2.revision,
|
||||
kind = "move", slot = 9 })
|
||||
check(not ok and err == "invalid move slot",
|
||||
"Gold rejects an invalid move slot")
|
||||
check(api2:submit({ id = 2, revision = moveMenu2.revision,
|
||||
kind = "move", slot = 1 }), "Gold accepts a valid move")
|
||||
eq(screen2.chosenMove, 1, "Gold uses the semantic move path")
|
||||
screen2.phase = "moves"
|
||||
local back2 = api2:snapshot()
|
||||
check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }),
|
||||
"Gold accepts move-menu back")
|
||||
eq(screen2.phase, "menu", "Gold back restores the command menu")
|
||||
|
||||
do
|
||||
local Data = require("tests.modkit").fixtures.fresh()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local pressed = {}
|
||||
local game3 = { data = Data, save = save, input = {
|
||||
wasPressed = function(_, key) return pressed[key] == true end,
|
||||
isDown = function() return false end,
|
||||
}, stack = { states = {} } }
|
||||
function game3.stack:top() return self.states[#self.states] end
|
||||
function game3.stack:push(state) self.states[#self.states + 1] = state end
|
||||
local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12)
|
||||
real.phase, real.queue, real.introSlide = "menu", {}, nil
|
||||
game3.stack.states = { real }
|
||||
pressed.a = true
|
||||
real:update(1 / 60)
|
||||
pressed.a = nil
|
||||
eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path")
|
||||
pressed.b = true
|
||||
real:update(1 / 60)
|
||||
pressed.b = nil
|
||||
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
|
||||
end
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local fs = { read = function() end, getInfo = function() end,
|
||||
getDirectoryItems = function() return {} end }
|
||||
local mod = { path = "mods/snapshot_test", manifest = {
|
||||
id = "snapshot_test", version = "1.0.0", permissionSet = {},
|
||||
} }
|
||||
local loader1 = Loader.new({ fs = fs, generation = 1 })
|
||||
loader1.game = game
|
||||
check(loader1:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 1 facade")
|
||||
local loader2 = Loader.new({ fs = fs, generation = 2 })
|
||||
loader2.game = game2
|
||||
check(loader2:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 2 facade")
|
||||
|
||||
S.finish()
|
||||
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
}, "mods/colorlib")
|
||||
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
|
||||
nil, nil, { testTargetManifest, installedColorlib })
|
||||
check(unconditionalConflict.hasIssues == true,
|
||||
"dependency resolver reports an unversioned conflict")
|
||||
|
||||
local function rangeTarget(version, conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_target",
|
||||
name = "Range Target",
|
||||
version = version,
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_target")
|
||||
end
|
||||
local function rangeSource(conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_source",
|
||||
name = "Range Source",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_source")
|
||||
end
|
||||
|
||||
local forwardSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local matchingTarget = rangeTarget("1.4.0")
|
||||
local nonmatchingTarget = rangeTarget("2.0.0")
|
||||
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, matchingTarget })
|
||||
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
|
||||
"dependency resolver applies a matching forward conflict range")
|
||||
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, nonmatchingTarget })
|
||||
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching forward conflict range")
|
||||
|
||||
local reverseSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
|
||||
nil, nil, { reverseSource, matchingTarget })
|
||||
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
|
||||
"dependency resolver applies a matching reverse conflict range")
|
||||
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
|
||||
nil, nil, { reverseSource, nonmatchingTarget })
|
||||
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching reverse conflict range")
|
||||
-- ------- scoped dependency tests
|
||||
local Json = require("src.link.Json")
|
||||
local scopedDepManifest = Manifest.validate({
|
||||
|
||||
@@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false
|
||||
and loader.modOptions.okmod.startMoney == 3000
|
||||
and loader.modOptions.okmod.tag == "BLUE",
|
||||
"RESET DEFAULTS restores every schema default")
|
||||
|
||||
-- optional conditions keep mode-specific rows compact and refresh in place
|
||||
local conditionalSchema = {
|
||||
{ key = "mode", label = "MODE", type = "choice", default = "one",
|
||||
choices = { { "ONE", "one" }, { "TWO", "two" } } },
|
||||
{ key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", equals = "one" } },
|
||||
{ key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", equals = "two" } },
|
||||
{ key = "notOne", label = "NOT ONE", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", not_equals = "one" } },
|
||||
}
|
||||
loader.modOptions.condmod = {}
|
||||
ms.cursor = 1
|
||||
ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema)
|
||||
check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly",
|
||||
"visible_if uses the controlling row default")
|
||||
ms.optionRows[1].step(mgame, 1)
|
||||
check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly"
|
||||
and ms.optionRows[3].id == "notOne" and ms.cursor == 1,
|
||||
"editing a controller refreshes conditions without moving the cursor")
|
||||
press(ms, "b")
|
||||
check(ms.screen == "list", "B leaves the options screen")
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
-- mod.postLog: one-way log reporting to the manifest-declared log_url.
|
||||
-- The things this pins are the strict ones -- https-only destination that
|
||||
-- lives in the manifest (not per-call), a closed list of format switches,
|
||||
-- a body ceiling, opaque per-mod handles, and refusal without the network
|
||||
-- permission.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Net = require("src.mods.Net")
|
||||
local Fetch = require("src.net.Fetch")
|
||||
|
||||
-- Stand in for the worker pool: jobs resolve when the test says so, so no
|
||||
-- test here touches a socket.
|
||||
local submitted, nextId, states = {}, 0, {}
|
||||
Fetch.post = function(url, body, opts)
|
||||
nextId = nextId + 1
|
||||
submitted[nextId] = { url = url, body = body, opts = opts }
|
||||
states[nextId] = { status = "pending", progress = 0 }
|
||||
return nextId
|
||||
end
|
||||
Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end
|
||||
Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end
|
||||
Fetch.release = function(id) states[id] = nil end
|
||||
Fetch.cancel = function(id)
|
||||
if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end
|
||||
end
|
||||
Fetch.available = function() return true end
|
||||
|
||||
local LOGGER = {
|
||||
["mods/log_sender/manifest.json"] = [[{
|
||||
"id": "log_sender",
|
||||
"name": "Log Sender",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["network"],
|
||||
"log_url": "https://logs.example.com/logs"
|
||||
}]],
|
||||
["mods/log_sender/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.send = function(body, opts)
|
||||
local handle, err = mod:postLog(body, opts)
|
||||
if not handle then return nil, err end
|
||||
return handle
|
||||
end
|
||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
||||
mod.exports.release = function(h) return mod.fetch:release(h) end
|
||||
]],
|
||||
}
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", '
|
||||
.. '"api": 2%s}'):format(id, extra or "")
|
||||
end
|
||||
|
||||
local NO_URL = {
|
||||
["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'),
|
||||
["mods/log_no_url/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.try = function()
|
||||
local ok, err = pcall(function() return mod:postLog("body") end)
|
||||
return ok, err
|
||||
end
|
||||
]],
|
||||
}
|
||||
|
||||
-- ------------------------------------------------ the closed opts list
|
||||
local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) })
|
||||
T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local api = run.loader.exports.log_sender
|
||||
|
||||
local bad, badErr = api.send("body", { format = "xml" })
|
||||
T.eq(bad, nil, "an unknown format is refused")
|
||||
T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones")
|
||||
|
||||
local badKey, keyErr = api.send("body", { envelope = true })
|
||||
T.eq(badKey, nil, "an unknown opt key is refused")
|
||||
T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so")
|
||||
|
||||
-- ------------------------------------------------ body validation
|
||||
local empty, emptyErr = api.send("")
|
||||
T.eq(empty, nil, "an empty body is refused")
|
||||
local big = string.rep("x", Net.MAX_BODY + 1)
|
||||
local bigH, bigErr = api.send(big)
|
||||
T.eq(bigH, nil, "an oversized body is refused")
|
||||
T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit")
|
||||
|
||||
-- ------------------------------------------------- text format (default)
|
||||
local handle, err = api.send("hello log")
|
||||
T.check(handle ~= nil, "a plain text post returns a handle (" .. tostring(err) .. ")")
|
||||
T.eq(type(handle), "table", "the handle is opaque, not the engine's job id")
|
||||
T.eq(api.poll(handle).status, "pending", "a fresh post polls as pending")
|
||||
|
||||
local sent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end
|
||||
end
|
||||
T.check(sent ~= nil, "the post reached the pool with the manifest URL")
|
||||
T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain")
|
||||
T.check(sent.opts.userAgent:find("log_sender", 1, true),
|
||||
"the request identifies the calling mod")
|
||||
|
||||
-- -------------------------------------------------- json format
|
||||
local jh, jerr = api.send("line one", { format = "json" })
|
||||
T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")")
|
||||
local jsent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.opts.contentType == "application/json" then jsent = job end
|
||||
end
|
||||
T.check(jsent ~= nil, "json posts as application/json")
|
||||
local decoded = require("src.link.Json").decode(jsent.body)
|
||||
T.eq(type(decoded), "table", "the json body is a table")
|
||||
T.eq(decoded.format, "json", "the envelope names its format")
|
||||
T.eq(decoded.mod, "log_sender", "the envelope names the mod")
|
||||
T.eq(decoded.body, "line one", "the payload survives the envelope")
|
||||
|
||||
-- completion flows through poll, like get
|
||||
states[1] = { status = "ok", progress = 1 }
|
||||
local got = api.poll(handle)
|
||||
T.eq(got.status, "ok", "a completed post polls ok")
|
||||
|
||||
api.release(handle)
|
||||
run.release()
|
||||
|
||||
-- --------------------------------------- manifest without log_url refuses
|
||||
local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) })
|
||||
T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")")
|
||||
local okCall, callErr = nurl.loader.exports.log_no_url.try()
|
||||
T.check(not okCall and callErr:find("log_url", 1, true),
|
||||
"postLog without log_url names the missing manifest field")
|
||||
nurl.release()
|
||||
|
||||
-- ------------------------------------------- manifest validation: the gate
|
||||
-- log_url without the network permission is a load violation in a strict
|
||||
-- manifest: the mod declares a network capability it did not opt in to. The
|
||||
-- violation fires inside manifest validation, so the mod never enters
|
||||
-- loader.mods at all.
|
||||
local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({
|
||||
["mods/log_bad/manifest.json"] = manifest("log_bad",
|
||||
', "log_url": "https://logs.example.com/logs"'),
|
||||
["mods/log_bad/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(badManifest.mods.log_bad, nil,
|
||||
"log_url without network: the mod is refused before load")
|
||||
|
||||
-- a non-https log_url is refused even with the permission
|
||||
local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({
|
||||
["mods/log_http/manifest.json"] = manifest("log_http",
|
||||
', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'),
|
||||
["mods/log_http/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(httpManifest.mods.log_http, nil,
|
||||
"an http log_url: the mod is refused before load")
|
||||
|
||||
-- an api 1 manifest carries no strict surface: log_url is ignored, and the
|
||||
-- mod loads (its postLog call still refuses -- there is no log_url to use)
|
||||
local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({
|
||||
["mods/log_api1/manifest.json"] = [[{
|
||||
"id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua",
|
||||
"api": 1, "log_url": "https://logs.example.com/logs"
|
||||
}]],
|
||||
["mods/log_api1/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url ("
|
||||
.. tostring(api1.errors[1]) .. ")")
|
||||
T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads")
|
||||
|
||||
T.finish("mod_postlog")
|
||||
@@ -34,6 +34,9 @@ function redGame.stack:top() return self.states[#self.states] end
|
||||
|
||||
local RedAPI = require("src.world.WorldAPI")
|
||||
local red = RedAPI.new(redGame, "fixture")
|
||||
local unavailable, reason = RedAPI.new({}, "fixture"):availableFieldActions()
|
||||
T.eq(#unavailable, 0, "Red lists no actions without an overworld")
|
||||
T.eq(reason, "no overworld", "Red reports a missing overworld")
|
||||
local RedWorld = require("src.world.OverworldController")
|
||||
T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function"
|
||||
@@ -59,7 +62,9 @@ T.check(not ok and err == "fishing rod unavailable",
|
||||
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
|
||||
|
||||
redWorld.player.moving = true
|
||||
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
|
||||
actions, err = red:availableFieldActions()
|
||||
T.eq(#actions, 0, "Red hides actions while moving")
|
||||
T.eq(err, "world is busy", "Red distinguishes a busy world from no actions")
|
||||
ok, err = red:useFieldAction("bicycle")
|
||||
T.check(not ok and err == "world is busy",
|
||||
"Red refuses a stale action while busy")
|
||||
@@ -142,6 +147,9 @@ goldWorld.fieldContext = function(_, mon) return {
|
||||
|
||||
local GoldAPI = require("src.world.gen2.WorldAPI")
|
||||
local gold = GoldAPI.new(goldGame, "fixture")
|
||||
unavailable, reason = GoldAPI.new({}, "fixture"):availableFieldActions()
|
||||
T.eq(#unavailable, 0, "Gold lists no actions without an overworld")
|
||||
T.eq(reason, "no overworld", "Gold reports a missing overworld")
|
||||
actions = gold:availableFieldActions()
|
||||
byId = {}
|
||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||
@@ -170,4 +178,9 @@ T.check(gold:useFieldAction("squirtbottle"),
|
||||
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
|
||||
"Gold delegates the SquirtBottle to its field-item path")
|
||||
|
||||
goldWorld.acceptsMenuInput = function() return false end
|
||||
actions, err = gold:availableFieldActions()
|
||||
T.eq(#actions, 0, "Gold hides actions while busy")
|
||||
T.eq(err, "world is busy", "Gold distinguishes a busy world from no actions")
|
||||
|
||||
T.finish()
|
||||
|
||||
Reference in New Issue
Block a user