Scorekeeper: share a sheet between devices
A host can share its score sheet and others nearby can join it, found the same way games are, or by typed address over a hotspot. - A joining guest is not let in on its own say-so. The host is prompted with the guest's name and answers Allow or Deny. A device already let in that drops out (locked phone) is let straight back; the host can remove anyone. - The host's sheet is authoritative. What travels is what was done (SheetOp: add round, rename, move column, ...), not whole sheets, so changes made at the same moment all land. A guest's own changes show at once and are laid over the host's sheet until confirmed (SheetReplica). - Sync asks the host for the sheet as it stands, and runs on its own when the app returns to the front, as it does for a game table. - Hello carries a purpose and hosts advertise one, so a game join screen does not list score sheets and a game guest typing a sheet's address is told so. - The Scorekeeper screen now expresses every edit as a SheetOp, so one grid serves a local sheet, a hosted one and a joined one. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
16f9321ca3
commit
89b19a894a
@@ -0,0 +1,358 @@
|
||||
package org.prolibertate.games.net
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.net.ScorekeeperSession.Role
|
||||
import org.prolibertate.games.score.AddRound
|
||||
import org.prolibertate.games.score.RenamePlayer
|
||||
import org.prolibertate.games.score.ScoreSheet
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* A shared score sheet between real sessions, over real sockets on loopback.
|
||||
*
|
||||
* The sheet logic is covered on its own; what is worth testing here is the part
|
||||
* that only exists with a link in it — being asked before being let in, being
|
||||
* let back in without asking, and everybody ending up looking at one sheet.
|
||||
*/
|
||||
class ScorekeeperSessionTest {
|
||||
|
||||
private val scopes = mutableListOf<CoroutineScope>()
|
||||
private val sessions = mutableListOf<ScorekeeperSession>()
|
||||
private val transports = mutableListOf<LoopbackTransport>()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
sessions.forEach { runCatching { it.stop() } }
|
||||
transports.forEach { runCatching { it.stop() } }
|
||||
scopes.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
/** TCP on loopback, with the discovery a test does not need left out. */
|
||||
private class LoopbackTransport : Transport {
|
||||
override val kind = TransportKind.LAN
|
||||
|
||||
@Volatile
|
||||
var server: ServerSocket? = null
|
||||
val port: Int get() = server!!.localPort
|
||||
|
||||
override fun isAvailable() = true
|
||||
|
||||
override fun host(
|
||||
displayName: String,
|
||||
scope: CoroutineScope,
|
||||
purpose: HostPurpose,
|
||||
): Flow<Connection> = callbackFlow {
|
||||
val listening = ServerSocket(0)
|
||||
server = listening
|
||||
val accepting = scope.launch(Dispatchers.IO) {
|
||||
while (isActive && !listening.isClosed) {
|
||||
val socket = runCatching { listening.accept() }.getOrNull() ?: break
|
||||
trySend(
|
||||
StreamConnection(
|
||||
peerId = "guest:${socket.port}",
|
||||
kind = TransportKind.LAN,
|
||||
input = socket.getInputStream(),
|
||||
output = socket.getOutputStream(),
|
||||
scope = scope,
|
||||
onClosed = { runCatching { socket.close() } },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
awaitClose {
|
||||
accepting.cancel()
|
||||
runCatching { listening.close() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection {
|
||||
val socket = Socket("127.0.0.1", host.port)
|
||||
return StreamConnection(
|
||||
peerId = host.id,
|
||||
kind = TransportKind.LAN,
|
||||
input = socket.getInputStream(),
|
||||
output = socket.getOutputStream(),
|
||||
scope = scope,
|
||||
onClosed = { runCatching { socket.close() } },
|
||||
)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
runCatching { server?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun newSession(deviceId: String): Pair<ScorekeeperSession, LoopbackTransport> {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes += it }
|
||||
val transport = LoopbackTransport().also { transports += it }
|
||||
val session = ScorekeeperSession(transport, MutableStateFlow(null), scope, deviceId)
|
||||
.also { sessions += it }
|
||||
return session to transport
|
||||
}
|
||||
|
||||
private class Host(val session: ScorekeeperSession, val transport: LoopbackTransport) {
|
||||
val state get() = session.state.value
|
||||
}
|
||||
|
||||
private fun startHost(sheet: ScoreSheet = ScoreSheet.of(2)): Host {
|
||||
val (session, transport) = newSession("host-device")
|
||||
session.startHosting("Hosty", sheet)
|
||||
await("the host to be listening") { transport.server != null }
|
||||
return Host(session, transport)
|
||||
}
|
||||
|
||||
private fun joinOf(host: Host) = DiscoveredHost(
|
||||
id = "lan:127.0.0.1:${host.transport.port}",
|
||||
name = "Hosty",
|
||||
kind = TransportKind.LAN,
|
||||
address = "127.0.0.1",
|
||||
port = host.transport.port,
|
||||
purpose = HostPurpose.SCOREKEEPER,
|
||||
)
|
||||
|
||||
private fun guestOf(host: Host, deviceId: String, name: String): ScorekeeperSession {
|
||||
val (session, _) = newSession(deviceId)
|
||||
session.join(joinOf(host), name)
|
||||
return session
|
||||
}
|
||||
|
||||
/** A guest that has asked, been let in, and is looking at the host's sheet. */
|
||||
private fun admittedGuest(host: Host, deviceId: String, name: String): ScorekeeperSession {
|
||||
val guest = guestOf(host, deviceId, name)
|
||||
await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
|
||||
host.session.allow(deviceId)
|
||||
await("$name to be let in") { guest.state.value.connected }
|
||||
return guest
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guest is asked about, and sees nothing, until the host allows it`() {
|
||||
val host = startHost()
|
||||
val guest = guestOf(host, "g1", "Gwen")
|
||||
|
||||
await("the request to reach the host") { host.state.requests.isNotEmpty() }
|
||||
assertEquals("Gwen", host.state.requests.single().name)
|
||||
assertTrue(guest.state.value.awaiting)
|
||||
assertFalse(guest.state.value.connected)
|
||||
assertFalse(guest.state.value.sheet.started)
|
||||
assertTrue(host.state.guests.isEmpty())
|
||||
|
||||
host.session.allow("g1")
|
||||
|
||||
await("the guest to be let in") { guest.state.value.connected }
|
||||
assertFalse(guest.state.value.awaiting)
|
||||
assertEquals(host.state.sheet, guest.state.value.sheet)
|
||||
assertTrue(guest.state.value.sheet.started)
|
||||
assertTrue(host.state.requests.isEmpty())
|
||||
assertEquals(listOf("Gwen"), host.state.guests.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guest that is turned away stays turned away`() {
|
||||
val host = startHost()
|
||||
val guest = guestOf(host, "g1", "Gwen")
|
||||
await("the request to reach the host") { host.state.requests.isNotEmpty() }
|
||||
|
||||
host.session.deny("g1")
|
||||
|
||||
await("the refusal to arrive") { guest.state.value.refused }
|
||||
assertEquals("The host declined.", guest.state.value.message)
|
||||
assertFalse(guest.state.value.connected)
|
||||
assertFalse(guest.state.value.sheet.started)
|
||||
assertTrue(host.state.guests.isEmpty())
|
||||
assertTrue(host.state.requests.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guest's change reaches the host and the other guests`() {
|
||||
val host = startHost()
|
||||
val first = admittedGuest(host, "g1", "Gwen")
|
||||
val second = admittedGuest(host, "g2", "Gus")
|
||||
val player = host.state.sheet.players[0].id
|
||||
|
||||
first.edit(AddRound(mapOf(player to 7)))
|
||||
|
||||
// Shown at once on the guest that made it, without waiting for the host.
|
||||
assertEquals(7, first.state.value.sheet.total(player))
|
||||
await("the host to apply it") { host.state.sheet.total(player) == 7 }
|
||||
await("the other guest to see it") { second.state.value.sheet.total(player) == 7 }
|
||||
assertEquals(1, host.state.sheet.rounds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the host's own change reaches the guests`() {
|
||||
val host = startHost()
|
||||
val guest = admittedGuest(host, "g1", "Gwen")
|
||||
val player = host.state.sheet.players[1].id
|
||||
|
||||
host.session.edit(RenamePlayer(player, "Hosty"))
|
||||
|
||||
await("the guest to see the new name") {
|
||||
guest.state.value.sheet.players[1].name == "Hosty"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changes made at the same moment all land`() {
|
||||
val host = startHost()
|
||||
val first = admittedGuest(host, "g1", "Gwen")
|
||||
val second = admittedGuest(host, "g2", "Gus")
|
||||
val player = host.state.sheet.players[0].id
|
||||
|
||||
first.edit(AddRound(mapOf(player to 1)))
|
||||
second.edit(AddRound(mapOf(player to 10)))
|
||||
host.session.edit(AddRound(mapOf(player to 100)))
|
||||
|
||||
await("every round to land everywhere") {
|
||||
listOf(host.state.sheet, first.state.value.sheet, second.state.value.sheet)
|
||||
.all { it.rounds.size == 3 && it.total(player) == 111 }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guest asking to sync is sent the sheet as it stands`() {
|
||||
val host = startHost()
|
||||
val guest = admittedGuest(host, "g1", "Gwen")
|
||||
val player = host.state.sheet.players[0].id
|
||||
host.session.edit(AddRound(mapOf(player to 4)))
|
||||
|
||||
guest.sync()
|
||||
|
||||
await("the guest to be in step") { guest.state.value.sheet.total(player) == 4 }
|
||||
assertEquals(host.state.sheet, guest.state.value.sheet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guest that was let in is let back in without being asked again`() {
|
||||
val host = startHost()
|
||||
val first = admittedGuest(host, "g1", "Gwen")
|
||||
// The phone is put down and its link goes with it.
|
||||
first.stop()
|
||||
await("the host to notice") { host.state.guests.singleOrNull()?.online == false }
|
||||
|
||||
val back = guestOf(host, "g1", "Gwen")
|
||||
|
||||
await("the guest to be back on the sheet") { back.state.value.connected }
|
||||
assertTrue(host.state.requests.isEmpty())
|
||||
assertEquals(1, host.state.guests.size)
|
||||
assertTrue(host.state.guests.single().online)
|
||||
assertEquals(host.state.sheet, back.state.value.sheet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guest the host removes has to ask again`() {
|
||||
val host = startHost()
|
||||
val guest = admittedGuest(host, "g1", "Gwen")
|
||||
|
||||
host.session.remove("g1")
|
||||
|
||||
await("the removal to arrive") { guest.state.value.refused }
|
||||
assertEquals("The host removed you.", guest.state.value.message)
|
||||
assertTrue(host.state.guests.isEmpty())
|
||||
|
||||
val again = guestOf(host, "g1", "Gwen")
|
||||
await("the new request") { host.state.requests.any { it.id == "g1" } }
|
||||
assertFalse(again.state.value.connected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `somebody who is not let in cannot change the sheet`() {
|
||||
val host = startHost()
|
||||
val (_, transport) = newSession("stranger")
|
||||
val before = host.state.sheet
|
||||
val player = before.players[0].id
|
||||
|
||||
runBlocking {
|
||||
val raw = transport.join(joinOf(host), CoroutineScope(Dispatchers.IO).also { scopes += it })
|
||||
val hello = Hello("stranger", "Stan", purpose = PURPOSE_SCOREKEEPER)
|
||||
// Said more than once, as a real guest does: the host may not be
|
||||
// listening yet the first time. Saying it twice is harmless.
|
||||
repeat(3) {
|
||||
raw.send(hello)
|
||||
delay(150)
|
||||
}
|
||||
raw.send(SheetEdit(1, AddRound(mapOf(player to 99))))
|
||||
}
|
||||
|
||||
await("the request to reach the host") { host.state.requests.any { it.id == "stranger" } }
|
||||
Thread.sleep(300)
|
||||
assertEquals(before, host.state.sheet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone looking for a game is told this is a score sheet`() {
|
||||
val host = startHost()
|
||||
val (_, transport) = newSession("gamer")
|
||||
val answer = AtomicInteger(0)
|
||||
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(Dispatchers.IO).also { scopes += it }
|
||||
val raw = transport.join(joinOf(host), scope)
|
||||
val reply = scope.launch {
|
||||
raw.incoming
|
||||
.onSubscription { raw.send(Hello("gamer", "Gail", purpose = PURPOSE_GAME)) }
|
||||
.collect { message ->
|
||||
if (message is Welcome && !message.accepted) {
|
||||
assertEquals("That is a score sheet, not a game.", message.reason)
|
||||
answer.set(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
val hello = Hello("gamer", "Gail", purpose = PURPOSE_GAME)
|
||||
val end = System.currentTimeMillis() + 5_000
|
||||
while (answer.get() == 0 && System.currentTimeMillis() < end) {
|
||||
delay(300)
|
||||
if (answer.get() == 0) raw.send(hello)
|
||||
}
|
||||
reply.cancel()
|
||||
}
|
||||
|
||||
assertEquals(1, answer.get())
|
||||
assertTrue(host.state.requests.isEmpty())
|
||||
assertEquals(Role.HOST, host.state.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopping hosting lets go of everybody`() {
|
||||
val host = startHost()
|
||||
val guest = admittedGuest(host, "g1", "Gwen")
|
||||
|
||||
host.session.stop()
|
||||
|
||||
assertEquals(Role.NONE, host.state.role)
|
||||
assertTrue(host.state.guests.isEmpty())
|
||||
await("the guest to notice") { !guest.state.value.connected }
|
||||
}
|
||||
|
||||
private fun await(what: String, timeoutMillis: Long = 5_000, condition: () -> Boolean) {
|
||||
val end = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < end) {
|
||||
if (condition()) return
|
||||
Thread.sleep(25)
|
||||
}
|
||||
fail("Timed out waiting for $what")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.net.Hello
|
||||
import org.prolibertate.games.net.NetMessage
|
||||
import org.prolibertate.games.net.PURPOSE_GAME
|
||||
import org.prolibertate.games.net.SheetEdit
|
||||
import org.prolibertate.games.net.SheetState
|
||||
import org.prolibertate.games.net.protocolJson
|
||||
|
||||
class SheetOpTest {
|
||||
|
||||
private val table = ScoreSheet.of(3)
|
||||
private val ids = table.players.map { it.id }
|
||||
|
||||
@Test
|
||||
fun `every op does what the sheet method of the same name does`() {
|
||||
assertEquals(table.withPlayerAdded(), AddPlayer.applyTo(table))
|
||||
assertEquals(table.renamed(ids[1], "Bob"), RenamePlayer(ids[1], "Bob").applyTo(table))
|
||||
assertEquals(table.withPlayerRemoved(ids[2]), RemovePlayer(ids[2]).applyTo(table))
|
||||
assertEquals(table.moved(0, 2), MovePlayer(ids[0], 2).applyTo(table))
|
||||
assertEquals(ScoreSheet.of(5), StartSheet(5).applyTo(table))
|
||||
assertEquals(ScoreSheet(), ClearSheet.applyTo(table))
|
||||
|
||||
val scored = AddRound(mapOf(ids[0] to 4)).applyTo(table)
|
||||
assertEquals(table.withRound(mapOf(ids[0] to 4)), scored)
|
||||
assertEquals(4, EditRound(0, mapOf(ids[0] to 4)).applyTo(AddRound(mapOf(ids[0] to 1)).applyTo(table)).total(ids[0]))
|
||||
assertTrue(DeleteRound(0).applyTo(scored).rounds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an op that no longer makes sense is a no-op`() {
|
||||
val scored = AddRound(mapOf(ids[0] to 4)).applyTo(table)
|
||||
|
||||
assertEquals(scored, RenamePlayer(999, "Nobody").applyTo(scored))
|
||||
assertEquals(scored, MovePlayer(999, 0).applyTo(scored))
|
||||
assertEquals(scored, RemovePlayer(999).applyTo(scored))
|
||||
assertEquals(scored, EditRound(7, mapOf(ids[0] to 1)).applyTo(scored))
|
||||
assertEquals(scored, DeleteRound(7).applyTo(scored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a column goes by who it is, not where it was`() {
|
||||
// Somebody else has already moved the column this guest was looking at.
|
||||
val shuffled = table.moved(0, 2)
|
||||
val moved = MovePlayer(ids[0], 0).applyTo(shuffled)
|
||||
assertEquals(ids, moved.players.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two rounds entered at once are two rounds`() {
|
||||
val both = listOf(
|
||||
AddRound(mapOf(ids[0] to 3)),
|
||||
AddRound(mapOf(ids[1] to 5)),
|
||||
).fold(table) { sheet, op -> op.applyTo(sheet) }
|
||||
|
||||
assertEquals(2, both.rounds.size)
|
||||
assertEquals(3, both.total(ids[0]))
|
||||
assertEquals(5, both.total(ids[1]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every op survives the wire`() {
|
||||
val ops = listOf(
|
||||
StartSheet(4), ClearSheet, AddPlayer, RemovePlayer(2), RenamePlayer(1, "Ann"),
|
||||
MovePlayer(1, 3), AddRound(mapOf(1 to 5, 2 to -3, 3 to 0)),
|
||||
EditRound(0, mapOf(1 to 9)), DeleteRound(2),
|
||||
)
|
||||
ops.forEach { op ->
|
||||
val message: NetMessage = SheetEdit(seq = 7, op = op)
|
||||
val decoded = protocolJson.decodeFromString<NetMessage>(protocolJson.encodeToString(message))
|
||||
assertEquals(message, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sheet survives the wire with its rounds`() {
|
||||
val sheet = AddRound(mapOf(ids[0] to 3, ids[2] to -2)).applyTo(table)
|
||||
val message: NetMessage = SheetState(revision = 4, sheet = sheet, acknowledged = 2)
|
||||
assertEquals(message, protocolJson.decodeFromString<NetMessage>(protocolJson.encodeToString(message)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hello from before there were score sheets is a hello for a game`() {
|
||||
val old = """{"t":"hello","peerId":"p","displayName":"Ann","protocol":2}"""
|
||||
val hello = protocolJson.decodeFromString<NetMessage>(old) as Hello
|
||||
assertEquals(PURPOSE_GAME, hello.purpose)
|
||||
}
|
||||
}
|
||||
|
||||
class SheetReplicaTest {
|
||||
|
||||
private val base = ScoreSheet.of(2)
|
||||
private val a = base.players[0].id
|
||||
private val b = base.players[1].id
|
||||
|
||||
@Test
|
||||
fun `a change shows at once`() {
|
||||
val replica = SheetReplica()
|
||||
replica.receive(base, revision = 1, acknowledged = 0)
|
||||
|
||||
replica.local(AddRound(mapOf(a to 5)))
|
||||
|
||||
assertEquals(5, replica.view.total(a))
|
||||
assertEquals(1, replica.unconfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sheet that has not seen my change does not wipe it out`() {
|
||||
val replica = SheetReplica()
|
||||
replica.receive(base, 1, 0)
|
||||
replica.local(AddRound(mapOf(a to 5)))
|
||||
|
||||
// The host pushes a rename it made before it had heard about the round.
|
||||
replica.receive(base.renamed(b, "Bob"), 2, 0)
|
||||
|
||||
assertEquals("Bob", replica.view.players[1].name)
|
||||
assertEquals(5, replica.view.total(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change the host has applied is not applied again`() {
|
||||
val replica = SheetReplica()
|
||||
replica.receive(base, 1, 0)
|
||||
val seq = replica.local(AddRound(mapOf(a to 5)))
|
||||
|
||||
// The host's sheet now includes the round, and says so.
|
||||
replica.receive(base.withRound(mapOf(a to 5)), 2, seq)
|
||||
|
||||
assertEquals(5, replica.view.total(a))
|
||||
assertEquals(1, replica.view.rounds.size)
|
||||
assertEquals(0, replica.unconfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the acknowledged changes are dropped`() {
|
||||
val replica = SheetReplica()
|
||||
replica.receive(base, 1, 0)
|
||||
val first = replica.local(AddRound(mapOf(a to 1)))
|
||||
replica.local(AddRound(mapOf(a to 10)))
|
||||
|
||||
replica.receive(base.withRound(mapOf(a to 1)), 2, first)
|
||||
|
||||
assertEquals(1, replica.unconfirmed)
|
||||
assertEquals(11, replica.view.total(a))
|
||||
assertEquals(2, replica.view.rounds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an older sheet that arrives late is ignored`() {
|
||||
val replica = SheetReplica()
|
||||
replica.receive(base.withRound(mapOf(a to 3)), 5, 0)
|
||||
replica.receive(base, 4, 0)
|
||||
|
||||
assertEquals(3, replica.view.total(a))
|
||||
assertEquals(5, replica.revision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh link starts from nothing`() {
|
||||
val replica = SheetReplica()
|
||||
replica.receive(base, 3, 0)
|
||||
replica.local(AddRound(mapOf(a to 5)))
|
||||
|
||||
replica.reset()
|
||||
|
||||
assertEquals(ScoreSheet(), replica.view)
|
||||
assertEquals(0, replica.unconfirmed)
|
||||
// And a sheet at an early revision is taken rather than thought stale.
|
||||
replica.receive(base, 1, 0)
|
||||
assertEquals(base, replica.view)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user