Module:Sandbox/Vergir

From The Deadlock Wiki
Revision as of 13:26, 11 August 2026 by Vergir (talk | contribs) (Testing reworked HeroData: parity harness (with help from vergir-bot LLM))
Jump to navigation Jump to search

Documentation for this module may be created at Module:Sandbox/Vergir/doc

local p = {};
local heroes_data = mw.loadJsonData("Data:HeroData.json")
local util_module = require('Module:Utilities')
local lang_module = require('Module:Lang')

--------------------------------------------------------------------------------
-- Internal helpers
--------------------------------------------------------------------------------

-- Localized string for a key, falling back to a readable version of `fallback`
-- plus a "missing translation" tooltip.
local function localize(key, fallback)
	local result = lang_module.get_string(key)
	if (result == "") or (result == nil) then
		result = util_module.add_space_before_cap(fallback) .. mw.getCurrentFrame():expandTemplate{title="MissingValveTranslationTooltip"}
	end
	return result
end

-- Raw scaling value of a stat under LevelScaling / SpiritScaling, or nil when
-- the hero has no scaling of that type for that stat.
local function raw_scaling(hero_data, scaling_type, stat)
	local scaling = hero_data and hero_data[scaling_type .. "Scaling"]
	if (scaling == nil or stat == nil) then return nil end
	return scaling[stat]
end

-- Mapping from hero name to internal tag name
local TAG_NAME_MAP = {
	["Abrams"] = "Abrams",
	["Bebop"] = "Bebop",
	["Billy"] = "Punkgoat",
	["Calico"] = "Nano",
	["The Doorman"] = "Doorman",
	["Drifter"] = "Drifter",
	["Dynamo"] = "Dynamo",
	["Apollo"] = "Fencer",
	["Graves"] = "Necro",
	["Grey Talon"] = "Orion",
	["Haze"] = "Haze",
	["Holliday"] = "Astro",
	["Infernus"] = "Inferno",
	["Ivy"] = "Tengu",
	["Kelvin"] = "Kelvin",
	["Lady Geist"] = "Geist",
	["Lash"] = "Lash",
	["McGinnis"] = "Engineer",
	["Mina"] = "VampireBat",
	["Mirage"] = "Mirage",
	["Mo & Krill"] = "Digger",
	["Paige"] = "Bookworm",
	["Paradox"] = "Chrono",
	["Celeste"] = "Unicorn",
	["Pocket"] = "Synth",
	["Rem"] = "Familiar",
	["Seven"] = "Gigawatt",
	["Shiv"] = "Shiv",
	["Silver"] = "Werewolf",
	["Silver (Transformed)"] = "Werewolf",
	["Sinclair"] = "Magician",
	["Venator"] = "Priest",
	["Victor"] = "Frank",
	["Vindicta"] = "Vindicta",
	["Viscous"] = "Viscous",
	["Vyper"] = "Viper",
	["Warden"] = "Warden",
	["Wraith"] = "Wraith",
	["Yamato"] = "Yamato",
}

--------------------------------------------------------------------------------
-- Data access, for use by other modules
--------------------------------------------------------------------------------

-- returns the table of a specific item, used by external modules
function p.get_json_item(name)
	for i,v in pairs(heroes_data) do
		if (v["Name"] == name) then
			return v
		end
	end
	return nil
end

-- Hero table for a hero_* key or an exact English name, or nil.
local function hero_by_key_or_name(input)
	local hero = heroes_data[input]
	if (hero == nil) then hero = p.get_json_item(input) end
	return hero
end

-- Value of a stat, checking the hero's top level, then Weapon, then AltFire.
-- Returns nil when the hero has no such stat.
function p.get_stat(hero_data, stat)
	if (hero_data == nil or stat == nil) then return nil end
	local value = hero_data[stat]
	if (value == nil and hero_data.Weapon) then
		value = hero_data.Weapon[stat]
	end
	if (value == nil and hero_data.Weapon and hero_data.Weapon.AltFire) then
		value = hero_data.Weapon.AltFire[stat]
	end
	return value
end

-- Ordered list of a hero's scalings for a stat, Level first then Spirit:
--   { { type = "Level", value = 1.5 }, { type = "Spirit", value = 0.04 } }
-- Absent and zero scalings are omitted, so an empty list means "no scaling".
function p.get_scalings(hero_data, stat)
	local scalings = {}
	for _, scaling_type in ipairs({"Level", "Spirit"}) do
		local value = raw_scaling(hero_data, scaling_type, stat)
		if (value ~= nil and value ~= 0) then
			table.insert(scalings, { type = scaling_type, value = value })
		end
	end
	return scalings
