-- Runtime coverage for the RBY translation seams backed by dynamic ROM data. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") love = love or require("tests.love_stub") local drawn = {} package.loaded["src.render.Font"] = { draw = function(text, x, y) drawn[#drawn + 1] = { text = text, x = x, y = y } end, drawBox = function() end, drawCode = function() end, width = function(text) return #text * 8 end, split = function(text) local spans = {} for i = 1, #text do spans[i] = { from = i, to = i, code = text:byte(i) } end return spans end, spansFitting = function(spans, pixels) return math.min(#spans, math.floor(pixels / 8)) end, } package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end, } local pushed package.loaded["src.render.TextBox"] = { new = function(_, text) return { text = text } end, } local Strings = require("src.core.Strings") Strings.load({ strings = { ["PALLET TOWN"] = "BOURG PALETTE", ["To %s"] = "Vers %s", ["FLY TO?"] = "VOLER VERS?", ["VOLER VERS?"] = "DOUBLE LOOKUP", ["ITEMS"] = "OBJETS", ["OBJETS"] = "DOUBLE LOOKUP", ["DIRECTOR"] = "RÉALISATEUR", ["T H E E N D"] = "F I N", ["No.%03d"] = "N°%03d", ["IDNo.%05d"] = "ID n°%05d", ["POKéMON BLUE"] = "BLEU", ["PIKACHU'S BEACH"] = "PLAGE", ["BILL'S PC"] = "PC DE LÉO", ["%s's PC"] = "PC DE %s", ["PC DE RED"] = "DOUBLE LOOKUP", } }) -- TownMap keeps the English source for ROUTE classification and translates -- only the rendered banner, including the fly prefix. local TownMap = require("src.ui.TownMap") local map = setmetatable({ fly = false }, TownMap) T.eq(map:bannerText({ name = "PALLET TOWN" }), "BOURG PALETTE", "town names pass through Strings at draw time") map.fly = true T.eq(map:bannerText({ name = "PALLET TOWN" }), "Vers BOURG PALETTE", "the composed fly banner translates its prefix and destination") -- Exercise the actual grid/fly draw path: the old fixed "To" at x=0 plus -- destination at x=24 overlapped as soon as the prefix was wider than two -- glyphs. It is now one reorderable format lookup and one draw call. local grid = setmetatable({ game = { data = { pokemon = {} } }, mode = "grid", bg = { map = {} }, locs = { { name = "PALLET TOWN", x = 1, y = 1 } }, sel = 1, fly = true, blink = 0, drawFlyArrows = function() end, }, TownMap) drawn = {} grid:draw() local banner = {} for _, entry in ipairs(drawn) do if entry.y == 0 then banner[#banner + 1] = entry end end T.eq(#banner, 1, "grid/fly draws one composed destination banner") T.eq(banner[1] and banner[1].text, "Vers BOURG PALETTE", "grid/fly uses the reorderable To %s translation") T.eq(banner[1] and banner[1].x, 0, "the composed grid/fly banner starts at the strip's left edge") T.check(banner[1] and banner[1].x + package.loaded["src.render.Font"] .width(banner[1].text) <= 144, "the grid/fly banner does not overlap its arrow tiles") -- The standalone FlyMenu has the same dynamic map-name path as TownMap. local realMap = package.loaded["src.world.Map"] package.loaded["src.ui.ListMenu"] = { new = function(_, title, items, opts) return { title = title, kind = (opts and opts.kind) or title, items = items, rows = 6, index = 1, scroll = 0, update = function() end, } end, } package.loaded["src.world.Map"] = { isFlyTown = function() return true end } local FlyMenu = assert(loadfile("src/ui/FlyMenu.lua"))() local fly = FlyMenu.new({ data = { field = { flyOrder = { "PALLET_TOWN" } }, maps = { PALLET_TOWN = {} }, }, save = { visited = { PALLET_TOWN = true } }, }) T.eq(fly.title, "FLY TO?", "FlyMenu preserves the source title for ListMenu hooks") T.eq(fly.kind, "FLY TO?", "FlyMenu preserves the source kind derived by ListMenu") T.eq(Strings(fly.title), "VOLER VERS?", "ListMenu's draw-time lookup translates once, not twice") T.eq(fly.items[1] and fly.items[1].label, "BOURG PALETTE", "FlyMenu translates dynamic destination names") local BagMenu = assert(loadfile("src/ui/BagMenu.lua"))() local bag = BagMenu.new({ data = { items = {} }, save = { inventory = {}, bagOrder = {} }, }) T.eq(bag.title, "ITEMS", "BagMenu preserves its source title") T.eq(bag.kind, "bag", "BagMenu preserves its stable explicit kind") T.eq(Strings(bag.title), "OBJETS", "BagMenu's draw-time lookup translates once, not twice") package.loaded["src.world.Map"] = realMap -- Credits lines come from field.credits and therefore need a runtime lookup. local Credits = require("src.ui.Credits") local credits = setmetatable({}, Credits) drawn = {} credits:drawPage({ lines = { { text = "DIRECTOR", column = 3 } } }, 0, 1) T.eq(drawn[1] and drawn[1].text, "RÉALISATEUR", "extracted credits text passes through Strings before drawing") credits.theEnd = { display = "T H E E N D" } credits.endImg = nil drawn = {} credits:drawTheEnd() T.eq(drawn[1] and drawn[1].text, "F I N", "the extracted THE END fallback is translated") T.eq(drawn[1] and drawn[1].x, 60, "the translated THE END fallback is centered from Font.width") -- Text-only title fallbacks are centered from their translated pixel width, -- rather than from the English source's hard-coded tile count. local TitleState = assert(loadfile("src/ui/TitleState.lua"))() local title = setmetatable({ game = {}, menuOpen = false, scy = 0, yellowLayout = false, phase = "loop", logo = nil, yellow = false, blue = true, version = nil, player = nil, playerQuads = nil, scrollPhase = "hold", monOffset = 0, currentSprite = function() return nil end, drawCopyright = function() end, }, TitleState) drawn = {} title:draw() T.eq(drawn[1] and drawn[1].text, "BLEU", "the translated title fallback is drawn") T.eq(drawn[1] and drawn[1].x, 64, "the translated title fallback is centered from Font.width") local SurfingMinigame = assert(loadfile("src/ui/SurfingMinigame.lua"))() local surfing = setmetatable({ titleBg = nil, introPikaX = 0, drawIntroPikachu = function() end, }, SurfingMinigame) drawn = {} surfing:drawTitleScreen() T.eq(drawn[1] and drawn[1].text, "PLAGE", "the translated beach title fallback is drawn") T.eq(drawn[1] and drawn[1].x, 60, "the translated beach title fallback is centered from Font.width") -- The trade info formats keep their numeric padding after translation. local TradeAnim = require("src.ui.TradeAnim") local trade = setmetatable({ game = { data = { pokemon = { PIKACHU = { name = "PIKACHU", dex = 25 }, } } } }, TradeAnim) drawn = {} trade:drawMonInfo({ species = "PIKACHU" }, "RED", 42, 0) T.eq(drawn[1] and drawn[1].text, "N°025", "translated Pokédex number format keeps zero padding") T.eq(drawn[4] and drawn[4].text, "ID n°00042", "translated trainer ID format keeps zero padding") -- Oak's demo step must resolve the ROM label, not translate the label name. local OakSpeech = require("src.ui.OakSpeech") local oakGame = { data = { text = { _OakSpeechText2A = "MONDE TRADUIT" } }, stack = { push = function(_, state) pushed = state end }, } local oak = setmetatable({ game = oakGame, demoSpecies = "NIDORINO", demoPic = {}, demoTrueColor = false, revealPic = function(_, _, done) done() end, advance = function() end, }, OakSpeech) pushed = nil oak:runStep({ kind = "demo" }) T.eq(pushed and pushed.text, "MONDE TRADUIT", "Oak demo resolves _OakSpeechText2A through game.data.text") -- ui.pc.items must inspect stable English labels even with a live catalog; -- only the final Menu rows are localized. This is also the runtime coverage -- for the dynamic player's-PC format. local function setUpvalue(fn, name, value) local i = 1 while true do local found = debug.getupvalue(fn, i) if not found then return false end if found == name then debug.setupvalue(fn, i, value); return true end i = i + 1 end end local Overworld = require("src.world.OverworldController") local hookLabels, menuItems local pcGame = { data = { text = {} }, save = { flags = { EVENT_MET_BILL = true }, player = { name = "RED" }, hallOfFame = {}, }, stack = { push = function() end }, } local pcRuntime = { call = function(name, _, _, items) T.eq(name, "ui.pc.items", "the PC rows use the documented hook") hookLabels = {} for i, item in ipairs(items) do hookLabels[i] = item.label end return items end, } package.loaded["src.ui.Menu"] = { new = function(_, items) menuItems = items; return { items = items } end, } T.check(setUpvalue(Overworld.openPC, "Game", pcGame), "openPC Game upvalue is injectable") T.check(setUpvalue(Overworld.openPC, "Runtime", pcRuntime), "openPC Runtime upvalue is injectable") T.check(setUpvalue(Overworld.openPC, "TextBox", { new = function(_, text, onDone) return { text = text, onDone = onDone } end, }), "openPC TextBox upvalue is injectable") local pc = setmetatable({}, { __index = Overworld }) pc:openPC() T.eq(hookLabels[1], "BILL'S PC", "ui.pc.items sees the stable box-PC source label") T.eq(hookLabels[2], "RED's PC", "ui.pc.items sees the stable player-PC source label") T.eq(menuItems[1].label, "PC DE LÉO", "the retained box-PC row is translated after the hook") T.eq(menuItems[2].label, "PC DE RED", "the dynamic player-PC format is translated exactly once after the hook") -- Keep a ROM-free guard in the normal engine tier as well as the Python test -- that runs modkit's actual harvester. These literals must stay at static -- call sites; wrapping only a variable would work at runtime but disappear -- from a freshly generated translation worksheet. local function source(path) local file = assert(io.open(path, "r")) local body = file:read("*a") file:close() return body end local harvestCalls = { ["src/ui/BagMenu.lua"] = { 'Strings.source("ITEMS")', }, ["src/ui/BoxMenu.lua"] = { 'Strings("WITHDRAW")', 'Strings("DEPOSIT")', 'Strings("The party is full!")', }, ["src/ui/Diploma.lua"] = { 'Strings.source("Congrats! This")', 'Strings.source("diploma certifies")', }, ["src/ui/FlyMenu.lua"] = { 'Strings.source("FLY TO?")' }, ["src/ui/TradeAnim.lua"] = { 'Strings("No.%03d",', 'Strings("IDNo.%05d",', }, ["src/ui/TrainerCard.lua"] = { 'Strings("MONEY/¥%d",', 'Strings("TIME/ %d:%02d",', }, ["src/world/OverworldController.lua"] = { 'Strings("BILL\'S PC")', 'Strings("%s\'s PC",', 'Strings("HEAL")', }, } for path, calls in pairs(harvestCalls) do local body = source(path) for _, call in ipairs(calls) do T.check(body:find(call, 1, true) ~= nil, path .. " keeps harvestable " .. call) end end Strings.load(nil) T.finish()