mirror of
https://github.com/rayaman/multi.git
synced 2026-09-04 23:17:35 -04:00
1096 lines
38 KiB
Lua
1096 lines
38 KiB
Lua
--[[
|
|
Test suite for multi.lua
|
|
Run with: lua multi_test.lua
|
|
|
|
Requires multi.lua to be in the same directory or on the Lua path.
|
|
Compatible with Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT.
|
|
]]
|
|
package.path = "../?/init.lua;../?.lua;../init.lua;../?.lua;" .. package.path
|
|
|
|
-- ─────────────────────────────────────────────
|
|
-- Minimal test runner
|
|
-- ─────────────────────────────────────────────
|
|
local passed, failed, skipped = 0, 0, 0
|
|
local failures = {}
|
|
|
|
local function test(name, fn)
|
|
local ok, err = pcall(fn)
|
|
if ok then
|
|
passed = passed + 1
|
|
io.write("\x1b[92m ✓\x1b[0m " .. name .. "\n")
|
|
else
|
|
failed = failed + 1
|
|
table.insert(failures, {name = name, err = tostring(err)})
|
|
io.write("\x1b[91m ✗\x1b[0m " .. name .. "\n")
|
|
io.write(" " .. tostring(err) .. "\n")
|
|
end
|
|
end
|
|
|
|
local function skip(name, reason)
|
|
skipped = skipped + 1
|
|
io.write("\x1b[93m -\x1b[0m " .. name .. " [SKIPPED: " .. (reason or "") .. "]\n")
|
|
end
|
|
|
|
local function section(name)
|
|
io.write("\n\x1b[97m── " .. name .. " ──\x1b[0m\n")
|
|
end
|
|
|
|
local function assert_eq(a, b, msg)
|
|
if a ~= b then
|
|
error((msg or "assert_eq failed") .. ": expected " .. tostring(b) .. ", got " .. tostring(a), 2)
|
|
end
|
|
end
|
|
|
|
local function assert_truthy(v, msg)
|
|
if not v then
|
|
error((msg or "expected truthy value, got falsy") .. ": " .. tostring(v), 2)
|
|
end
|
|
end
|
|
|
|
local function assert_falsy(v, msg)
|
|
if v then
|
|
error((msg or "expected falsy value, got truthy") .. ": " .. tostring(v), 2)
|
|
end
|
|
end
|
|
|
|
local function assert_type(v, t, msg)
|
|
if type(v) ~= t then
|
|
error((msg or "type mismatch") .. ": expected " .. t .. ", got " .. type(v), 2)
|
|
end
|
|
end
|
|
|
|
-- ─────────────────────────────────────────────
|
|
-- Load the library
|
|
-- ─────────────────────────────────────────────
|
|
local multi, thread
|
|
local ok, err = pcall(function()
|
|
multi, thread = require("multi"):init()
|
|
end)
|
|
|
|
if not ok then
|
|
io.write("\x1b[91mFATAL: Could not load multi.lua: " .. tostring(err) .. "\x1b[0m\n")
|
|
io.write("Make sure multi.lua is in the same directory or on package.path.\n")
|
|
os.exit(1)
|
|
end
|
|
|
|
-- Helper: run the scheduler for up to `max_ticks` ticks or until `done()` returns true.
|
|
local function run_until(done, max_ticks)
|
|
max_ticks = max_ticks or 10000
|
|
for _ = 1, max_ticks do
|
|
multi:uManager()
|
|
if done and done() then return true end
|
|
end
|
|
return done == nil
|
|
end
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 1. LIBRARY METADATA
|
|
-- ═════════════════════════════════════════════
|
|
section("Library Metadata")
|
|
|
|
test("multi is a table", function()
|
|
assert_type(multi, "table")
|
|
end)
|
|
|
|
test("multi.Version is a string", function()
|
|
assert_type(multi.Version, "string")
|
|
assert_truthy(#multi.Version > 0, "Version string should not be empty")
|
|
end)
|
|
|
|
test("multi.Name is 'root'", function()
|
|
assert_eq(multi.Name, "root")
|
|
end)
|
|
|
|
test("multi.Type is registered rootprocess type", function()
|
|
assert_truthy(multi:isType(multi.registerType("rootprocess")), "Type should be rootprocess")
|
|
end)
|
|
|
|
test("$multi global is populated", function()
|
|
assert_truthy(_G["$multi"], "$multi global should exist")
|
|
assert_truthy(_G["$multi"].multi, "$multi.multi should exist")
|
|
assert_truthy(_G["$multi"].thread, "$multi.thread should exist")
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 2. TYPE SYSTEM
|
|
-- ═════════════════════════════════════════════
|
|
section("Type System")
|
|
|
|
test("registerType returns the type string", function()
|
|
local t = multi.registerType("test_custom_type_xyz")
|
|
assert_eq(t, "test_custom_type_xyz")
|
|
end)
|
|
|
|
test("registerType is idempotent (re-register same type)", function()
|
|
local t1 = multi.registerType("idempotent_type")
|
|
local t2 = multi.registerType("idempotent_type")
|
|
assert_eq(t1, t2)
|
|
end)
|
|
|
|
test("hasType finds registered types", function()
|
|
multi.registerType("findable_type")
|
|
assert_truthy(multi.hasType("findable_type"), "Should find registered type")
|
|
end)
|
|
|
|
test("hasType returns nil for unknown types", function()
|
|
local result = multi.hasType("definitely_not_registered_xyzzy")
|
|
assert_falsy(result, "Should return nil for unknown type")
|
|
end)
|
|
|
|
test("getTypes returns a table", function()
|
|
assert_type(multi.getTypes(), "table")
|
|
assert_truthy(#multi.getTypes() > 0, "Should have at least one registered type")
|
|
end)
|
|
|
|
test("multi:isType() works correctly", function()
|
|
assert_truthy(multi:isType(multi.registerType("rootprocess")))
|
|
assert_falsy(multi:isType("not_root"))
|
|
end)
|
|
|
|
test("DestroyedObj sentinels are tables", function()
|
|
assert_type(multi.DestroyedObj, "table")
|
|
assert_eq(multi.DESTROYED, multi.DestroyedObj)
|
|
end)
|
|
|
|
test("setType converts object to DestroyedObj", function()
|
|
local obj = {foo = "bar", baz = 42}
|
|
multi.setType(obj, multi.DestroyedObj)
|
|
-- After destruction, accessing fields should return DestroyedObj-family values
|
|
assert_truthy(obj.foo ~= nil or obj.foo == nil) -- should not error
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 3. UTILITY FUNCTIONS
|
|
-- ═════════════════════════════════════════════
|
|
section("Utility Functions")
|
|
|
|
test("multi.randomString returns a string of the right length", function()
|
|
for _, n in ipairs({1, 5, 10, 32}) do
|
|
local s = multi.randomString(n)
|
|
assert_type(s, "string")
|
|
assert_eq(#s, n, "randomString(" .. n .. ") length")
|
|
end
|
|
end)
|
|
|
|
test("multi.randomString produces alphanumeric characters only", function()
|
|
local s = multi.randomString(100)
|
|
assert_truthy(s:match("^[a-zA-Z0-9]+$"), "Should only contain alphanumeric chars")
|
|
end)
|
|
|
|
test("multi.ForEach iterates all elements", function()
|
|
local collected = {}
|
|
multi.ForEach({10, 20, 30}, function(v) table.insert(collected, v) end)
|
|
assert_eq(#collected, 3)
|
|
assert_eq(collected[1], 10)
|
|
assert_eq(collected[3], 30)
|
|
end)
|
|
|
|
test("multi.ForEach on empty table does nothing", function()
|
|
local count = 0
|
|
multi.ForEach({}, function() count = count + 1 end)
|
|
assert_eq(count, 0)
|
|
end)
|
|
|
|
test("multi.isMulitObj returns true for multi objects", function()
|
|
local alarm = multi:newAlarm(999)
|
|
assert_truthy(multi.isMulitObj(alarm))
|
|
alarm:Destroy()
|
|
end)
|
|
|
|
test("multi.isMulitObj returns false for plain tables", function()
|
|
assert_falsy(multi.isMulitObj({foo = "bar"}))
|
|
end)
|
|
|
|
test("multi.isMulitObj returns false for non-tables", function()
|
|
assert_falsy(multi.isMulitObj("string"))
|
|
assert_falsy(multi.isMulitObj(42))
|
|
assert_falsy(multi.isMulitObj(nil))
|
|
end)
|
|
|
|
test("multi.Round rounds correctly", function()
|
|
assert_eq(multi.Round(3.14159, 2), 3.14)
|
|
assert_eq(multi.Round(2.5, 0), 3)
|
|
assert_eq(multi.Round(1.005, 2), 1.01)
|
|
end)
|
|
|
|
test("multi.AlignTable returns a string", function()
|
|
local result = multi.AlignTable({
|
|
{"Name", "Age", "City"},
|
|
{"Alice", "30", "NYC"},
|
|
{"Bob", "4", "LA"},
|
|
})
|
|
assert_type(result, "string")
|
|
assert_truthy(result:find("Alice"), "Should contain 'Alice'")
|
|
assert_truthy(result:find("Bob"), "Should contain 'Bob'")
|
|
end)
|
|
|
|
test("multi.timer returns elapsed time and results", function()
|
|
local t, result = multi.timer(function() return 42 end)
|
|
assert_type(t, "number")
|
|
assert_truthy(t >= 0, "elapsed time should be non-negative")
|
|
assert_eq(result, 42)
|
|
end)
|
|
|
|
test("multi.isTimeout returns true for TIMEOUT sentinel", function()
|
|
assert_truthy(multi.isTimeout(multi.TIMEOUT))
|
|
assert_truthy(multi.isTimeout("TIMEOUT")) -- For backwards compat
|
|
end)
|
|
|
|
test("multi.isTimeout returns false for non-TIMEOUT values", function()
|
|
assert_falsy(multi.isTimeout(nil))
|
|
assert_falsy(multi.isTimeout(42))
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 4. UUID GENERATION
|
|
-- ═════════════════════════════════════════════
|
|
section("UUID Generation")
|
|
|
|
test("generate_uuid7 returns a string", function()
|
|
local uuid = multi.generate_uuid7()
|
|
assert_type(uuid, "string")
|
|
end)
|
|
|
|
test("generate_uuid7 has correct format (8-4-4-4-12)", function()
|
|
local uuid = multi.generate_uuid7()
|
|
assert_truthy(uuid:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$"),
|
|
"UUID format mismatch: " .. uuid)
|
|
end)
|
|
|
|
test("generate_uuid7 version nibble is '7'", function()
|
|
local uuid = multi.generate_uuid7()
|
|
-- The 15th character (after removing hyphens at positions 9,14,19,24) is the version nibble
|
|
-- UUID format: xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx -> char 15 is '7'
|
|
assert_eq(uuid:sub(15, 15), "7", "Version nibble should be '7', got: " .. uuid)
|
|
end)
|
|
|
|
test("generate_uuid7 produces unique IDs", function()
|
|
local ids = {}
|
|
for i = 1, 20 do
|
|
ids[i] = multi.generate_uuid7()
|
|
end
|
|
-- Check a sample for uniqueness
|
|
local seen = {}
|
|
for _, id in ipairs(ids) do
|
|
assert_falsy(seen[id], "Duplicate UUID generated: " .. id)
|
|
seen[id] = true
|
|
end
|
|
end)
|
|
|
|
test("extract_uuid7_timestamp returns a table with expected fields", function()
|
|
local uuid = multi.generate_uuid7()
|
|
local result = multi.extract_uuid7_timestamp(uuid)
|
|
assert_type(result, "table")
|
|
assert_truthy(result.milliseconds, "Should have milliseconds")
|
|
assert_truthy(result.seconds, "Should have seconds")
|
|
assert_truthy(result.date, "Should have date")
|
|
assert_truthy(result.iso8601, "Should have iso8601")
|
|
end)
|
|
|
|
test("extract_uuid7_timestamp iso8601 ends with 'Z'", function()
|
|
local uuid = multi.generate_uuid7()
|
|
local result = multi.extract_uuid7_timestamp(uuid)
|
|
assert_eq(result.iso8601:sub(-1), "Z")
|
|
end)
|
|
|
|
test("extract_uuid7_timestamp returns nil for invalid UUID", function()
|
|
local result = multi.extract_uuid7_timestamp("not-a-valid-uuid")
|
|
assert_falsy(result, "Should return nil for invalid UUID")
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 5. CONNECTION SYSTEM
|
|
-- ═════════════════════════════════════════════
|
|
section("Connection System")
|
|
|
|
test("newConnection returns a connection object", function()
|
|
local conn = multi:newConnection()
|
|
assert_type(conn, "table")
|
|
assert_eq(conn.Type, multi.registerType("connector", "connections"))
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("connection Fire calls connected function", function()
|
|
local conn = multi:newConnection()
|
|
local fired = false
|
|
conn:Connect(function() fired = true end)
|
|
conn:Fire()
|
|
assert_truthy(fired, "Connected function should have been called")
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("connection Fire passes arguments", function()
|
|
local conn = multi:newConnection()
|
|
local got_a, got_b
|
|
conn:Connect(function(a, b) got_a = a; got_b = b end)
|
|
conn:Fire(10, 20)
|
|
assert_eq(got_a, 10)
|
|
assert_eq(got_b, 20)
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("connection supports multiple subscribers", function()
|
|
local conn = multi:newConnection()
|
|
local count = 0
|
|
conn:Connect(function() count = count + 1 end)
|
|
conn:Connect(function() count = count + 1 end)
|
|
conn:Connect(function() count = count + 1 end)
|
|
conn:Fire()
|
|
assert_eq(count, 3, "All three subscribers should have been called")
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("hasConnections is false before Connect", function()
|
|
local conn = multi:newConnection()
|
|
assert_falsy(conn:hasConnections())
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("hasConnections is true after Connect", function()
|
|
local conn = multi:newConnection()
|
|
conn:Connect(function() end)
|
|
assert_truthy(conn:hasConnections())
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("Unconnect removes the subscription", function()
|
|
local conn = multi:newConnection()
|
|
local count = 0
|
|
local ref = conn:Connect(function() count = count + 1 end)
|
|
conn:Fire()
|
|
assert_eq(count, 1)
|
|
conn:Unconnect(ref)
|
|
conn:Fire()
|
|
assert_eq(count, 1, "Should not fire after Unconnect")
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("Lock prevents Fire from calling subscribers", function()
|
|
local conn = multi:newConnection()
|
|
local count = 0
|
|
conn:Connect(function() count = count + 1 end)
|
|
conn:Lock()
|
|
conn:Fire()
|
|
assert_eq(count, 0, "Locked connection should not fire")
|
|
conn:Unlock()
|
|
conn:Fire()
|
|
assert_eq(count, 1, "Unlocked connection should fire")
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("Bind replaces the subscriber list", function()
|
|
local conn = multi:newConnection()
|
|
local old_count, new_count = 0, 0
|
|
conn:Connect(function() old_count = old_count + 1 end)
|
|
local old_fast = conn:Bind({function() new_count = new_count + 1 end})
|
|
conn:Fire()
|
|
assert_eq(old_count, 0, "Old subscriber should not be called after Bind")
|
|
assert_eq(new_count, 1, "New subscriber should be called")
|
|
assert_type(old_fast, "table")
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("Remove clears all subscribers", function()
|
|
local conn = multi:newConnection()
|
|
local count = 0
|
|
conn:Connect(function() count = count + 1 end)
|
|
conn:Connect(function() count = count + 1 end)
|
|
conn:Remove()
|
|
conn:Fire()
|
|
assert_eq(count, 0, "No subscribers should remain after Remove")
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("connection__add operator creates OR connection", function()
|
|
local c1 = multi:newConnection()
|
|
local c2 = multi:newConnection()
|
|
local or_conn = c1 + c2
|
|
local count = 0
|
|
or_conn:Connect(function() count = count + 1 end)
|
|
c1:Fire()
|
|
c2:Fire()
|
|
assert_eq(count, 2, "OR connection should fire for each source")
|
|
or_conn:Destroy()
|
|
c1:Destroy()
|
|
c2:Destroy()
|
|
end)
|
|
|
|
test("Destroy makes connection inert", function()
|
|
local conn = multi:newConnection()
|
|
local count = 0
|
|
conn:Connect(function() count = count + 1 end)
|
|
conn:Destroy()
|
|
-- Fire on a destroyed connection should be a no-op
|
|
conn:Fire()
|
|
assert_eq(count, 0, "Destroyed connection should not fire")
|
|
assert_truthy(conn.destroyed, "destroyed flag should be set")
|
|
end)
|
|
|
|
test("connection_count increments on newConnection", function()
|
|
local before = multi.connection_count
|
|
local c = multi:newConnection()
|
|
assert_eq(multi.connection_count, before + 1)
|
|
c:Destroy()
|
|
end)
|
|
|
|
test("connection_subscriptions tracks Connect and Unconnect", function()
|
|
local conn = multi:newConnection()
|
|
local before = multi.connection_subscriptions
|
|
local ref = conn:Connect(function() end)
|
|
assert_eq(multi.connection_subscriptions, before + 1)
|
|
conn:Unconnect(ref)
|
|
assert_eq(multi.connection_subscriptions, before, "Should decrement on Unconnect")
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("getConnections returns the subscriber list", function()
|
|
local conn = multi:newConnection()
|
|
conn:Connect(function() end)
|
|
conn:Connect(function() end)
|
|
local conns = conn:getConnections()
|
|
assert_type(conns, "table")
|
|
assert_truthy(#conns >= 2)
|
|
conn:Destroy()
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 6. TIMER
|
|
-- ═════════════════════════════════════════════
|
|
section("Timer")
|
|
|
|
test("newTimer returns a timer object", function()
|
|
local t = multi:newTimer()
|
|
assert_eq(t.Type, multi.registerType("timer", "timers"))
|
|
end)
|
|
|
|
test("timer Get returns a non-negative number after Start", function()
|
|
local t = multi:newTimer()
|
|
t:Start()
|
|
local elapsed = t:Get()
|
|
assert_type(elapsed, "number")
|
|
assert_truthy(elapsed >= 0)
|
|
end)
|
|
|
|
test("timer Pause freezes elapsed time", function()
|
|
local t = multi:newTimer()
|
|
t:Start()
|
|
-- busy-wait briefly
|
|
local deadline = os.clock() + 0.02
|
|
while os.clock() < deadline do end
|
|
t:Pause()
|
|
local frozen = t:Get()
|
|
local deadline2 = os.clock() + 0.02
|
|
while os.clock() < deadline2 do end
|
|
local after = t:Get()
|
|
assert_eq(frozen, after, "Paused timer should not advance")
|
|
assert_truthy(t:isPaused(), "isPaused should return true")
|
|
end)
|
|
|
|
test("timer Resume resumes counting", function()
|
|
local t = multi:newTimer()
|
|
t:Start()
|
|
local deadline = os.clock() + 0.01
|
|
while os.clock() < deadline do end
|
|
t:Pause()
|
|
local before = t:Get()
|
|
t:Resume()
|
|
local deadline2 = os.clock() + 0.02
|
|
while os.clock() < deadline2 do end
|
|
local after = t:Get()
|
|
assert_truthy(after > before, "Resumed timer should advance")
|
|
assert_falsy(t:isPaused())
|
|
end)
|
|
|
|
test("timer Reset restarts counting from zero", function()
|
|
local t = multi:newTimer()
|
|
t:Start()
|
|
local deadline = os.clock() + 0.02
|
|
while os.clock() < deadline do end
|
|
t:Reset()
|
|
local after_reset = t:Get()
|
|
assert_truthy(after_reset < 0.05, "Timer should be near zero after Reset")
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 7. SCHEDULER ACTORS
|
|
-- ═════════════════════════════════════════════
|
|
section("Scheduler Actors")
|
|
|
|
test("newLoop creates a loop object and fires OnLoop", function()
|
|
local fires = 0
|
|
local loop = multi:newLoop(function() fires = fires + 1 end)
|
|
run_until(function() return fires >= 3 end, 1000)
|
|
assert_truthy(fires >= 3, "Loop should have fired at least 3 times, got " .. fires)
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("newLoop Pause stops firing", function()
|
|
local fires = 0
|
|
local loop = multi:newLoop(function() fires = fires + 1 end)
|
|
run_until(function() return fires >= 2 end, 1000)
|
|
loop:Pause()
|
|
local snapshot = fires
|
|
run_until(nil, 100)
|
|
assert_eq(fires, snapshot, "Paused loop should not fire")
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("newLoop Resume restarts firing", function()
|
|
local fires = 0
|
|
local loop = multi:newLoop(function() fires = fires + 1 end)
|
|
run_until(function() return fires >= 2 end, 1000)
|
|
loop:Pause()
|
|
local before = fires
|
|
run_until(nil, 50)
|
|
loop:Resume()
|
|
run_until(function() return fires >= before + 2 end, 1000)
|
|
assert_truthy(fires >= before + 2, "Resumed loop should fire again")
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("newUpdater fires OnUpdate based on skip interval", function()
|
|
local fires = 0
|
|
local updater = multi:newUpdater(1, function() fires = fires + 1 end)
|
|
run_until(function() return fires >= 3 end, 2000)
|
|
assert_truthy(fires >= 3)
|
|
updater:Destroy()
|
|
end)
|
|
|
|
test("newAlarm fires OnRing after timeout", function()
|
|
local rang = false
|
|
local alarm = multi:newAlarm(0, function() rang = true end) -- 0-second alarm fires immediately
|
|
run_until(function() return rang end, 500)
|
|
assert_truthy(rang, "Alarm should have rung")
|
|
end)
|
|
|
|
test("newAlarm does not ring before timeout", function()
|
|
local rang = false
|
|
local alarm = multi:newAlarm(9999, function() rang = true end)
|
|
run_until(nil, 100)
|
|
assert_falsy(rang, "Alarm should not have rung yet")
|
|
alarm:Destroy()
|
|
end)
|
|
|
|
test("newAlarm Reset re-arms the alarm", function()
|
|
local ring_count = 0
|
|
local alarm = multi:newAlarm(0, function() ring_count = ring_count + 1 end)
|
|
run_until(function() return ring_count >= 1 end, 500)
|
|
alarm:Reset()
|
|
run_until(function() return ring_count >= 2 end, 500)
|
|
assert_truthy(ring_count >= 2, "Alarm should ring again after Reset")
|
|
end)
|
|
|
|
test("newTLoop fires periodically", function()
|
|
local fires = 0
|
|
local tloop = multi:newTLoop(function() fires = fires + 1 end, 0)
|
|
run_until(function() return fires >= 3 end, 5000)
|
|
assert_truthy(fires >= 3, "TLoop should fire multiple times, got " .. fires)
|
|
tloop:Destroy()
|
|
end)
|
|
|
|
test("newStep fires OnStep for each step", function()
|
|
local steps = {}
|
|
local s = multi:newStep(1, 4, 1)
|
|
s.OnStep(function(self, pos) table.insert(steps, pos) end)
|
|
run_until(function() return #steps >= 3 end, 5000)
|
|
assert_truthy(#steps >= 3)
|
|
assert_eq(steps[1], 1)
|
|
assert_eq(steps[2], 2)
|
|
s:Destroy()
|
|
end)
|
|
|
|
test("newStep fires OnEnd when reaching the end", function()
|
|
local ended = false
|
|
local s = multi:newStep(1, 3, 1)
|
|
s.OnEnd(function() ended = true end)
|
|
run_until(function() return ended end, 5000)
|
|
assert_truthy(ended, "Step should have fired OnEnd")
|
|
end)
|
|
|
|
test("newEvent fires OnEvent when task returns truthy", function()
|
|
local done = false
|
|
local tick = 0
|
|
local ev = multi:newEvent(function()
|
|
tick = tick + 1
|
|
if tick >= 3 then return true end
|
|
end, function() done = true end)
|
|
run_until(function() return done end, 5000)
|
|
assert_truthy(done, "Event should have fired")
|
|
end)
|
|
|
|
test("newEvent does not fire when task returns falsy", function()
|
|
local done = false
|
|
local ev = multi:newEvent(function() return false end, function() done = true end)
|
|
run_until(nil, 200)
|
|
assert_falsy(done, "Event should not have fired")
|
|
ev:Destroy()
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 8. OBJECT LIFECYCLE (Pause / Resume / Destroy)
|
|
-- ═════════════════════════════════════════════
|
|
section("Object Lifecycle")
|
|
|
|
test("Pause sets Active to false", function()
|
|
local loop = multi:newLoop(function() end)
|
|
assert_truthy(loop.Active)
|
|
loop:Pause()
|
|
assert_falsy(loop.Active)
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("isPaused returns correct state", function()
|
|
local loop = multi:newLoop(function() end)
|
|
assert_falsy(loop:isPaused())
|
|
loop:Pause()
|
|
assert_truthy(loop:isPaused())
|
|
loop:Resume()
|
|
assert_falsy(loop:isPaused())
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("isActive returns correct state", function()
|
|
local loop = multi:newLoop(function() end)
|
|
assert_truthy(loop:isActive())
|
|
loop:Pause()
|
|
assert_falsy(loop:isActive())
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("Destroy removes object from Mainloop", function()
|
|
local before = #multi.Mainloop
|
|
local loop = multi:newLoop(function() end)
|
|
assert_eq(#multi.Mainloop, before + 1)
|
|
loop:Destroy()
|
|
assert_eq(#multi.Mainloop, before, "Destroyed object should be removed from Mainloop")
|
|
end)
|
|
|
|
test("isDone returns true after Pause", function()
|
|
local loop = multi:newLoop(function() end)
|
|
loop:Pause()
|
|
assert_truthy(loop:isDone())
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("setName sets the Name field", function()
|
|
local loop = multi:newLoop(function() end)
|
|
loop:setName("MyTestLoop")
|
|
assert_eq(loop.Name, "MyTestLoop")
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("reallocate moves object to another processor", function()
|
|
local proc = multi:newProcessor("TestReallocProc", {Start = true})
|
|
local loop = multi:newLoop(function() end)
|
|
local before_main = #multi.Mainloop
|
|
loop:reallocate(proc)
|
|
assert_eq(#multi.Mainloop, before_main - 1, "Loop should be removed from main Mainloop")
|
|
assert_truthy(#proc.Mainloop >= 1, "Loop should be in proc Mainloop")
|
|
proc:Destroy()
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 9. PROCESSOR
|
|
-- ═════════════════════════════════════════════
|
|
section("Processor")
|
|
|
|
test("newProcessor returns a process object", function()
|
|
local proc = multi:newProcessor("TestProc1", {Start = false})
|
|
assert_eq(proc.Type, multi.registerType("process", "processes"))
|
|
assert_type(proc.Mainloop, "table")
|
|
proc:Destroy()
|
|
end)
|
|
|
|
test("processor Start/Stop toggles active state", function()
|
|
local proc = multi:newProcessor("TestProc2", {Start = false})
|
|
assert_falsy(proc.isActive())
|
|
proc.Start()
|
|
assert_truthy(proc.isActive())
|
|
proc.Stop()
|
|
assert_falsy(proc.isActive())
|
|
proc:Destroy()
|
|
end)
|
|
|
|
test("processor run() executes when active", function()
|
|
local fires = 0
|
|
local proc = multi:newProcessor("TestProc3", {Start = true})
|
|
proc:newLoop(function() fires = fires + 1 end)
|
|
for _ = 1, 10 do proc.run() end
|
|
assert_truthy(fires > 0, "Processor run() should execute objects")
|
|
proc:Destroy()
|
|
end)
|
|
|
|
test("processor run() is a no-op when stopped", function()
|
|
local fires = 0
|
|
local proc = multi:newProcessor("TestProc4", {Start = false})
|
|
proc:newLoop(function() fires = fires + 1 end)
|
|
for _ = 1, 10 do proc.run() end
|
|
assert_eq(fires, 0, "Stopped processor should not run objects")
|
|
proc:Destroy()
|
|
end)
|
|
|
|
test("processor getName and getFullName", function()
|
|
local proc = multi:newProcessor("MyNamedProc", {Start = false})
|
|
assert_eq(proc:getName(), "MyNamedProc")
|
|
local full = proc:getFullName()
|
|
assert_truthy(full:find("MyNamedProc"), "getFullName should contain processor name")
|
|
proc:Destroy()
|
|
end)
|
|
|
|
test("processor MaxObjects constraint", function()
|
|
local proc = multi:newProcessor("MaxObjProc", {Start = false, MaxObjects = 2})
|
|
local a = proc:newLoop(function() end)
|
|
local b = proc:newLoop(function() end)
|
|
local c, err = proc:newLoop(function() end)
|
|
assert_truthy(err, "Should return error when MaxObjects exceeded")
|
|
proc:Destroy()
|
|
end)
|
|
|
|
test("getProcessors returns a list containing created processors", function()
|
|
local procs_before = #multi:getProcessors()
|
|
local proc = multi:newProcessor("GetProcsTest", {Start = false})
|
|
local procs_after = #multi:getProcessors()
|
|
assert_truthy(procs_after > procs_before)
|
|
proc:Destroy()
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 10. THREADING
|
|
-- ═════════════════════════════════════════════
|
|
section("Threading")
|
|
|
|
test("thread.isThread returns false outside a thread", function()
|
|
assert_falsy(thread.isThread(), "Should return false in main coroutine")
|
|
end)
|
|
|
|
test("newThread creates a thread and it runs", function()
|
|
local done = false
|
|
thread:newThread("TestThread1", function()
|
|
done = true
|
|
end)
|
|
run_until(function() return done end, 5000)
|
|
assert_truthy(done, "Thread should have run")
|
|
end)
|
|
|
|
test("thread OnDeath fires when thread finishes", function()
|
|
local death_fired = false
|
|
local t = thread:newThread("TestThread_Death", function()
|
|
return "finished"
|
|
end)
|
|
t.OnDeath(function(val)
|
|
death_fired = true
|
|
end)
|
|
run_until(function() return death_fired end, 5000)
|
|
assert_truthy(death_fired)
|
|
end)
|
|
|
|
test("thread OnDeath receives return values", function()
|
|
local result
|
|
local t = thread:newThread("TestThread_Ret", function()
|
|
return 42, "hello"
|
|
end)
|
|
t.OnDeath(function(a, b)
|
|
result = {a, b}
|
|
end)
|
|
run_until(function() return result ~= nil end, 5000)
|
|
assert_truthy(result)
|
|
assert_eq(result[1], 42)
|
|
assert_eq(result[2], "hello")
|
|
end)
|
|
|
|
test("thread.sleep suspends for approximately the given time", function()
|
|
local start_t = os.clock()
|
|
local done = false
|
|
thread:newThread("SleepThread", function()
|
|
thread.sleep(0.05)
|
|
done = true
|
|
end)
|
|
run_until(function() return done end, 100000)
|
|
local elapsed = os.clock() - start_t
|
|
assert_truthy(done)
|
|
assert_truthy(elapsed >= 0.04, "Should have slept at least ~0.05s, elapsed=" .. elapsed)
|
|
end)
|
|
|
|
test("thread.hold waits for condition", function()
|
|
local flag = false
|
|
local saw_flag = false
|
|
thread:newThread("HoldThread", function()
|
|
thread.hold(function() return flag end)
|
|
saw_flag = true
|
|
end)
|
|
-- Run without setting flag first
|
|
run_until(nil, 200)
|
|
assert_falsy(saw_flag, "Thread should still be waiting")
|
|
flag = true
|
|
run_until(function() return saw_flag end, 5000)
|
|
assert_truthy(saw_flag, "Thread should have proceeded after flag set")
|
|
end)
|
|
|
|
test("thread.hold on a connection waits for Fire", function()
|
|
local conn = multi:newConnection()
|
|
local received
|
|
thread:newThread("HoldConnThread", function()
|
|
local val = thread.hold(conn)
|
|
received = val
|
|
end)
|
|
run_until(nil, 100)
|
|
assert_falsy(received, "Should still be waiting")
|
|
conn:Fire(99)
|
|
run_until(function() return received ~= nil end, 5000)
|
|
assert_eq(received, 99)
|
|
conn:Destroy()
|
|
end)
|
|
|
|
test("thread.skip skips N scheduler ticks", function()
|
|
local done = false
|
|
local ticks_before = 0
|
|
local loop = multi:newLoop(function() ticks_before = ticks_before + 1 end)
|
|
thread:newThread("SkipThread", function()
|
|
thread.skip(5)
|
|
done = true
|
|
end)
|
|
run_until(function() return done end, 5000)
|
|
assert_truthy(done)
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("thread GlobalVariables set and get", function()
|
|
thread.set("test_gvar_key", "hello_global")
|
|
assert_eq(thread.get("test_gvar_key"), "hello_global")
|
|
end)
|
|
|
|
test("thread.waitFor blocks until variable is set", function()
|
|
local got
|
|
local key = "waitfor_test_" .. multi.randomString(6)
|
|
thread:newThread("WaitForSetter", function()
|
|
thread.sleep(0)
|
|
thread.set(key, "ready")
|
|
end)
|
|
thread:newThread("WaitForWaiter", function()
|
|
got = thread.waitFor(key)
|
|
end)
|
|
run_until(function() return got ~= nil end, 5000)
|
|
assert_eq(got, "ready")
|
|
end)
|
|
|
|
test("newFunction wraps a thread and supports wait()", function()
|
|
local fn = thread:newFunction(function(x)
|
|
return x * 2
|
|
end, true) -- holdme = true, so calling blocks until result
|
|
local result
|
|
thread:newThread("newFnCaller", function()
|
|
result = fn(21)
|
|
end)
|
|
run_until(function() return result ~= nil end, 5000)
|
|
assert_eq(result, 42)
|
|
end)
|
|
|
|
test("newFunction with holdme=false returns future table", function()
|
|
local fn = thread:newFunction(function()
|
|
return "async_result"
|
|
end, false)
|
|
local future = fn()
|
|
assert_type(future, "table")
|
|
assert_truthy(future.wait, "Future should have a wait method")
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 11. PRIORITY SYSTEM
|
|
-- ═════════════════════════════════════════════
|
|
section("Priority System")
|
|
|
|
test("setPriority accepts string 'normal'", function()
|
|
local loop = multi:newLoop(function() end)
|
|
loop:setPriority("normal")
|
|
assert_eq(loop.Priority, multi.Priority_Normal)
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("setPriority accepts string shortcuts", function()
|
|
local loop = multi:newLoop(function() end)
|
|
local cases = {
|
|
{"c", multi.Priority_Core},
|
|
{"vh", multi.Priority_Very_High},
|
|
{"h", multi.Priority_High},
|
|
{"a", multi.Priority_Above_Normal},
|
|
{"n", multi.Priority_Normal},
|
|
{"b", multi.Priority_Below_Normal},
|
|
{"l", multi.Priority_Low},
|
|
{"vl", multi.Priority_Very_Low},
|
|
{"i", multi.Priority_Idle},
|
|
}
|
|
for _, case in ipairs(cases) do
|
|
loop:setPriority(case[1])
|
|
assert_eq(loop.Priority, case[2], "Priority mismatch for shortcut '" .. case[1] .. "'")
|
|
end
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("setPriority accepts numeric values", function()
|
|
local loop = multi:newLoop(function() end)
|
|
loop:setPriority(64)
|
|
assert_eq(loop.Priority, 64)
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("ResetPriority restores the default priority", function()
|
|
local loop = multi:newLoop(function() end)
|
|
loop:setPriority("normal")
|
|
local default = loop.Priority
|
|
loop:setPriority("idle")
|
|
assert_eq(loop.Priority, multi.Priority_Idle)
|
|
loop:ResetPriority()
|
|
assert_eq(loop.Priority, default, "Priority should be restored to default")
|
|
loop:Destroy()
|
|
end)
|
|
|
|
test("PriorityResolve maps priority values to names", function()
|
|
assert_eq(multi.PriorityResolve[multi.Priority_Normal], "Normal")
|
|
assert_eq(multi.PriorityResolve[multi.Priority_High], "High")
|
|
assert_eq(multi.PriorityResolve[multi.Priority_Idle], "Idle")
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 12. STATS & INTROSPECTION
|
|
-- ═════════════════════════════════════════════
|
|
section("Stats & Introspection")
|
|
|
|
test("getStats returns a table with root entry", function()
|
|
local stats = multi:getStats()
|
|
assert_type(stats, "table")
|
|
assert_truthy(stats["root"], "Stats should contain 'root' entry")
|
|
assert_type(stats["root"].connections, "number")
|
|
assert_type(stats["root"].subscriptions, "number")
|
|
end)
|
|
|
|
test("getChildren returns the Mainloop", function()
|
|
local children = multi:getChildren()
|
|
assert_eq(children, multi.Mainloop)
|
|
end)
|
|
|
|
test("getVersion returns the library version string", function()
|
|
assert_eq(multi:getVersion(), multi.Version)
|
|
end)
|
|
|
|
test("getRunners excludes internal process threads", function()
|
|
local runners = multi:getRunners()
|
|
assert_type(runners, "table")
|
|
for _, r in ipairs(runners) do
|
|
assert_falsy(r.__ignore, "Runners should not include __ignore objects")
|
|
end
|
|
end)
|
|
|
|
test("getCurrentProcess returns multi at top level", function()
|
|
assert_eq(multi.getCurrentProcess(), multi)
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 13. OS UTILITIES
|
|
-- ═════════════════════════════════════════════
|
|
section("OS Utilities")
|
|
|
|
test("os.getOS returns 'windows' or 'unix'", function()
|
|
local os_name = os.getOS()
|
|
assert_truthy(os_name == "windows" or os_name == "unix",
|
|
"os.getOS should return 'windows' or 'unix', got: " .. tostring(os_name))
|
|
end)
|
|
|
|
test("os.sleep is defined (just check it exists)", function()
|
|
assert_type(os.sleep, "function")
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 14. TABLE UTILITIES
|
|
-- ═════════════════════════════════════════════
|
|
section("Table Utilities (table.merge)")
|
|
|
|
test("table.merge merges non-overlapping keys", function()
|
|
local t1 = {a = 1}
|
|
local t2 = {b = 2}
|
|
table.merge(t1, t2)
|
|
assert_eq(t1.a, 1)
|
|
assert_eq(t1.b, 2)
|
|
end)
|
|
|
|
test("table.merge overwrites with t2 values on conflict", function()
|
|
local t1 = {a = 1, b = "old"}
|
|
local t2 = {b = "new", c = 3}
|
|
table.merge(t1, t2)
|
|
assert_eq(t1.b, "new")
|
|
assert_eq(t1.c, 3)
|
|
end)
|
|
|
|
test("table.merge recurses into nested tables", function()
|
|
local t1 = {nested = {x = 1}}
|
|
local t2 = {nested = {y = 2}}
|
|
table.merge(t1, t2)
|
|
assert_eq(t1.nested.x, 1)
|
|
assert_eq(t1.nested.y, 2)
|
|
end)
|
|
|
|
test("table.merge returns t1", function()
|
|
local t1 = {a = 1}
|
|
local result = table.merge(t1, {b = 2})
|
|
assert_eq(result, t1)
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 15. SCHEDULED JOBS / TASKS
|
|
-- ═════════════════════════════════════════════
|
|
section("Task Queue")
|
|
|
|
test("newTask adds a function to the task queue", function()
|
|
local ran = false
|
|
multi:newTask(function() ran = true end)
|
|
-- Tasks are processed by the Task Handler thread inside threadManager
|
|
run_until(function() return ran end, 5000)
|
|
assert_truthy(ran, "Task should have been run")
|
|
end)
|
|
|
|
test("multiple tasks run in order", function()
|
|
local order = {}
|
|
multi:newTask(function() table.insert(order, 1) end)
|
|
multi:newTask(function() table.insert(order, 2) end)
|
|
multi:newTask(function() table.insert(order, 3) end)
|
|
run_until(function() return #order >= 3 end, 5000)
|
|
assert_eq(#order, 3)
|
|
assert_eq(order[1], 1)
|
|
assert_eq(order[2], 2)
|
|
assert_eq(order[3], 3)
|
|
end)
|
|
|
|
-- ═════════════════════════════════════════════
|
|
-- 16. NIL SENTINEL
|
|
-- ═════════════════════════════════════════════
|
|
section("NIL Sentinel")
|
|
|
|
test("multi.NIL is a table with Type='NIL'", function()
|
|
assert_type(multi.NIL, "table")
|
|
assert_eq(multi.NIL.Type, "NIL")
|
|
end)
|
|
|
|
test("multi.NIL is distinct from Lua nil", function()
|
|
assert_truthy(multi.NIL ~= nil)
|
|
end)
|
|
|
|
-- ─────────────────────────────────────────────
|
|
-- SUMMARY
|
|
-- ─────────────────────────────────────────────
|
|
io.write(string.format(
|
|
"\n\x1b[97m────────────────────────────────\x1b[0m\n" ..
|
|
"\x1b[92m Passed : %d\x1b[0m\n" ..
|
|
"\x1b[91m Failed : %d\x1b[0m\n" ..
|
|
"\x1b[93m Skipped: %d\x1b[0m\n" ..
|
|
"\x1b[97m────────────────────────────────\x1b[0m\n",
|
|
passed, failed, skipped
|
|
))
|
|
|
|
if #failures > 0 then
|
|
io.write("\n\x1b[91mFailed tests:\x1b[0m\n")
|
|
for _, f in ipairs(failures) do
|
|
io.write(string.format(" • %s\n %s\n", f.name, f.err))
|
|
end
|
|
end
|
|
|
|
os.exit(failed == 0 and 0 or 1)
|