end

-- DEPRECATED, kept for [[Module:HeroComparisonTable]]. Use p.get_scalings.
-- Keyed by scaling value, so it silently drops an entry when Level and Spirit
-- scale by the same amount, and it keeps zero scalings.
function p.get_hero_scaling_data(hero_data, hero_stat_key)
	local scaling_data_returned = {}
	for _, scaling_type in ipairs({"Spirit", "Level"}) do
		local scaling_value = raw_scaling(hero_data, scaling_type, hero_stat_key)
		if (scaling_value ~= nil) then
			scaling_data_returned[scaling_value] = scaling_type
		end
	end
	return scaling_data_returned
end

-- Whether a hero has a stat at all: a non-zero base value, or any scaling.
function p.has_stat(hero_data, stat)
	local base = p.get_stat(hero_data, stat)
	if (base ~= nil and base ~= 0) then return true end
	return #p.get_scalings(hero_data, stat) > 0
end

-- Resolves a hero_* key or an English hero name to a hero_* key.
-- Returns nil when the input matches no hero. Name matching is case-insensitive.
function p.find_hero_key(input)
	if (input == nil or input == '') then return nil end
	if (heroes_data[input] ~= nil) then return input end
	local wanted = mw.ustring.lower(input)
	for hero_key, hero_data in pairs(heroes_data) do
		if (hero_data["Name"] and mw.ustring.lower(hero_data["Name"]) == wanted) then
			return hero_key
		end
	end
	return nil
end

-- A hero's Nth roster tag (1-3) by English name, or '' when there is no such tag.
function p.hero_tag(hero_en, index)
	local tagname = hero_en and TAG_NAME_MAP[hero_en]
	if (tagname == nil) then return '' end
	if (index ~= 1 and index ~= 2 and index ~= 3) then return '' end
	return lang_module.get_string(string.format("Citadel_%s_HeroTag_%d", tagname, index)) or ''
end

--------------------------------------------------------------------------------
-- Wikitext entry points
--------------------------------------------------------------------------------

-- returns the key of the specified hero's english name
function p.get_hero_key(frame)
	local hero_name_input = frame.args[1]
	if not hero_name_input then
		return "Error: Hero name not specified."
	end
	return p.find_hero_key(hero_name_input) or "Hero not found."
end

