Files
2026-11-03 12:41:09 -05:00

240 lines
12 KiB
Lua
Executable File

local target = assert(arg[1], "clock.lua path required")
local total = 0
local function test(name, fn)
local old_core, old_hearth, old_time = core, hearth, os.time
local ok, err = pcall(fn)
core, hearth, os.time = old_core, old_hearth, old_time
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
-- Contract doubles keep this suite independent of the engine, filesystem,
-- schema implementation, and real calendar/timezone. model_spec tests the math;
-- persistence_spec tests validation/migration of the stored records themselves.
local function harness(settings, singleplayer, stored)
local calls = {loads = 0, saves = 0, deferred = {}, solar = {}}
local C = {settings = {get = function(_, key) return settings[key] end}}
C.is_singleplayer = function() return singleplayer end
C.after = function(delay, fn) calls.deferred[#calls.deferred + 1] = {delay = delay, fn = fn} end
C.set_timeofday = function(value) calls.timeofday = value end
local P = {}
P.load_world = function(kind)
assert(kind == "location"); calls.loads = calls.loads + 1
if calls.load_error then error("Last Hearth: persistence failed; save retained for recovery", 0) end
return stored
end
P.save_world = function(kind, value)
assert(kind == "location"); calls.saves = calls.saves + 1
if calls.save_error then error("Last Hearth: persistence failed; save retained for recovery", 0) end
calls.saved = value
end
local M = {}
M.location = function(lat, lon)
lat, lon = tonumber(lat), tonumber(lon)
if not lat or not lon or lat ~= lat or lon ~= lon or math.abs(lat) > 90 or math.abs(lon) > 180 then return end
return {latitude = lat, longitude = lon}
end
M.solar = function(now, lat, lon)
local value = {now = now, latitude = lat, longitude = lon, timeofday = 0.5}
calls.solar[#calls.solar + 1] = value
return value
end
M.weather = function(now, lat, lon)
calls.weather = {now = now, latitude = lat, longitude = lon}
return "clear"
end
core, hearth = C, {model = M, persistence = P}
os.time = function() return calls.now or 1800000000 end
return calls
end
local function rejects(settings, singleplayer, text, stored)
local calls = harness(settings, singleplayer, stored)
local ok, message = pcall(dofile, target)
assert(not ok and type(message) == "string" and message:find(text, 1, true), "Expected configuration rejection")
assert(#calls.deferred == 0 and calls.saves == 0, "Rejected configuration must not start clock or save")
return calls
end
local function configured(deployment)
return {hearth_environment = deployment, hearth_latitude = "45", hearth_longitude = "0"}
end
test("singleplayer defaults local with disclosed neutral preview and deferred initialization", function()
local calls = harness({}, true)
dofile(target)
assert(hearth.has_location == false and hearth.clock == nil and hearth.weather == nil)
assert(calls.loads == 1 and calls.saves == 0 and #calls.deferred == 1)
assert(calls.deferred[1].delay == 0 and calls.deferred[1].fn == hearth.clock_update)
calls.deferred[1].fn()
assert(hearth.clock.latitude == 0 and hearth.clock.longitude == 0)
assert(hearth.clock.now == 1800000000 and hearth.weather == "clear" and calls.timeofday == 0.5)
assert(hearth.has_location == false)
end)
test("dedicated defaults production and requires explicit coordinates", function()
rejects({}, false, "requires explicit")
local calls = harness(configured(nil), false)
dofile(target); calls.deferred[1].fn()
assert(hearth.has_location and hearth.clock.latitude == 45)
end)
test("only the three environment enum values are accepted", function()
for _, value in ipairs({"", "dev", "LOCAL", "Production", " staging", "PRIVATE\nfixture"}) do
rejects({hearth_environment = value}, true, "must be local, staging, or production")
end
end)
test("dedicated local is forbidden even with coordinates", function()
rejects(configured("local"), false, "dedicated servers cannot")
end)
test("production rejects every nonempty date before parsing or persistence", function()
for _, value in ipairs({"1782043200", "bad", " ", "0", "nan"}) do
local settings = configured("production"); settings.hearth_preview_date = value
assert(rejects(settings, true, "production rejects").loads == 0)
end
end)
test("production rejects all weather settings except auto", function()
for _, value in ipairs({"rain", "clear", "", "bad", "AUTO"}) do
local settings = configured("production"); settings.hearth_preview_weather = value
rejects(settings, false, "production rejects")
end
end)
test("production rejects launcher mode and smoke token independently", function()
for _, key in ipairs({"hearth_launcher_mode", "hearth_smoke_token"}) do
for _, value in ipairs({"", "PRIVATE\nfixture"}) do
local settings = configured("production"); settings[key] = value
local calls = harness(settings, false)
local ok, message = pcall(dofile, target)
assert(not ok and message == "Last Hearth: production rejects fixture and launcher test settings.")
assert(calls.loads == 0 and calls.saves == 0 and #calls.deferred == 0)
end
end
end)
test("production rejects every course setting including empty and disabled strings before persistence", function()
for _, singleplayer in ipairs({true, false}) do
for _, key in ipairs({"hearth_m1_course", "hearth_m1_course_nonce",
"hearth_m1_course_snag", "hearth_course_token"}) do
for _, value in ipairs({"", "false", "0", "true", "PRIVATE\nfixture"}) do
local settings = configured("production"); settings[key] = value
local calls = harness(settings, singleplayer, {latitude = 45, longitude = 0})
local ok, message = pcall(dofile, target)
assert(not ok and message == "Last Hearth: production rejects fixture and launcher test settings.", key)
assert(calls.loads == 0 and calls.saves == 0 and #calls.deferred == 0)
assert(#calls.solar == 0 and calls.weather == nil and calls.timeofday == nil)
assert(hearth.clock_update == nil and hearth.set_location == nil)
end
end
end
end)
test("implicit dedicated production also rejects course flags before loading stored location", function()
for _, key in ipairs({"hearth_m1_course", "hearth_m1_course_nonce",
"hearth_m1_course_snag", "hearth_course_token"}) do
local settings = configured(nil); settings[key] = "false"
assert(rejects(settings, false, "production rejects", {latitude = 45, longitude = 0}).loads == 0)
end
end)
test("local and staging clocks accept course fixtures without granting enrollment or starting a run", function()
for _, singleplayer in ipairs({true, false}) do
local settings = configured(singleplayer and "local" or "staging")
settings.hearth_m1_course, settings.hearth_m1_course_nonce = "true", "public-clock-fixture"
settings.hearth_m1_course_snag, settings.hearth_course_token = "false", "public-clock-fixture"
settings.hearth_preview_date, settings.hearth_preview_weather = "1782043200", "clear"
settings.hearth_launcher_mode = "course"
local calls = harness(settings, singleplayer)
dofile(target); calls.deferred[1].fn()
assert(calls.loads == 1 and calls.saves == 0 and #calls.deferred == 1)
assert(hearth.clock.now == 1782043200 and hearth.clock.latitude == 45 and hearth.clock.longitude == 0)
assert(hearth.weather == "clear" and hearth.course == nil and hearth.sessions == nil)
end
end)
test("production allows empty date and automatic weather and advances real UTC", function()
local settings = configured("production")
settings.hearth_preview_date, settings.hearth_preview_weather = "", "auto"
local calls = harness(settings, false)
dofile(target); hearth.clock_update()
assert(hearth.clock.now == 1800000000)
calls.now = 1800000010; hearth.clock_update()
assert(hearth.clock.now == 1800000010 and calls.weather.now == 1800000010)
end)
test("production requires configured valid coordinates even in singleplayer with a stored location", function()
for _, singleplayer in ipairs({true, false}) do
for _, coords in ipairs({{}, {"45"}, {"bad", "0"}, {"91", "0"}, {"0", "181"}}) do
rejects({hearth_environment = "production", hearth_latitude = coords[1], hearth_longitude = coords[2]},
singleplayer, "requires explicit", {latitude = 45, longitude = 0})
end
end
end)
test("dedicated staging preserves the explicit coordinate requirement", function()
rejects({hearth_environment = "staging"}, false, "requires explicit", {latitude = 45, longitude = 0})
end)
test("staging accepts integer fixture dates including bounds on dedicated engines", function()
for _, date in ipairs({"946684800", "1782043200", "1797854400", "4102444800"}) do
local settings = configured("staging")
settings.hearth_preview_date, settings.hearth_preview_weather = date, "rain"
settings.hearth_launcher_mode, settings.hearth_smoke_token = "smoke", "fixture"
local calls = harness(settings, false)
dofile(target); hearth.clock_update()
assert(hearth.clock.now == tonumber(date) and calls.weather.now == tonumber(date))
assert(hearth.weather == "rain" and calls.timeofday == 0.5)
end
end)
test("local fixtures and all supported weather choices work", function()
for _, weather in ipairs({"auto", "clear", "rain"}) do
local calls = harness({hearth_preview_date = "1782043200", hearth_preview_weather = weather}, true)
dofile(target); hearth.clock_update()
assert(hearth.clock.now == 1782043200 and calls.weather.now == 1782043200)
assert(hearth.weather == (weather == "auto" and "clear" or weather))
end
end)
test("nonproduction rejects fractional out of range and nonfinite dates", function()
for _, deployment in ipairs({"local", "staging"}) do
for _, date in ipairs({"946684799", "4102444801", "1782043200.5", "bad", "nan", "inf", "1e309", " "}) do
local settings = configured(deployment); settings.hearth_preview_date = date
rejects(settings, deployment == "local", "must be an integer UTC timestamp")
end
end
end)
test("nonproduction rejects invalid weather instead of ignoring it", function()
for _, weather in ipairs({"", "snow", "Rain"}) do
rejects({hearth_preview_weather = weather}, true, "must be auto, clear, or rain")
end
end)
test("singleplayer stored location is loaded exclusively through persistence", function()
local calls = harness({}, true, {latitude = -30, longitude = 120})
dofile(target); hearth.clock_update()
assert(calls.loads == 1 and calls.saves == 0 and hearth.has_location)
assert(hearth.clock.latitude == -30 and hearth.clock.longitude == 120)
end)
test("configured location takes precedence but still validates stored location", function()
local calls = harness(configured("local"), true, {latitude = -30, longitude = 120})
dofile(target); hearth.clock_update()
assert(calls.loads == 1 and hearth.clock.latitude == 45 and hearth.clock.longitude == 0)
calls = harness(configured("production"), false); calls.load_error = true
local ok = pcall(dofile, target)
assert(not ok and calls.loads == 1 and calls.saves == 0 and #calls.deferred == 0)
end)
test("stored location errors never silently become neutral previews", function()
local calls = harness({}, true); calls.load_error = true
assert(not pcall(dofile, target))
assert(calls.saves == 0 and #calls.deferred == 0)
end)
test("set_location saves before publishing and next update uses the new coordinates", function()
local calls = harness({}, true)
dofile(target)
hearth.set_location(-45, 70)
assert(calls.saves == 1 and calls.saved.latitude == -45 and calls.saved.longitude == 70)
assert(hearth.has_location)
hearth.clock_update(); assert(hearth.clock.latitude == -45 and hearth.clock.longitude == 70)
end)
test("failed location save retains unchosen and previously selected state", function()
local calls = harness({}, true)
dofile(target); calls.save_error = true
assert(not pcall(hearth.set_location, 45, 10) and not hearth.has_location)
hearth.clock_update(); assert(hearth.clock.latitude == 0 and hearth.clock.longitude == 0)
calls.save_error = false; hearth.set_location(20, 30); calls.save_error = true
assert(not pcall(hearth.set_location, 45, 10) and hearth.has_location)
hearth.clock_update(); assert(hearth.clock.latitude == 20 and hearth.clock.longitude == 30)
end)
test("invalid location input and dedicated selection never write", function()
local calls = harness({}, true)
dofile(target)
assert(not pcall(hearth.set_location, 91, 0) and calls.saves == 0)
calls = harness(configured("staging"), false)
dofile(target)
assert(not pcall(hearth.set_location, 0, 0) and calls.saves == 0)
end)
print("PASS " .. total .. " clock tests")