mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
346 lines
13 KiB
Lua
346 lines
13 KiB
Lua
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
local Net = require("src.link.Net")
|
|
local Json = require("src.link.Json")
|
|
local Session = require("src.link.Session")
|
|
|
|
local function sessionPair()
|
|
local hostNet, guestNet = Net.loopbackPair()
|
|
return Session.new(hostNet, { role = "host", kind = "link" }),
|
|
Session.new(guestNet, { role = "guest", kind = "link" })
|
|
end
|
|
|
|
local function fakeTransport(options)
|
|
options = options or {}
|
|
local transport = {
|
|
paired = options.paired ~= false,
|
|
closed = false,
|
|
error = nil,
|
|
inbox = options.inbox or {},
|
|
closeCount = 0,
|
|
}
|
|
function transport:update()
|
|
if options.onUpdate then options.onUpdate(self) end
|
|
if options.updateError then error(options.updateError) end
|
|
end
|
|
function transport:poll()
|
|
if options.pollError then error(options.pollError) end
|
|
local messages = self.inbox
|
|
self.inbox = {}
|
|
return messages
|
|
end
|
|
function transport:send(message)
|
|
self.sent = message
|
|
return true
|
|
end
|
|
function transport:close()
|
|
self.closeCount = self.closeCount + 1
|
|
self.closed = true
|
|
if options.closeError then error(options.closeError) end
|
|
end
|
|
return transport
|
|
end
|
|
|
|
local function readFile(path)
|
|
local handle = assert(io.open(path, "rb"))
|
|
local body = handle:read("*a")
|
|
handle:close()
|
|
return body
|
|
end
|
|
|
|
do
|
|
local host, guest = sessionPair()
|
|
T.eq(host:getRole(), "host", "host role is assigned locally")
|
|
T.eq(guest:getRole(), "guest", "guest role is assigned locally")
|
|
T.eq(host:getKind(), "link", "session kind is retained")
|
|
T.eq(host:getStatus(), "paired", "wrapped loopback starts paired")
|
|
|
|
guest:send({
|
|
type = "hello", name = "BLUE", role = "host", kind = "tournament",
|
|
})
|
|
host:update()
|
|
local hello = host:take("hello")
|
|
T.eq(hello.name, "BLUE", "send forwards the original payload")
|
|
T.eq(hello.session, nil, "send adds no session envelope")
|
|
T.eq(host:getRole(), "host", "peer payload cannot replace local role")
|
|
T.eq(host:getKind(), "link", "peer payload cannot replace local kind")
|
|
end
|
|
|
|
do
|
|
local host, guest = sessionPair()
|
|
guest:send({ type = "before", sequence = 1 })
|
|
guest:send({ type = "hello", sequence = 2 })
|
|
guest:send({ type = "after", sequence = 3 })
|
|
guest:send({ type = "hello", sequence = 4 })
|
|
host:update()
|
|
|
|
local hello = host:take("hello")
|
|
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
|
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
|
|
|
local rest = host:poll()
|
|
T.eq(#rest, 2, "poll returns every remaining packet once")
|
|
T.eq(rest[1].sequence, 3, "take preserves the earlier remainder order")
|
|
T.eq(rest[2].sequence, 4, "take preserves repeated-type order")
|
|
T.eq(#host:poll(), 0, "poll clears the private FIFO")
|
|
end
|
|
|
|
do
|
|
local sent
|
|
local transport = {
|
|
paired = false,
|
|
code = nil,
|
|
address = "192.0.2.5:7777",
|
|
target = "ROOM01",
|
|
update = function(self)
|
|
self.paired = true
|
|
self.code = "ROOM02"
|
|
end,
|
|
poll = function() return {} end,
|
|
send = function(_, message)
|
|
sent = message
|
|
return "queued", 7
|
|
end,
|
|
close = function(self) self.closed = true end,
|
|
}
|
|
local session = Session.new(transport, { role = "guest", kind = "tournament" })
|
|
T.eq(session:getStatus(), "connecting", "unpaired transport starts connecting")
|
|
T.eq(session.address, "192.0.2.5:7777", "address metadata is mirrored")
|
|
T.eq(session.target, "ROOM01", "target metadata is mirrored")
|
|
|
|
local outbound = { type = "ping" }
|
|
local result, count = session:send(outbound)
|
|
T.eq(result, "queued", "send preserves the transport's first return")
|
|
T.eq(count, 7, "send preserves the transport's second return")
|
|
T.eq(sent, outbound, "send forwards the original table unchanged")
|
|
|
|
session:update()
|
|
T.eq(session:getStatus(), "paired", "update observes transport pairing")
|
|
T.eq(session.code, "ROOM02", "update refreshes relay metadata")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({ onUpdate = function(self)
|
|
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
|
|
self.closed = true
|
|
end })
|
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
|
session:update()
|
|
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
|
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
|
T.eq(session:take("bye").final, true, "final close packet remains observable")
|
|
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
|
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({
|
|
onUpdate = function(self) self.closed = true end,
|
|
closeError = "normal cleanup exploded",
|
|
})
|
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
|
T.check(pcall(session.update, session),
|
|
"normal-close cleanup exception does not escape the game loop")
|
|
local reason, detail = session:getFailure()
|
|
T.eq(reason, "transport_error",
|
|
"normal-close cleanup exception becomes a transport failure")
|
|
T.check(detail:find("normal cleanup exploded", 1, true) ~= nil,
|
|
"normal-close cleanup failure keeps its diagnostic detail")
|
|
T.eq(session:getStatus(), "failed",
|
|
"normal-close cleanup exception cannot report a clean close")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({ onUpdate = function(self)
|
|
self.inbox = { { type = "before", sequence = 1 } }
|
|
self.error = "socket failed"
|
|
self.closed = true
|
|
end })
|
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
|
session:update()
|
|
local reason, detail = session:getFailure()
|
|
T.eq(reason, "transport_error", "transport failure has a stable reason")
|
|
T.eq(detail, "socket failed", "transport failure retains original detail")
|
|
T.eq(session:getStatus(), "draining", "transport failure drains valid prefix")
|
|
T.eq(session.error, nil, "legacy error stays hidden during drain")
|
|
T.eq(session.closed, false, "legacy closed stays false during failed drain")
|
|
T.eq(session:pollOne().sequence, 1, "failed drain returns its valid prefix")
|
|
T.eq(session:getStatus(), "failed", "failed drain reaches failed")
|
|
T.eq(session.error, "socket failed", "legacy error appears at terminal failure")
|
|
transport.error = "later error"
|
|
session:update()
|
|
local _, latchedDetail = session:getFailure()
|
|
T.eq(latchedDetail, "socket failed", "first terminal failure stays latched")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({ inbox = {
|
|
{ type = "before", sequence = 1 },
|
|
false,
|
|
{ type = "after", sequence = 3 },
|
|
} })
|
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
|
session:update()
|
|
local reason = session:getFailure()
|
|
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
|
|
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
|
|
local messages = session:poll()
|
|
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
|
|
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
|
|
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
|
session:update()
|
|
T.eq(session:getFailure(), "protocol_error",
|
|
"table without string type is a protocol error")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({
|
|
inbox = { { type = "future_world_packet", value = 9 } },
|
|
})
|
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
|
session:update()
|
|
T.eq(session:pollOne().value, 9, "unknown typed packet stays mode-owned")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({
|
|
inbox = { { type = "already_decoded", value = 4 } },
|
|
updateError = "update exploded",
|
|
})
|
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
|
local ok = pcall(session.update, session)
|
|
T.check(ok, "transport update exception does not escape the game loop")
|
|
T.eq(session:getStatus(), "draining", "update exception still drains prior inbox")
|
|
T.eq(session:pollOne().value, 4, "decoded packet survives update exception")
|
|
T.eq(session:getStatus(), "failed", "update exception becomes terminal failure")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({ pollError = "poll exploded" })
|
|
local session = Session.new(transport, { role = "host", kind = "link" })
|
|
T.check(pcall(session.update, session),
|
|
"transport poll exception does not escape the game loop")
|
|
local reason, detail = session:getFailure()
|
|
T.eq(reason, "transport_error", "poll exception is a transport failure")
|
|
T.check(detail:find("poll exploded", 1, true) ~= nil,
|
|
"poll exception keeps its diagnostic detail")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport({ closeError = "close exploded" })
|
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
|
T.check(pcall(session.close, session),
|
|
"transport close exception does not escape cleanup")
|
|
T.eq(session:getFailure(), "transport_error",
|
|
"close exception is a transport failure")
|
|
session:close()
|
|
T.eq(transport.closeCount, 1, "failed close is still attempted only once")
|
|
end
|
|
|
|
do
|
|
local transport = fakeTransport()
|
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
|
session:close()
|
|
session:close()
|
|
session:update()
|
|
T.eq(transport.closeCount, 1, "close and post-terminal update are idempotent")
|
|
T.eq(session:getStatus(), "closed", "explicit close reaches closed")
|
|
end
|
|
|
|
do
|
|
T.check(not pcall(Session.new, nil, { role = "host", kind = "link" }),
|
|
"constructor rejects missing transport")
|
|
local transport = fakeTransport()
|
|
T.check(not pcall(Session.new, transport, { role = "leader", kind = "link" }),
|
|
"constructor rejects unsupported role")
|
|
T.check(not pcall(Session.new, transport, { role = "host", kind = "" }),
|
|
"constructor rejects empty kind")
|
|
end
|
|
|
|
do
|
|
local senderNet, receiverNet = Net.loopbackPair()
|
|
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
|
senderNet:send(false)
|
|
receiver:update()
|
|
T.eq(receiver:getFailure(), "protocol_error",
|
|
"loopback forwards decoded false to session validation")
|
|
end
|
|
|
|
do
|
|
local delivered = false
|
|
local transport = Net.new()
|
|
transport.enetHost = {
|
|
service = function()
|
|
if delivered then return nil end
|
|
delivered = true
|
|
return { type = "receive", data = "false" }
|
|
end,
|
|
}
|
|
local session = Session.new(transport, { role = "guest", kind = "link" })
|
|
session:update()
|
|
T.eq(session:getFailure(), "protocol_error",
|
|
"ENet forwards decoded false to session validation")
|
|
end
|
|
|
|
do
|
|
local transport = Net.new()
|
|
local session = Session.new(transport, { role = "host", kind = "tournament" })
|
|
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
|
"TCP control handoff does not index a decoded scalar")
|
|
session:update()
|
|
T.eq(session:getFailure(), "protocol_error",
|
|
"decoded TCP scalar reaches session validation")
|
|
end
|
|
|
|
do
|
|
local transport = Net.new()
|
|
transport:handleTCPLine(Json.encode({ type = "hosted", code = "ABCDEF" }))
|
|
T.eq(transport.code, "ABCDEF", "valid relay controls stay transport-owned")
|
|
transport:handleTCPLine(Json.encode({ type = "hello", name = "RED" }))
|
|
T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact")
|
|
end
|
|
|
|
do
|
|
local source = readFile("src/link/LinkState.lua")
|
|
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
|
|
"LinkState depends on the session boundary")
|
|
T.check(source:find('kind = "link"', 1, true) ~= nil,
|
|
"LinkState assigns the link session kind locally")
|
|
T.check(source:find("self.net.inbox", 1, true) == nil,
|
|
"LinkState never mutates a transport inbox")
|
|
T.check(source:find("self.net = Net.new()", 1, true) == nil,
|
|
"LinkState stores only successful session wrappers")
|
|
T.check(source:find(':take("hello")', 1, true) ~= nil,
|
|
"LinkState retrieves hello without draining unrelated packets")
|
|
T.check(source:find(':take("party")', 1, true) ~= nil,
|
|
"LinkState leaves battle handoff packets in session order")
|
|
T.check(source:find("getStatus()", 1, true) ~= nil,
|
|
"LinkState uses the session lifecycle instead of raw terminal flags")
|
|
end
|
|
|
|
do
|
|
local source = readFile("src/link/Tournament.lua")
|
|
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
|
|
"Tournament depends on the session boundary")
|
|
T.check(source:find('kind = "tournament"', 1, true) ~= nil,
|
|
"Tournament assigns its connection role and kind locally")
|
|
T.check(source:find("self.net.inbox", 1, true) == nil,
|
|
"Tournament never mutates a transport inbox")
|
|
T.check(source:find("self.net = Net.new()", 1, true) == nil,
|
|
"Tournament stores only a successful session wrapper")
|
|
T.check(source:find(':take("hello")', 1, true) ~= nil,
|
|
"Tournament retrieves match hello without draining its tail")
|
|
T.check(source:find(":pollOne()", 1, true) ~= nil,
|
|
"Tournament processes handoff prefixes one packet at a time")
|
|
T.check(source:find("getStatus()", 1, true) ~= nil,
|
|
"Tournament uses the session lifecycle instead of raw terminal flags")
|
|
end
|
|
|
|
T.finish("link_session")
|