--{{#invoke:HeroData|resolve_key|HERO_KEY_OR_NAME}}--
-- The matching hero_* key, or '' when the input matches no hero.
function p.resolve_key(frame)
	return p.find_hero_key(frame.args[1]) or ''
end

--{{#invoke:HeroData|get_hero_var|HERO_NAME|STAT_NAME|sig_figs_or_localize}}--
--sig_figs optional for rounding floats
p.get_hero_var = function(frame)
    local hero_name = frame.args[1]
    local hero_stat_key = frame.args[2]
    local sig_figs_or_localize = frame.args[3]
    
    local hero = hero_by_key_or_name(hero_name)
    if(hero == nil) then return "Hero Not Found" end
    
    local var_value = p.get_stat(hero, hero_stat_key)
    if(var_value == nil) then return 0 end
    
    --round
    if (sig_figs_or_localize ~= nil and tonumber(sig_figs_or_localize) ~= nil) then
        var_value = util_module.round_to_sig_fig(var_value, sig_figs_or_localize)
        if (var_value == nil) then return "get_hero_var() error with rounding" end
    end
    
    --localize
    if (sig_figs_or_localize == "true") then
        return lang_module.get_string(var_value)
    end

    return var_value
end

--{{#invoke:HeroData|get_alt_fire_var|HERO_NAME|STAT_NAME|sig_figs_or_localize}}--
p.get_alt_fire_var = function(frame)
    local hero_name = frame.args[1]
    local stat_key = frame.args[2]
    local sig_figs_or_localize = frame.args[3]
    
    local hero = hero_by_key_or_name(hero_name)
    if(hero == nil) then return "Hero Not Found" end
    
    if not (hero.Weapon and hero.Weapon.AltFire) then
        return "No Alt Fire"
    end
    
    local var_value = hero.Weapon.AltFire[stat_key]
    if(var_value == nil) then return 0 end
    
    --round
    if (sig_figs_or_localize ~= nil and tonumber(sig_figs_or_localize) ~= nil) then
        var_value = util_module.round_to_sig_fig(var_value, sig_figs_or_localize)
    end
    
    --localize
    if (sig_figs_or_localize == "true") then
        return lang_module.get_string(var_value)
    end

    return var_value
end

--{{#invoke:HeroData|get_list_elem|HERO_NAME|VAR|NUMBER|LOCALIZE}}
p.get_list_elem = function(frame)
	local hero_name = frame.args[1]
	local var = frame.args[2]
	local number_int = tonumber(frame.args[3])
	local localize_arg = frame.args[4]
	
	local hero = hero_by_key_or_name(hero_name)
	if(hero == nil) then return "Hero Not Found" end
	
	local list = p.get_stat(hero, var)
	if (list == nil) then return "" end
	local element = list[number_int]
	if (element == nil) then return "" end
	
	if localize_arg=="true" then 
		element = lang_module.get_string(element)
	end
	
	return element
end

p.get_ability_key = function(frame)
	local hero_key = frame.args[1]
	local bound_slot_number = frame.args[2]
	local hero_data = heroes_data[hero_key]
	if (hero_data == nil) then return "Hero key "..hero_key.. " not found" end
	local bound_abilities_data = hero_data["BoundAbilities"]
	if (bound_abilities_data == nil) then return "Hero key " .. hero_key.. " has no BoundAbilities" end
	return bound_abilities_data[tonumber(bound_slot_number)]["Key"]
end

p.write_role_playstyle_quote = function(frame)
	local hero_key = frame.args[1]
	local hero_data = heroes_data[hero_key]
	if (hero_data == nil) then return hero_key.." not found" end
	local role_key = hero_data["Role"]
	local role_localized = lang_module.get_string(role_key, nil, 'en')
	local playstyle_key = hero_data["Playstyle"]
	local playstyle_localized = lang_module.get_string(playstyle_key, nil, 'en')
	
	local str = "<b>" .. role_localized .. '</b><br>' .. playstyle_localized
	local template_args = {}
	template_args[1] = ""
	template_args[2] = str
	return frame:expandTemplate{title = 'Quotation', args = template_args}
end

p.write_default_items = function(frame)
	local hero_key = frame.args[1] --unlocalized
	if (hero_key == nil) then return "No hero key provided" end
	local str = ""
	local hero = heroes_data[hero_key]
	if (hero == nil) then return "Hero not found, must be unlocalized" end
	local template_title = 'PageRef'
	for i, item_key in ipairs(hero["RecommendedItems"] or {}) do
		local template_args = {}
		template_args[1] = lang_module.get_string(item_key, 'en')
		template_args['alt_name'] = localize(item_key, item_key)
		local expanded_template = mw.getCurrentFrame():expandTemplate{ title = template_title, args = template_args }
		str = str .. "* " .. expanded_template .. "\n"
	end
	return str
end

--If the hero scales with the stat, it returns {{Ss|value}} or {{Ls|value}}, else blank string
--{{#invoke:HeroData|get_hero_scalar|HERO_NAME|SCALING_TYPE|STAT_NAME|sig_figs_or_localize}}--
p.get_hero_scalar = function(frame)
	local hero_key = frame.args[1]
	local scaling_type = frame.args[2]
	local hero_stat_key = frame.args[3]
	local sig_figs_or_localize = frame.args[4]
	local no_template = frame.args["no_template"]
	
	local hero_data = hero_by_key_or_name(hero_key)
	if(hero_data == nil) then return "Hero not found." end
	
	local scaling_value = raw_scaling(hero_data, scaling_type, hero_stat_key)
	if scaling_value == nil then return "" end

	--round
	if (sig_figs_or_localize ~= nil and tonumber(sig_figs_or_localize) ~= nil) then
		scaling_value = util_module.round_to_sig_fig(scaling_value, sig_figs_or_localize)
		if (scaling_value == nil) then return "get_hero_scalar() error with rounding" end
	end
	
	--localize
	if (sig_figs_or_localize == "true") then
		return lang_module.get_string(scaling_value)
	end
	
	if no_template == "true" then
		return scaling_value
	end
	
	return p.write_scalar_str(scaling_value, scaling_type)
end

-- Check if a hero has a stat (Base, Boons, or Spirit Scaling)
-- Returns "true" if found, nil if not (for #if templates)
p.hero_has_stat = function(frame)
	local hero_key = frame.args[1]
	local stat_key = frame.args[2]
	if (hero_key == nil) then return nil end
	
	local hero_data = hero_by_key_or_name(hero_key)
	if (hero_data == nil) then return nil end
	
	if p.has_stat(hero_data, stat_key) then return "true" end
	return nil
end

-- Function to call hero tags based on name
p.get_hero_tag = function (frame)
	local name = frame.args[1]
	local number = tonumber(frame.args[2])

	-- Validate input
	if not name then
		return "Error: Missing name input."
	end

	if number ~= 1 and number ~= 2 and number ~= 3 then
		return string.format("Error: Invalid number '%s'. Must be 1, 2, or 3.", tostring(frame.args[2]))
	end

	if TAG_NAME_MAP[name] == nil then
		return string.format("Error: Unknown name '%s'.", name)
	end

	return p.hero_tag(name, number)
end

--{{#invoke:HeroData|has_tags|HERO_ENGLISH_NAME}}--
-- 'true' when the hero has roster tags, '' otherwise.
function p.has_tags(frame)
	if p.hero_tag(frame.args[1], 1) ~= '' then return 'true' end
	return ''
end

-- Retrieve scaling string of a hero's given stat, if it has scaling, else return blank
-- Scaling string meaning the expanded template {{Ss|scalar}} or {{Ls|scalar}}
function p.write_scalar_str(scaling_value, scaling_type, compact)
	local scaling_abbrevs = {Spirit = "Ss", Level = "PI"}
	
	-- Return blank if it doesnt scale	
	if (scaling_value == 0) then return "" end
	
	-- Round it
	scaling_value = util_module.round_to_sig_fig(scaling_value, 3)
	
	--The hero has a scaling value with this stat	
	local template_title = "Template:" .. scaling_abbrevs[scaling_type]
	local template_args = {}
	template_args["1"] = scaling_value
	if compact then 
		template_args["compact"] = "yes"
		template_args["show_value"] = "yes"
	end
	
	local template_call = mw.getCurrentFrame():expandTemplate{ title = template_title, args = template_args }
    
	return template_call:gsub("\n", ""):gsub("\r", "")
end

-- Outputs a wikitable of heroes grouped by GroundDashDuration using {{HeroIcon|hero_name}}
-- {{#invoke:HeroData|write_ground_dash_buckets}}
p.write_ground_dash_buckets = function(frame)
    local buckets = {
        [0.62] = {},
        [0.68] = {},
        [0.72] = {},
    }

    -- Collect heroes
    for hero_key, hero_data in pairs(heroes_data) do
        if not hero_data.InDevelopment and not hero_data.IsDisabled then
            local dash = p.get_stat(hero_data, "GroundDashDuration")
            if dash and buckets[dash] then
                table.insert(buckets[dash], hero_data.Name or hero_key)
            end
        end
    end

    -- Sort alphabetically
    for _, list in pairs(buckets) do
        table.sort(list)
    end

    -- Determine max rows dynamically
    local max_rows = 0
    for _, list in pairs(buckets) do
        if #list > max_rows then max_rows = #list end
    end

    local output = {"{| class='wikitable mw-collapsible'",
    	"|+Hero Dash Buckets", "! Bucket 1 !! Bucket 2 !! Bucket 3"}
    local current_frame = frame or mw.getCurrentFrame()

    for i = 1, max_rows do
        local row = {}
        for _, bucket in ipairs({0.62, 0.68, 0.72}) do
            local hero_name = buckets[bucket][i]
            table.insert(row, hero_name and current_frame:expandTemplate{title = "HeroIcon", args = {hero_name}} or "")
        end
        table.insert(output, "|-\n| " .. table.concat(row, " || "))
    end

    table.insert(output, "|}")
    return table.concat(output, "\n")
end
--------------------------------------------------------------------------------
-- TEMPORARY parity harness - NOT part of the live module.
-- {{#invoke:Sandbox/Vergir|test|SUITE}} where SUITE is one of:
--   stats | list | scalars | scalings | has_stat | keys | tags
--------------------------------------------------------------------------------

local old_module = require('Module:HeroData')

local function repr(v, depth)
	depth = depth or 0
	if type(v) ~= 'table' then return tostring(v) end
	if depth > 3 then return '<deep>' end
	local entries = {}
	for k, val in pairs(v) do
		table.insert(entries, { k = tostring(k), v = val })
	end
	table.sort(entries, function(a, b) return a.k < b.k end)
	local parts = {}
	for _, e in ipairs(entries) do
		table.insert(parts, e.k .. '=' .. repr(e.v, depth + 1))
	end
	return '{' .. table.concat(parts, ',') .. '}'
end

-- Calls fn(...) and returns a normalised string, so that a raised error on one
-- side compares equal to the same error on the other (line numbers stripped).
local function attempt(fn, ...)
	local ok, result = pcall(fn, ...)
	if ok then return repr(result) end
	return 'ERR:' .. tostring(result):gsub("^.-:%d+: ", "")
end

local function sorted_hero_keys()
	local list = {}
	for hero_key in pairs(heroes_data) do table.insert(list, hero_key) end
	table.sort(list)
	return list
end

-- Every key appearing anywhere in the hero data, so sweeps are exhaustive.
local function all_stat_keys()
	local seen = {}
	for _, hero in pairs(heroes_data) do
		local sources = {
			hero,
			hero.Weapon or {},
			(hero.Weapon and hero.Weapon.AltFire) or {},
			hero.LevelScaling or {},
			hero.SpiritScaling or {},
		}
		for _, source in ipairs(sources) do
			for stat in pairs(source) do
				if type(stat) == 'string' then seen[stat] = true end
			end
		end
	end
	local list = {}
	for stat in pairs(seen) do table.insert(list, stat) end
	table.sort(list)
	return list
end

local MAX_ROWS = 50

local function report(title, checked, mismatches)
	local out = {
		"'''" .. title .. "'''",
		'',
		'Comparisons: ' .. checked,
		'Mismatches: ' .. #mismatches,
		'',
	}
	if #mismatches == 0 then
		table.insert(out, ':<span style="color:#13f278">No differences.</span>')
		return table.concat(out, '\n')
	end
	table.insert(out, '{| class="wikitable"')
	table.insert(out, '! Case !! old !! new')
	for i, row in ipairs(mismatches) do
		if i > MAX_ROWS then
			table.insert(out, '|-\n| colspan="3" | ... and ' .. (#mismatches - MAX_ROWS) .. ' more')
			break
		end
		table.insert(out, '|-\n| <code>' .. row[1] .. '</code> || <code>' ..
			mw.text.nowiki(row[2]) .. '</code> || <code>' .. mw.text.nowiki(row[3]) .. '</code>')
	end
	table.insert(out, '|}')
	return table.concat(out, '\n')
end

local suites = {}

-- p.get_stat, via get_hero_var
suites.stats = function()
	local checked, mismatches = 0, {}
	local stat_keys = all_stat_keys()
	for _, hero_key in ipairs(sorted_hero_keys()) do
		for _, stat in ipairs(stat_keys) do
			local frame = { args = { hero_key, stat } }
			local a = attempt(old_module.get_hero_var, frame)
			local b = attempt(p.get_hero_var, frame)
			checked = checked + 1
			if a ~= b then
				table.insert(mismatches, { hero_key .. ' / ' .. stat, a, b })
			end
		end
	end
	return report('get_hero_var (p.get_stat)', checked, mismatches)
end

-- p.get_stat, via get_list_elem
suites.list = function()
	local checked, mismatches = 0, {}
	local stat_keys = all_stat_keys()
	for _, hero_key in ipairs(sorted_hero_keys()) do
		for _, stat in ipairs(stat_keys) do
			for index = 1, 2 do
				local frame = { args = { hero_key, stat, tostring(index) } }
				local a = attempt(old_module.get_list_elem, frame)
				local b = attempt(p.get_list_elem, frame)
				checked = checked + 1
				if a ~= b then
					table.insert(mismatches, { hero_key .. ' / ' .. stat .. ' [' .. index .. ']', a, b })
				end
			end
		end
	end
	return report('get_list_elem (p.get_stat)', checked, mismatches)
end

-- raw_scaling, via get_hero_scalar (both template and no_template modes)
suites.scalars = function()
	local checked, mismatches = 0, {}
	local stat_keys = all_stat_keys()
	for _, hero_key in ipairs(sorted_hero_keys()) do
		for _, stat in ipairs(stat_keys) do
			for _, scaling_type in ipairs({ 'Level', 'Spirit' }) do
				for _, no_template in ipairs({ 'true', '' }) do
					local frame = { args = { hero_key, scaling_type, stat, no_template = no_template } }
					local a = attempt(old_module.get_hero_scalar, frame)
					local b = attempt(p.get_hero_scalar, frame)
					checked = checked + 1
					if a ~= b then
						table.insert(mismatches, {
							hero_key .. ' / ' .. scaling_type .. ' / ' .. stat ..
								(no_template == 'true' and ' (raw)' or ''), a, b })
					end
				end
			end
		end
	end
	return report('get_hero_scalar (raw_scaling)', checked, mismatches)
end

-- The deprecated shim must stay byte-identical for Module:HeroComparisonTable
suites.scalings = function()
	local checked, mismatches = 0, {}
	local stat_keys = all_stat_keys()
	for _, hero_key in ipairs(sorted_hero_keys()) do
		local hero_data = heroes_data[hero_key]
		for _, stat in ipairs(stat_keys) do
			local a = attempt(old_module.get_hero_scaling_data, hero_data, stat)
			local b = attempt(p.get_hero_scaling_data, hero_data, stat)
			checked = checked + 1
			if a ~= b then
				table.insert(mismatches, { hero_key .. ' / ' .. stat, a, b })
			end
		end
	end
	return report('get_hero_scaling_data (deprecated shim)', checked, mismatches)
end

-- EXPECTED to differ: new version is Weapon/AltFire aware
suites.has_stat = function()
	local checked, mismatches = 0, {}
	local stat_keys = all_stat_keys()
	for _, hero_key in ipairs(sorted_hero_keys()) do
		for _, stat in ipairs(stat_keys) do
			local frame = { args = { hero_key, stat } }
			local a = attempt(old_module.hero_has_stat, frame)
			local b = attempt(p.hero_has_stat, frame)
			checked = checked + 1
			if a ~= b then
				table.insert(mismatches, { hero_key .. ' / ' .. stat, a, b })
			end
		end
	end
	return report('hero_has_stat (EXPECTED to widen)', checked, mismatches)
end

-- EXPECTED to differ: new version also accepts a hero_* key
suites.keys = function()
	local checked, mismatches = 0, {}
	local inputs = {}
	for _, hero_key in ipairs(sorted_hero_keys()) do
		table.insert(inputs, hero_key)
		local name = heroes_data[hero_key]['Name']
		if name then
			table.insert(inputs, name)
			table.insert(inputs, mw.ustring.upper(name))
			table.insert(inputs, mw.ustring.lower(name))
		end
	end
	for _, extra in ipairs({ '', ' ', 'Not A Hero', 'hero_nope' }) do
		table.insert(inputs, extra)
	end
	for _, input in ipairs(inputs) do
		local frame = { args = { input } }
		local a = attempt(old_module.get_hero_key, frame)
		local b = attempt(p.get_hero_key, frame)
		checked = checked + 1
		if a ~= b then
			table.insert(mismatches, { input, a, b })
		end
	end
	return report('get_hero_key (EXPECTED to widen)', checked, mismatches)
end

-- get_hero_tag must be unchanged; the has_tags predicate may narrow
suites.tags = function()
	local checked, mismatches = 0, {}
	local names = {}
	for _, hero_key in ipairs(sorted_hero_keys()) do
		local name = heroes_data[hero_key]['Name']
		if name then table.insert(names, name) end
	end
	for _, extra in ipairs({ 'Not A Hero', '' }) do table.insert(names, extra) end

	for _, name in ipairs(names) do
		for _, index in ipairs({ '0', '1', '2', '3', '4', 'x' }) do
			local frame = { args = { name, index } }
			local a = attempt(old_module.get_hero_tag, frame)
			local b = attempt(p.get_hero_tag, frame)
			checked = checked + 1
			if a ~= b then
				table.insert(mismatches, { name .. ' [' .. index .. ']', a, b })
			end
		end
		-- Predicate: old Infobox_hero.hero_has_tags vs new p.has_tags
		local old_tag = old_module.get_hero_tag({ args = { name, '1' } })
		local old_pred = tostring(string.find(old_tag, 'Error:') == nil)
		local new_pred = tostring(p.has_tags({ args = { name } }) == 'true')
		checked = checked + 1
		if old_pred ~= new_pred then
			table.insert(mismatches, { 'PREDICATE ' .. name, old_pred, new_pred })
		end
	end
	return report('get_hero_tag + has_tags predicate', checked, mismatches)
end

function p.test(frame)
	local suite = frame.args[1]
	if suite == nil or suites[suite] == nil then
		local names = {}
		for name in pairs(suites) do table.insert(names, name) end
		table.sort(names)
		return 'Pick a suite: ' .. table.concat(names, ', ')
	end
	return suites[suite]()
end

return p