Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for the PaintMyKI5 scanner package."""
|
||||
@@ -0,0 +1,291 @@
|
||||
local luaRoot = PROJECT_ROOT .. "/42.20/media/lua"
|
||||
package.path = table.concat({
|
||||
luaRoot .. "/shared/?.lua",
|
||||
luaRoot .. "/shared/?/?.lua",
|
||||
package.path,
|
||||
}, ";")
|
||||
|
||||
local passed = 0
|
||||
local function test(name, body)
|
||||
local ok, errorMessage = pcall(body)
|
||||
if not ok then error(name .. ": " .. tostring(errorMessage), 0) end
|
||||
passed = passed + 1
|
||||
end
|
||||
|
||||
local function assertNear(actual, expected)
|
||||
assert(math.abs(actual - expected) < 0.0001,
|
||||
"expected " .. tostring(expected) .. ", got " .. tostring(actual))
|
||||
end
|
||||
|
||||
local function javaList(values)
|
||||
return {
|
||||
size = function() return #values end,
|
||||
get = function(_, index) return values[index + 1] end,
|
||||
}
|
||||
end
|
||||
|
||||
PaintMyKI5 = {
|
||||
VehiclePaintData = {
|
||||
bucketUses = 10,
|
||||
paintCans = {},
|
||||
vehicles = {
|
||||
["Base.TestCar"] = {
|
||||
textures = {
|
||||
{
|
||||
skinIndex = 2,
|
||||
texture = "Vehicles/TestCar_Green",
|
||||
paints = {
|
||||
{ item = "Base.PaintGreen", percent = 97.9, uses = 9.79 },
|
||||
{ item = "Base.PaintWhite", percent = 2.1, uses = 0.21 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
package.loaded["PaintMyKI5/PaintMyKI5VehiclePaintData"] = true
|
||||
|
||||
require "PaintMyKI5/PaintRequirements"
|
||||
require "PaintMyKI5/PaintInventory"
|
||||
|
||||
local function fakeVehicle()
|
||||
return {
|
||||
getScript = function()
|
||||
return {
|
||||
getFullName = function() return "Base.TestCar" end,
|
||||
getAreaById = function() return true end,
|
||||
}
|
||||
end,
|
||||
getSkinCount = function() return 3 end,
|
||||
getSkinIndex = function() return 0 end,
|
||||
getCurrentSpeedKmHour = function() return 0 end,
|
||||
isRemovedFromWorld = function() return false end,
|
||||
isInArea = function() return true end,
|
||||
getUseablePart = function() return nil end,
|
||||
}
|
||||
end
|
||||
|
||||
test("requirements retain fractional proportional uses", function()
|
||||
local requirements = PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 2)
|
||||
assert(#requirements == 2)
|
||||
assertNear(requirements[1].uses, 9.79)
|
||||
assertNear(requirements[2].uses, 0.21)
|
||||
assertNear(requirements[1].uses + requirements[2].uses, 10)
|
||||
end)
|
||||
|
||||
test("invalid skin is rejected", function()
|
||||
assert(PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 1) == nil)
|
||||
end)
|
||||
|
||||
local syncCount = 0
|
||||
local removedCount = 0
|
||||
local addedCount = 0
|
||||
sendItemStats = function() syncCount = syncCount + 1 end
|
||||
sendRemoveItemFromContainer = function() removedCount = removedCount + 1 end
|
||||
sendAddItemToContainer = function() addedCount = addedCount + 1 end
|
||||
instanceItem = function() return {} end
|
||||
|
||||
local function fakeItem(itemType, delta, failAdd)
|
||||
local item = { itemType = itemType, delta = delta, inContainer = true }
|
||||
local container = {
|
||||
DoRemoveItem = function(_, target) target.inContainer = false end,
|
||||
AddItem = function(_, target)
|
||||
if failAdd then return nil end
|
||||
target.inContainer = true
|
||||
return target
|
||||
end,
|
||||
}
|
||||
item.getUseDelta = function() return 0.1 end
|
||||
item.getCurrentUsesFloat = function(self) return self.delta end
|
||||
item.setUsedDelta = function(self, value) self.delta = value end
|
||||
item.getContainer = function() return container end
|
||||
return item
|
||||
end
|
||||
|
||||
test("inventory aggregates partial cans and consumes exact fractions", function()
|
||||
local greenA = fakeItem("Base.PaintGreen", 0.5)
|
||||
local greenB = fakeItem("Base.PaintGreen", 0.5)
|
||||
local white = fakeItem("Base.PaintWhite", 1.0)
|
||||
local byType = {
|
||||
["Base.PaintGreen"] = javaList({ greenA, greenB }),
|
||||
["Base.PaintWhite"] = javaList({ white }),
|
||||
}
|
||||
local character = {
|
||||
getInventory = function()
|
||||
return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end }
|
||||
end,
|
||||
removeFromHands = function() end,
|
||||
}
|
||||
local requirements = {
|
||||
{ item = "Base.PaintGreen", uses = 9.79 },
|
||||
{ item = "Base.PaintWhite", uses = 0.21 },
|
||||
}
|
||||
assert(PaintMyKI5.PaintInventory.consumeRequirements(character, requirements))
|
||||
assertNear(greenB.delta, 0.021)
|
||||
assertNear(white.delta, 0.979)
|
||||
assert(removedCount == 1 and addedCount == 1)
|
||||
end)
|
||||
|
||||
test("a missing color prevents every mutation", function()
|
||||
local green = fakeItem("Base.PaintGreen", 1.0)
|
||||
local byType = {
|
||||
["Base.PaintGreen"] = javaList({ green }),
|
||||
["Base.PaintWhite"] = javaList({}),
|
||||
}
|
||||
local character = {
|
||||
getInventory = function()
|
||||
return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end }
|
||||
end,
|
||||
removeFromHands = function() end,
|
||||
}
|
||||
assert(not PaintMyKI5.PaintInventory.consumeRequirements(character, {
|
||||
{ item = "Base.PaintGreen", uses = 5 },
|
||||
{ item = "Base.PaintWhite", uses = 1 },
|
||||
}))
|
||||
assertNear(green.delta, 1.0)
|
||||
end)
|
||||
|
||||
test("replacement failure rolls back earlier paint", function()
|
||||
local green = fakeItem("Base.PaintGreen", 1.0)
|
||||
local white = fakeItem("Base.PaintWhite", 0.1, true)
|
||||
local byType = {
|
||||
["Base.PaintGreen"] = javaList({ green }),
|
||||
["Base.PaintWhite"] = javaList({ white }),
|
||||
}
|
||||
local character = {
|
||||
getInventory = function()
|
||||
return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end }
|
||||
end,
|
||||
removeFromHands = function() end,
|
||||
}
|
||||
assert(not PaintMyKI5.PaintInventory.consumeRequirements(character, {
|
||||
{ item = "Base.PaintGreen", uses = 1 },
|
||||
{ item = "Base.PaintWhite", uses = 1 },
|
||||
}))
|
||||
assertNear(green.delta, 1.0)
|
||||
assert(white.inContainer)
|
||||
end)
|
||||
|
||||
ISBaseTimedAction = {
|
||||
derive = function(_, name)
|
||||
local derived = { Type = name }
|
||||
derived.__index = derived
|
||||
return setmetatable(derived, { __index = ISBaseTimedAction })
|
||||
end,
|
||||
new = function(actionClass, character)
|
||||
return setmetatable({ character = character }, actionClass)
|
||||
end,
|
||||
stop = function() end,
|
||||
perform = function() end,
|
||||
}
|
||||
package.loaded["TimedActions/ISBaseTimedAction"] = true
|
||||
isClient = function() return false end
|
||||
isServer = function() return true end
|
||||
dofile(luaRoot .. "/shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua")
|
||||
PaintMyKI5.PaintInventory.checkRequirements = function() return true end
|
||||
|
||||
local function serverCharacter()
|
||||
return {
|
||||
getVehicle = function() return nil end,
|
||||
isTimedActionInstant = function() return false end,
|
||||
}
|
||||
end
|
||||
|
||||
local function mutableVehicle()
|
||||
local vehicle = fakeVehicle()
|
||||
vehicle.skin = 0
|
||||
vehicle.transmitted = 0
|
||||
vehicle.updated = 0
|
||||
vehicle.getSkinIndex = function(self) return self.skin end
|
||||
vehicle.setSkinIndex = function(self, skin) self.skin = skin end
|
||||
vehicle.transmitSkinIndex = function(self) self.transmitted = self.transmitted + 1 end
|
||||
vehicle.updateSkin = function(self) self.updated = self.updated + 1 end
|
||||
return vehicle
|
||||
end
|
||||
|
||||
test("server completion consumes then transmits the selected skin", function()
|
||||
local vehicle = mutableVehicle()
|
||||
local character = serverCharacter()
|
||||
local consumed = 0
|
||||
PaintMyKI5.PaintInventory.consumeRequirements = function()
|
||||
consumed = consumed + 1
|
||||
return true
|
||||
end
|
||||
local action = ISPaintMyKI5Vehicle:new(character, vehicle, 2, "Engine")
|
||||
action:serverStart()
|
||||
assert(action:complete())
|
||||
assert(consumed == 1 and vehicle.skin == 2 and vehicle.transmitted == 1)
|
||||
end)
|
||||
|
||||
test("server rejects forged and raced skin changes without consuming", function()
|
||||
local character = serverCharacter()
|
||||
local consumed = 0
|
||||
PaintMyKI5.PaintInventory.consumeRequirements = function()
|
||||
consumed = consumed + 1
|
||||
return true
|
||||
end
|
||||
local forgedVehicle = mutableVehicle()
|
||||
local forged = ISPaintMyKI5Vehicle:new(character, forgedVehicle, 99, "Engine")
|
||||
forged:serverStart()
|
||||
assert(not forged:complete())
|
||||
|
||||
local racedVehicle = mutableVehicle()
|
||||
local raced = ISPaintMyKI5Vehicle:new(character, racedVehicle, 2, "Engine")
|
||||
raced:serverStart()
|
||||
racedVehicle.skin = 1
|
||||
assert(not raced:complete())
|
||||
assert(consumed == 0)
|
||||
end)
|
||||
|
||||
test("failed skin or inventory commit restores state without losing paint", function()
|
||||
local character = serverCharacter()
|
||||
local consumed = 0
|
||||
PaintMyKI5.PaintInventory.consumeRequirements = function()
|
||||
consumed = consumed + 1
|
||||
return false
|
||||
end
|
||||
local vehicle = mutableVehicle()
|
||||
local action = ISPaintMyKI5Vehicle:new(character, vehicle, 2, "Engine")
|
||||
action:serverStart()
|
||||
assert(not action:complete())
|
||||
assert(vehicle.skin == 0 and vehicle.transmitted == 2 and consumed == 1)
|
||||
|
||||
local broken = mutableVehicle()
|
||||
broken.setSkinIndex = function(self, skin)
|
||||
if skin == 2 then error("skin mutation failed") end
|
||||
self.skin = skin
|
||||
end
|
||||
consumed = 0
|
||||
local brokenAction = ISPaintMyKI5Vehicle:new(character, broken, 2, "Engine")
|
||||
brokenAction:serverStart()
|
||||
assert(not brokenAction:complete())
|
||||
assert(broken.skin == 0 and consumed == 0)
|
||||
end)
|
||||
|
||||
test("singleplayer completion updates the local skin", function()
|
||||
isServer = function() return false end
|
||||
local vehicle = mutableVehicle()
|
||||
PaintMyKI5.PaintInventory.consumeRequirements = function() return true end
|
||||
local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 2, "Engine")
|
||||
action:serverStart()
|
||||
assert(action:complete())
|
||||
assert(vehicle.skin == 2 and vehicle.updated == 1 and vehicle.transmitted == 0)
|
||||
isServer = function() return true end
|
||||
end)
|
||||
|
||||
test("client completion never mutates inventory or the vehicle", function()
|
||||
isClient = function() return true end
|
||||
local vehicle = mutableVehicle()
|
||||
local consumed = 0
|
||||
PaintMyKI5.PaintInventory.consumeRequirements = function()
|
||||
consumed = consumed + 1
|
||||
return true
|
||||
end
|
||||
local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 1, "Engine")
|
||||
assert(action:complete())
|
||||
assert(consumed == 0 and vehicle.skin == 0 and vehicle.transmitted == 0)
|
||||
isClient = function() return false end
|
||||
end)
|
||||
|
||||
print("Lua tests passed: " .. passed)
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from lupa import LuaRuntime
|
||||
|
||||
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
runner = Path(__file__).with_name("lua") / "run.lua"
|
||||
runtime = LuaRuntime(unpack_returned_tuples=True)
|
||||
runtime.globals().PROJECT_ROOT = project_root.as_posix()
|
||||
compile_file = runtime.eval(
|
||||
"function(path) local chunk, message = loadfile(path); "
|
||||
"if not chunk then error(message) end end"
|
||||
)
|
||||
for lua_file in sorted((project_root / "42.20/media/lua").rglob("*.lua")):
|
||||
compile_file(lua_file.as_posix())
|
||||
runtime.execute(runner.read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scanner import scan_ki5_workshop as scanner
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
LUA_ROOT = PROJECT_ROOT / "42.20/media/lua"
|
||||
|
||||
|
||||
class PaintUseAllocationTests(unittest.TestCase):
|
||||
def test_allocates_one_full_bucket_proportionally(self) -> None:
|
||||
paints = (
|
||||
scanner.PaintPercentage(scanner.PaintCan("Base.PaintGrey", "Grey", (0.5,) * 3, 0.1), 60.0),
|
||||
scanner.PaintPercentage(scanner.PaintCan("Base.PaintGreen", "Green", (0.4,) * 3, 0.1), 20.0),
|
||||
scanner.PaintPercentage(scanner.PaintCan("Base.PaintWhite", "White", (0.9,) * 3, 0.1), 20.0),
|
||||
)
|
||||
|
||||
allocated = scanner.allocate_paint_uses(paints, 10)
|
||||
|
||||
self.assertEqual(
|
||||
[("Base.PaintGrey", 6.0), ("Base.PaintGreen", 2.0), ("Base.PaintWhite", 2.0)],
|
||||
[(entry.paint_can.item_id, uses) for entry, uses in allocated],
|
||||
)
|
||||
self.assertAlmostEqual(10.0, sum(uses for _entry, uses in allocated))
|
||||
|
||||
def test_equal_remainders_are_deterministic(self) -> None:
|
||||
paints = tuple(
|
||||
scanner.PaintPercentage(
|
||||
scanner.PaintCan(item_id, item_id, (0.5,) * 3, 0.1),
|
||||
percentage,
|
||||
)
|
||||
for item_id, percentage in (
|
||||
("Base.PaintRed", 33.3),
|
||||
("Base.PaintBlue", 33.3),
|
||||
("Base.PaintGreen", 33.4),
|
||||
)
|
||||
)
|
||||
|
||||
allocated = scanner.allocate_paint_uses(paints, 10)
|
||||
|
||||
self.assertEqual(
|
||||
[("Base.PaintGreen", 3.34), ("Base.PaintBlue", 3.33), ("Base.PaintRed", 3.33)],
|
||||
[(entry.paint_can.item_id, uses) for entry, uses in allocated],
|
||||
)
|
||||
self.assertAlmostEqual(10.0, sum(uses for _entry, uses in allocated))
|
||||
|
||||
def test_bucket_capacity_comes_from_game_use_delta(self) -> None:
|
||||
paints = (
|
||||
scanner.PaintCan("Base.PaintGreen", "Green", (0.4,) * 3, 0.1),
|
||||
scanner.PaintCan("Base.PaintWhite", "White", (0.9,) * 3, 0.1),
|
||||
)
|
||||
|
||||
self.assertEqual(10, scanner.paint_bucket_uses(paints))
|
||||
|
||||
|
||||
class InGameLuaContractTests(unittest.TestCase):
|
||||
EXPECTED_FILES = (
|
||||
"shared/PaintMyKI5/PaintRequirements.lua",
|
||||
"shared/PaintMyKI5/PaintInventory.lua",
|
||||
"shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua",
|
||||
"client/PaintMyKI5/ISPaintMyKI5UI.lua",
|
||||
"client/PaintMyKI5/PaintVehicleContextMenu.lua",
|
||||
)
|
||||
|
||||
def test_runtime_files_exist(self) -> None:
|
||||
self.assertEqual([], [name for name in self.EXPECTED_FILES if not (LUA_ROOT / name).is_file()])
|
||||
|
||||
def test_server_authoritative_timed_action_contract(self) -> None:
|
||||
source = (LUA_ROOT / self.EXPECTED_FILES[2]).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("ISBaseTimedAction:derive", source)
|
||||
self.assertIn("function ISPaintMyKI5Vehicle:complete()", source)
|
||||
self.assertIn("PaintRequirements.getRequirements", source)
|
||||
self.assertIn("PaintInventory.consumeRequirements", source)
|
||||
self.assertIn("vehicle:setSkinIndex", source)
|
||||
self.assertIn("vehicle:transmitSkinIndex", source)
|
||||
self.assertNotRegex(source, r"sendClientCommand\([^\n]+setSkinIndex")
|
||||
|
||||
def test_context_menu_and_texture_preview_contract(self) -> None:
|
||||
context_source = (LUA_ROOT / self.EXPECTED_FILES[4]).read_text(encoding="utf-8")
|
||||
ui_source = (LUA_ROOT / self.EXPECTED_FILES[3]).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("Events.OnFillWorldObjectContextMenu.Add", context_source)
|
||||
self.assertIn("getFullName()", context_source)
|
||||
self.assertIn("getTexture", ui_source)
|
||||
self.assertIn("or getTexture(textureData.texture)", ui_source)
|
||||
self.assertIn("drawTextureScaledAspect", ui_source)
|
||||
self.assertGreaterEqual(ui_source.count("PaintRequirements.isValidTarget"), 2)
|
||||
self.assertIn("ISTimedActionQueue.add", ui_source)
|
||||
|
||||
def test_b42_translation_files_are_valid_json(self) -> None:
|
||||
context = json.loads(
|
||||
(LUA_ROOT / "shared/Translate/EN/ContextMenu.json").read_text(encoding="utf-8")
|
||||
)
|
||||
ui = json.loads((LUA_ROOT / "shared/Translate/EN/IG_UI.json").read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual("Paint vehicle", context["ContextMenu_PaintMyKI5"])
|
||||
self.assertIn("IGUI_PaintMyKI5_Title", ui)
|
||||
self.assertIn("IGUI_PaintMyKI5_OneBucket", ui)
|
||||
|
||||
def test_generated_manifest_contains_exact_use_requirements(self) -> None:
|
||||
manifest = (
|
||||
LUA_ROOT / "shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("schemaVersion = 2", manifest)
|
||||
self.assertIn("bucketUses = 10", manifest)
|
||||
self.assertRegex(manifest, r'item = "Base\.Paint\w+", percent = \d+\.\d, uses = \d+\.\d\d')
|
||||
use_groups = re.findall(r"paints = \{(.*?)\n \},", manifest, re.S)
|
||||
self.assertTrue(use_groups)
|
||||
self.assertTrue(
|
||||
all(
|
||||
abs(sum(map(float, re.findall(r"uses = (\d+\.\d+)", group))) - 10.0) < 0.001
|
||||
for group in use_groups
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,515 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from scanner.scan_ki5_workshop import (
|
||||
MAX_METADATA_CHARS,
|
||||
ModCandidate,
|
||||
PaintCan,
|
||||
analyze_colors,
|
||||
discover_paint_cans,
|
||||
discover_mods,
|
||||
main,
|
||||
parse_vehicle_scripts,
|
||||
resolve_texture,
|
||||
scan_workshop,
|
||||
write_lua_manifest,
|
||||
)
|
||||
|
||||
|
||||
class ScannerTests(unittest.TestCase):
|
||||
TEST_PAINTS = (
|
||||
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
|
||||
PaintCan("Base.PaintGrey", "Paint - Gray", (0.50, 0.50, 0.50), 0.1),
|
||||
PaintCan("Base.PaintWhite", "Paint - White", (0.92, 0.92, 0.92), 0.1),
|
||||
)
|
||||
|
||||
def test_discovers_full_b42_paint_cans_and_rgb_values(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
game_root = Path(temporary_directory)
|
||||
self._write_game_palette(game_root)
|
||||
|
||||
paints = discover_paint_cans(game_root)
|
||||
|
||||
self.assertEqual(self.TEST_PAINTS, paints)
|
||||
|
||||
def test_paint_discovery_excludes_spray_paint_and_incomplete_buckets(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
game_root = Path(temporary_directory)
|
||||
self._write_game_palette(game_root, include_decoys=True)
|
||||
|
||||
paints = discover_paint_cans(game_root)
|
||||
|
||||
self.assertEqual(self.TEST_PAINTS, paints)
|
||||
|
||||
def test_paint_discovery_handles_multiple_semicolon_separated_tags(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
game_root = Path(temporary_directory)
|
||||
self._write_game_palette(game_root)
|
||||
item_script = game_root / "media/scripts/generated/items/drainable.txt"
|
||||
contents = item_script.read_text(encoding="utf-8")
|
||||
item_script.write_text(
|
||||
contents.replace("Tags = base:paint", "Tags = base:tool;base:paint"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
paints = discover_paint_cans(game_root)
|
||||
|
||||
self.assertEqual(self.TEST_PAINTS, paints)
|
||||
|
||||
def test_discovers_damnlib_once_and_selects_latest_b42_overlay(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
mod_root = root / "123" / "mods" / "testCar"
|
||||
self._write(mod_root / "mod.info", "name=Test Car\nid=testCar\nrequire=other, DamnLib\n")
|
||||
self._write(mod_root / "42.13" / "mod.info", "name=Test Car\nid=testCar\nrequire=\\damnlib\n")
|
||||
self._write(mod_root / "42.20" / "mod.info", "name=Test Car\nid=testCar\nrequire=damnlib\n")
|
||||
self._write(mod_root / "42.20" / "media/scripts/vehicles/car.txt", "module Base {}")
|
||||
|
||||
mods = discover_mods(root)
|
||||
|
||||
self.assertEqual(1, len(mods))
|
||||
self.assertEqual("123", mods[0].workshop_id)
|
||||
self.assertEqual("testCar", mods[0].mod_id)
|
||||
self.assertEqual(mod_root / "42.20", mods[0].content_root)
|
||||
|
||||
def test_dependency_token_must_match_exactly(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
self._write(
|
||||
root / "123/mods/notDamnLib/mod.info",
|
||||
"name=No Match\nid=noMatch\nrequire=notdamnlib\n",
|
||||
)
|
||||
|
||||
self.assertEqual([], discover_mods(root))
|
||||
|
||||
def test_maps_only_skin_textures_to_full_vehicle_id_and_filters_trailers(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
scripts = root / "media/scripts/vehicles"
|
||||
self._write(
|
||||
scripts / "cars.txt",
|
||||
"""
|
||||
module Base
|
||||
{
|
||||
model Interior { texture = Vehicles/Interior, }
|
||||
vehicle ExampleCar
|
||||
{
|
||||
engineForce = 4000,
|
||||
skin { texture = Vehicles/Example_Shell_green, }
|
||||
skin { texture = Vehicles/Example_Shell_white, }
|
||||
skin { texture = Vehicles/Example_Shell_green, }
|
||||
textureDamage1Shell = Vehicles/Example_damage,
|
||||
}
|
||||
vehicle TrailerExample
|
||||
{
|
||||
engineForce = 10,
|
||||
skin { texture = Vehicles/Trailer_Shell, }
|
||||
attachment trailer { offset = 0 0 0, }
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
vehicles = parse_vehicle_scripts(scripts)
|
||||
|
||||
self.assertEqual(1, len(vehicles))
|
||||
self.assertEqual("Base.ExampleCar", vehicles[0].vehicle_id)
|
||||
self.assertEqual(
|
||||
(
|
||||
"Vehicles/Example_Shell_green",
|
||||
"Vehicles/Example_Shell_white",
|
||||
"Vehicles/Example_Shell_green",
|
||||
),
|
||||
vehicles[0].texture_references,
|
||||
)
|
||||
self.assertEqual((0, 1, 2), tuple(skin.skin_index for skin in vehicles[0].skins))
|
||||
|
||||
def test_ignores_malformed_vehicle_blocks(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
scripts = root / "media/scripts/vehicles"
|
||||
self._write(
|
||||
scripts / "broken.txt",
|
||||
"""
|
||||
module Base
|
||||
{
|
||||
vehicle BrokenCar
|
||||
engineForce = 4000,
|
||||
skin { texture = Vehicles/Broken_Shell, }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
self.assertEqual([], parse_vehicle_scripts(scripts))
|
||||
|
||||
def test_analyzes_requested_color_mix(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
image_path = Path(temporary_directory) / "mix.png"
|
||||
image = Image.new("RGB", (10, 1))
|
||||
image.putdata([(128, 128, 128)] * 6 + [(105, 204, 105)] * 2 + [(235, 235, 235)] * 2)
|
||||
image.save(image_path)
|
||||
|
||||
colors = analyze_colors(image_path, self.TEST_PAINTS)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
("Base.PaintGrey", 60.0),
|
||||
("Base.PaintGreen", 20.0),
|
||||
("Base.PaintWhite", 20.0),
|
||||
],
|
||||
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||
)
|
||||
|
||||
def test_ignores_fully_transparent_pixels(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
image_path = Path(temporary_directory) / "alpha.png"
|
||||
image = Image.new("RGBA", (2, 1))
|
||||
image.putdata([(255, 0, 0, 255), (0, 0, 255, 0)])
|
||||
image.save(image_path)
|
||||
|
||||
colors = analyze_colors(image_path, self.TEST_PAINTS)
|
||||
|
||||
self.assertEqual(
|
||||
[("Base.PaintGrey", 100.0)],
|
||||
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||
)
|
||||
|
||||
def test_transparent_colors_do_not_influence_visible_palette(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
image_path = Path(temporary_directory) / "visible.png"
|
||||
image = Image.new("RGBA", (257, 1))
|
||||
transparent_noise = [(index, 255 - index, index // 2, 0) for index in range(256)]
|
||||
image.putdata([*transparent_noise, (0, 160, 0, 255)])
|
||||
image.save(image_path)
|
||||
|
||||
colors = analyze_colors(image_path, self.TEST_PAINTS)
|
||||
|
||||
self.assertEqual(
|
||||
[("Base.PaintGreen", 100.0)],
|
||||
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||
)
|
||||
|
||||
def test_integration_resolves_common_texture_and_prints_mapping(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
mod_root = root / "999" / "mods" / "exampleCar"
|
||||
self._write(
|
||||
mod_root / "42.13/mod.info",
|
||||
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
|
||||
)
|
||||
self._write(
|
||||
mod_root / "42.13/media/scripts/vehicles/example.txt",
|
||||
"""
|
||||
module Base
|
||||
{
|
||||
vehicle ExampleCar
|
||||
{
|
||||
engineForce = 4000,
|
||||
skin { texture = Vehicles/Example_Shell, }
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
|
||||
image_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (1, 1), (0, 160, 0)).save(image_path)
|
||||
|
||||
report = scan_workshop(root, self.TEST_PAINTS)
|
||||
|
||||
self.assertEqual(1, len(report.textures))
|
||||
self.assertEqual("Base.ExampleCar", report.textures[0].vehicle_id)
|
||||
self.assertEqual(image_path, report.textures[0].texture_path)
|
||||
self.assertEqual("Base.PaintGreen", report.textures[0].paints[0].paint_can.item_id)
|
||||
|
||||
game_root = root / "game"
|
||||
output_path = root / "generated.lua"
|
||||
self._write_game_palette(game_root)
|
||||
output = io.StringIO()
|
||||
exit_code = main(
|
||||
[
|
||||
"--workshop-root",
|
||||
str(root),
|
||||
"--game-root",
|
||||
str(game_root),
|
||||
"--output",
|
||||
str(output_path),
|
||||
],
|
||||
output=output,
|
||||
)
|
||||
console = output.getvalue()
|
||||
self.assertEqual(0, exit_code)
|
||||
self.assertIn("Workshop 999 | Example Car (exampleCar)", console)
|
||||
self.assertIn("Vehicle: Base.ExampleCar", console)
|
||||
self.assertIn("Paint - Green [Base.PaintGreen] 100.0%", console)
|
||||
self.assertIn("B42 paint cans: 3", console)
|
||||
self.assertIn("1 mods, 1 cars, 1 textures", console)
|
||||
|
||||
def test_missing_texture_adds_warning_and_keeps_scanning(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
mod_root = root / "999" / "mods" / "exampleCar"
|
||||
self._write(
|
||||
mod_root / "42.13/mod.info",
|
||||
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
|
||||
)
|
||||
self._write(
|
||||
mod_root / "42.13/media/scripts/vehicles/example.txt",
|
||||
"""
|
||||
module Base
|
||||
{
|
||||
vehicle ExampleCar
|
||||
{
|
||||
engineForce = 4000,
|
||||
skin { texture = Vehicles/Missing_Shell, }
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
report = scan_workshop(root, self.TEST_PAINTS)
|
||||
|
||||
self.assertEqual(1, report.cars_found)
|
||||
self.assertEqual((), report.textures)
|
||||
self.assertEqual(
|
||||
("Missing texture for Base.ExampleCar: Vehicles/Missing_Shell",),
|
||||
report.warnings,
|
||||
)
|
||||
|
||||
def test_cli_rejects_missing_workshop_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
game_root = root / "game"
|
||||
self._write_game_palette(game_root)
|
||||
missing_root = root / "missing"
|
||||
output = io.StringIO()
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"--workshop-root",
|
||||
str(missing_root),
|
||||
"--game-root",
|
||||
str(game_root),
|
||||
],
|
||||
output=output,
|
||||
)
|
||||
|
||||
self.assertEqual(2, exit_code)
|
||||
self.assertIn("Workshop root does not exist", output.getvalue())
|
||||
|
||||
def test_cli_rejects_missing_game_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
output = io.StringIO()
|
||||
|
||||
exit_code = main(
|
||||
["--workshop-root", str(root), "--game-root", str(root / "missing")],
|
||||
output=output,
|
||||
)
|
||||
|
||||
self.assertEqual(2, exit_code)
|
||||
self.assertIn("Game root does not exist", output.getvalue())
|
||||
|
||||
def test_rejects_drive_qualified_texture_reference(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
mod_root = Path(temporary_directory) / "123/mods/example"
|
||||
mod_root.mkdir(parents=True)
|
||||
mod = ModCandidate("123", "example", "Example", mod_root, mod_root)
|
||||
|
||||
self.assertIsNone(resolve_texture(mod, "C:outside.png"))
|
||||
|
||||
def test_nearest_paint_matching_uses_supplied_palette(self) -> None:
|
||||
paints = (
|
||||
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
|
||||
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
image_path = Path(temporary_directory) / "near.png"
|
||||
image = Image.new("RGB", (2, 1))
|
||||
image.putdata([(30, 30, 30), (225, 225, 225)])
|
||||
image.save(image_path)
|
||||
|
||||
percentages = analyze_colors(image_path, paints)
|
||||
|
||||
self.assertEqual(
|
||||
[("Custom.Bright", 50.0), ("Custom.Dark", 50.0)],
|
||||
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
|
||||
)
|
||||
|
||||
def test_partially_transparent_pixels_are_alpha_weighted(self) -> None:
|
||||
paints = (
|
||||
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
|
||||
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
image_path = Path(temporary_directory) / "alpha-weighted.png"
|
||||
image = Image.new("RGBA", (2, 1))
|
||||
image.putdata([(25, 25, 25, 255), (230, 230, 230, 64)])
|
||||
image.save(image_path)
|
||||
|
||||
percentages = analyze_colors(image_path, paints)
|
||||
|
||||
self.assertEqual(
|
||||
[("Custom.Dark", 79.9), ("Custom.Bright", 20.1)],
|
||||
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
|
||||
)
|
||||
|
||||
def test_empty_paint_palette_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
image_path = Path(temporary_directory) / "pixel.png"
|
||||
Image.new("RGB", (1, 1), (0, 0, 0)).save(image_path)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "paint palette is empty"):
|
||||
analyze_colors(image_path, ())
|
||||
|
||||
def test_writes_deterministic_lua_manifest_for_ingame_menu(self) -> None:
|
||||
from scanner.scan_ki5_workshop import PaintPercentage, ScanReport, TextureResult
|
||||
|
||||
texture = TextureResult(
|
||||
workshop_id="123",
|
||||
mod_id="example\"mod",
|
||||
mod_name="Example\nCar\x01",
|
||||
vehicle_id="Base.ExampleCar",
|
||||
skin_index=0,
|
||||
texture_reference="Vehicles/Example_Shell",
|
||||
texture_path=Path("ignored/absolute/path.png"),
|
||||
paints=(PaintPercentage(self.TEST_PAINTS[0], 100.0),),
|
||||
)
|
||||
report = ScanReport(1, 1, (texture,), ())
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output_path = Path(temporary_directory) / "generated.lua"
|
||||
|
||||
write_lua_manifest(output_path, report, self.TEST_PAINTS)
|
||||
first_contents = output_path.read_text(encoding="utf-8")
|
||||
write_lua_manifest(output_path, report, self.TEST_PAINTS)
|
||||
|
||||
self.assertEqual(first_contents, output_path.read_text(encoding="utf-8"))
|
||||
self.assertIn("PaintMyKI5.VehiclePaintData = {", first_contents)
|
||||
self.assertIn('vehicleId = "Base.ExampleCar"', first_contents)
|
||||
self.assertIn('texture = "Vehicles/Example_Shell"', first_contents)
|
||||
self.assertIn("skinIndex = 0", first_contents)
|
||||
self.assertIn('item = "Base.PaintGreen"', first_contents)
|
||||
self.assertIn("percent = 100.0", first_contents)
|
||||
self.assertIn('modId = "example\\\"mod"', first_contents)
|
||||
self.assertIn('modName = "Example\\nCar\\001"', first_contents)
|
||||
self.assertNotIn("ignored/absolute/path.png", first_contents)
|
||||
|
||||
oversized = replace(texture, mod_name="x" * (MAX_METADATA_CHARS + 1))
|
||||
output_path.write_text("preserve me", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "field exceeds"):
|
||||
write_lua_manifest(
|
||||
output_path,
|
||||
ScanReport(1, 1, (oversized,), ()),
|
||||
self.TEST_PAINTS,
|
||||
)
|
||||
self.assertEqual("preserve me", output_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual([], list(output_path.parent.glob(".*.tmp")))
|
||||
|
||||
def test_cli_writes_requested_lua_manifest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
game_root = root / "game"
|
||||
workshop_root = root / "workshop"
|
||||
output_path = root / "out/generated.lua"
|
||||
self._write_game_palette(game_root)
|
||||
mod_root = workshop_root / "999/mods/exampleCar"
|
||||
self._write(
|
||||
mod_root / "42.13/mod.info",
|
||||
"name=Example Car\nid=exampleCar\nrequire=damnlib\n",
|
||||
)
|
||||
self._write(
|
||||
mod_root / "42.13/media/scripts/vehicles/example.txt",
|
||||
"""
|
||||
module Base
|
||||
{
|
||||
vehicle ExampleCar
|
||||
{
|
||||
engineForce = 1,
|
||||
skin { texture = Vehicles/Example_Shell, }
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
|
||||
image_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (1, 1), (105, 204, 105)).save(image_path)
|
||||
output = io.StringIO()
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"--game-root", str(game_root),
|
||||
"--workshop-root", str(workshop_root),
|
||||
"--output", str(output_path),
|
||||
"--quiet",
|
||||
],
|
||||
output=output,
|
||||
)
|
||||
|
||||
self.assertEqual(0, exit_code)
|
||||
self.assertTrue(output_path.is_file())
|
||||
self.assertIn("Wrote 1 textures for 1 cars", output.getvalue())
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, contents: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def _write_game_palette(cls, game_root: Path, *, include_decoys: bool = False) -> None:
|
||||
item_blocks = []
|
||||
lua_rows = []
|
||||
names = {}
|
||||
for paint in cls.TEST_PAINTS:
|
||||
short_id = paint.item_id.split(".", 1)[1]
|
||||
item_blocks.append(
|
||||
f"""
|
||||
item {short_id}
|
||||
{{
|
||||
ItemType = base:drainable,
|
||||
PourType = Bucket,
|
||||
ReplaceOnDeplete = Base.PaintbucketEmpty,
|
||||
UseDelta = {paint.use_delta},
|
||||
Tags = base:paint,
|
||||
}}
|
||||
"""
|
||||
)
|
||||
red, green, blue = paint.rgb
|
||||
lua_rows.append(
|
||||
f'{{ paint = "{short_id}", text = "unused", color = {{ {red},{green},{blue} }} }},'
|
||||
)
|
||||
names[paint.item_id] = paint.display_name
|
||||
if include_decoys:
|
||||
item_blocks.extend(
|
||||
[
|
||||
"item SprayPaint { ItemType=base:drainable, DisplayCategory=Paint, }",
|
||||
"item PaintFake { ItemType=base:drainable, PourType=Bucket, Tags=base:paint, }",
|
||||
]
|
||||
)
|
||||
lua_rows.extend(
|
||||
[
|
||||
'{ paint = "SprayPaint", text = "unused", color = { 1,0,0 } },',
|
||||
'{ paint = "PaintFake", text = "unused", color = { 0,0,1 } },',
|
||||
]
|
||||
)
|
||||
cls._write(
|
||||
game_root / "media/scripts/generated/items/drainable.txt",
|
||||
"module Base {\n" + "\n".join(item_blocks) + "\n}",
|
||||
)
|
||||
cls._write(
|
||||
game_root / "media/lua/shared/BuildingObjects/ISPaintMenu.lua",
|
||||
"ISPaintMenu.PaintMenuItems = {\n" + "\n".join(lua_rows) + "\n}",
|
||||
)
|
||||
item_names = game_root / "media/lua/shared/Translate/EN/ItemName.json"
|
||||
item_names.parent.mkdir(parents=True, exist_ok=True)
|
||||
item_names.write_text(json.dumps(names), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user