extract projection snapshot logic

implement GameStateRepositoryTest
add lambda to the GameEventHandler.handle{} to set the version

add VersionBuilder
add version to the events
add creation date to the events
rename gameId to aggregateId
add EventHandler interface
This commit is contained in:
2025-03-13 00:27:44 +01:00
parent d5b033e731
commit 286dedac76
36 changed files with 684 additions and 266 deletions

View File

@@ -18,7 +18,7 @@ class GameCommandRunner(
command: GameCommand, command: GameCommand,
outgoingErrorChannelNotification: SendChannel<Notification>, outgoingErrorChannelNotification: SendChannel<Notification>,
) { ) {
val gameState = gameStateRepository.get(command.payload.gameId) val gameState = gameStateRepository.getLast(command.payload.aggregateId)
val errorNotifier = errorNotifier(command, outgoingErrorChannelNotification) val errorNotifier = errorNotifier(command, outgoingErrorChannelNotification)
when (command) { when (command) {

View File

@@ -11,7 +11,7 @@ sealed interface GameCommand : Command {
@Serializable @Serializable
sealed interface Payload { sealed interface Payload {
val gameId: GameId val aggregateId: GameId
val player: Player val player: Player
} }
} }

View File

@@ -20,7 +20,7 @@ data class ICantPlayCommand(
@Serializable @Serializable
data class Payload( data class Payload(
override val gameId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
) : GameCommand.Payload ) : GameCommand.Payload
@@ -37,13 +37,14 @@ data class ICantPlayCommand(
if (playableCards.isEmpty()) { if (playableCards.isEmpty()) {
val takenCard = state.deck.stack.first() val takenCard = state.deck.stack.first()
eventHandler.handle( eventHandler.handle {
PlayerHavePassEvent( PlayerHavePassEvent(
gameId = payload.gameId, aggregateId = payload.aggregateId,
player = payload.player, player = payload.player,
takenCard = takenCard, takenCard = takenCard,
), version = it,
) )
}
} else { } else {
playerErrorNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}") playerErrorNotifier("You can and must play one card, like ${playableCards.first()::class.simpleName}")
} }

View File

@@ -20,7 +20,7 @@ data class IWantToJoinTheGameCommand(
@Serializable @Serializable
data class Payload( data class Payload(
override val gameId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
) : GameCommand.Payload ) : GameCommand.Payload
@@ -30,12 +30,13 @@ data class IWantToJoinTheGameCommand(
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
) { ) {
if (!state.isStarted) { if (!state.isStarted) {
eventHandler.handle( eventHandler.handle {
NewPlayerEvent( NewPlayerEvent(
payload.gameId, aggregateId = payload.aggregateId,
payload.player, player = payload.player,
), version = it,
) )
}
} else { } else {
playerErrorNotifier("The game is already started") playerErrorNotifier("The game is already started")
} }

View File

@@ -21,7 +21,7 @@ data class IWantToPlayCardCommand(
@Serializable @Serializable
data class Payload( data class Payload(
override val gameId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
val card: Card, val card: Card,
) : GameCommand.Payload ) : GameCommand.Payload
@@ -41,13 +41,14 @@ data class IWantToPlayCardCommand(
} }
if (state.canBePlayThisCard(payload.player, payload.card)) { if (state.canBePlayThisCard(payload.player, payload.card)) {
eventHandler.handle( eventHandler.handle {
CardIsPlayedEvent( CardIsPlayedEvent(
payload.gameId, aggregateId = payload.aggregateId,
payload.card, card = payload.card,
payload.player, player = payload.player,
), version = it,
) )
}
} else { } else {
playerErrorNotifier("You cannot play this card") playerErrorNotifier("You cannot play this card")
} }

View File

@@ -20,7 +20,7 @@ data class IamReadyToPlayCommand(
@Serializable @Serializable
data class Payload( data class Payload(
override val gameId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
) : GameCommand.Payload ) : GameCommand.Payload
@@ -39,12 +39,13 @@ data class IamReadyToPlayCommand(
} else if (playerIsAlreadyReady) { } else if (playerIsAlreadyReady) {
playerErrorNotifier("You are already ready") playerErrorNotifier("You are already ready")
} else { } else {
eventHandler.handle( eventHandler.handle {
PlayerReadyEvent( PlayerReadyEvent(
payload.gameId, aggregateId = payload.aggregateId,
payload.player, player = payload.player,
), version = it,
) )
} }
} }
}
} }

View File

@@ -0,0 +1,13 @@
package eventDemo.app.event
import eventDemo.libs.event.AggregateId
import eventDemo.libs.event.Event
/**
* A stream to publish and read the played card event.
*/
interface EventHandler<E : Event<ID>, ID : AggregateId> {
fun registerProjectionBuilder(builder: (E) -> Unit)
fun handle(buildEvent: (version: Int) -> E): E
}

View File

@@ -1,6 +1,8 @@
package eventDemo.app.event package eventDemo.app.event
import eventDemo.app.entity.GameId
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import eventDemo.libs.event.VersionBuilder
/** /**
* A stream to publish and read the played card event. * A stream to publish and read the played card event.
@@ -8,20 +10,20 @@ import eventDemo.app.event.event.GameEvent
class GameEventHandler( class GameEventHandler(
private val eventBus: GameEventBus, private val eventBus: GameEventBus,
private val eventStream: GameEventStream, private val eventStream: GameEventStream,
) { private val versionBuilder: VersionBuilder,
) : EventHandler<GameEvent, GameId> {
private val projectionsBuilders: MutableList<(GameEvent) -> Unit> = mutableListOf() private val projectionsBuilders: MutableList<(GameEvent) -> Unit> = mutableListOf()
fun registerProjectionBuilder(builder: GameProjectionBuilder) { override fun registerProjectionBuilder(builder: GameProjectionBuilder) {
projectionsBuilders.add(builder) projectionsBuilders.add(builder)
} }
fun handle(vararg events: GameEvent) { override fun handle(buildEvent: (version: Int) -> GameEvent): GameEvent =
events.forEach { event -> buildEvent(versionBuilder.buildNextVersion()).also { event ->
eventStream.publish(event) eventStream.publish(event)
projectionsBuilders.forEach { it(event) } projectionsBuilders.forEach { it(event) }
eventBus.publish(event) eventBus.publish(event)
} }
}
} }
typealias GameProjectionBuilder = (GameEvent) -> Unit typealias GameProjectionBuilder = (GameEvent) -> Unit

View File

@@ -3,15 +3,20 @@ package eventDemo.app.event.event
import eventDemo.app.entity.Card import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
* An [GameEvent] to represent a played card. * An [GameEvent] to represent a played card.
*/ */
data class CardIsPlayedEvent( data class CardIsPlayedEvent(
override val gameId: GameId, override val aggregateId: GameId,
val card: Card, val card: Card,
override val player: Player, override val player: Player,
override val eventId: UUID = UUID.randomUUID(), override val version: Int,
) : GameEvent, ) : GameEvent,
PlayerActionEvent PlayerActionEvent {
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}

View File

@@ -11,5 +11,6 @@ import java.util.UUID
@Serializable @Serializable
sealed interface GameEvent : Event<GameId> { sealed interface GameEvent : Event<GameId> {
override val eventId: UUID override val eventId: UUID
override val gameId: GameId override val aggregateId: GameId
override val version: Int
} }

View File

@@ -4,26 +4,31 @@ import eventDemo.app.entity.Deck
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import eventDemo.app.entity.initHands import eventDemo.app.entity.initHands
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
* This [GameEvent] is sent when all players are ready. * This [GameEvent] is sent when all players are ready.
*/ */
data class GameStartedEvent( data class GameStartedEvent(
override val gameId: GameId, override val aggregateId: GameId,
val firstPlayer: Player, val firstPlayer: Player,
val deck: Deck, val deck: Deck,
override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
companion object { companion object {
fun new( fun new(
id: GameId, id: GameId,
players: Set<Player>, players: Set<Player>,
shuffleIsDisabled: Boolean = isDisabled, shuffleIsDisabled: Boolean = isDisabled,
version: Int,
): GameStartedEvent = ): GameStartedEvent =
GameStartedEvent( GameStartedEvent(
gameId = id, aggregateId = id,
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(), firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
deck = deck =
Deck Deck
@@ -31,6 +36,7 @@ data class GameStartedEvent(
.let { if (shuffleIsDisabled) it else it.shuffle() } .let { if (shuffleIsDisabled) it else it.shuffle() }
.initHands(players) .initHands(players)
.placeFirstCardOnDiscard(), .placeFirstCardOnDiscard(),
version = version,
) )
} }
} }

View File

@@ -2,14 +2,18 @@ package eventDemo.app.event.event
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
* An [GameEvent] to represent a new player joining the game. * An [GameEvent] to represent a new player joining the game.
*/ */
data class NewPlayerEvent( data class NewPlayerEvent(
override val gameId: GameId, override val aggregateId: GameId,
val player: Player, val player: Player,
override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -2,6 +2,6 @@ package eventDemo.app.event.event
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
sealed interface PlayerActionEvent { sealed interface PlayerActionEvent : GameEvent {
val player: Player val player: Player
} }

View File

@@ -3,16 +3,20 @@ package eventDemo.app.event.event
import eventDemo.app.entity.Card import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
* This [GameEvent] is sent when a player chose a color. * This [GameEvent] is sent when a player chose a color.
*/ */
data class PlayerChoseColorEvent( data class PlayerChoseColorEvent(
override val gameId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
val color: Card.Color, val color: Card.Color,
override val version: Int,
) : GameEvent, ) : GameEvent,
PlayerActionEvent { PlayerActionEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -3,16 +3,20 @@ package eventDemo.app.event.event
import eventDemo.app.entity.Card import eventDemo.app.entity.Card
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
* This [GameEvent] is sent when a player can play. * This [GameEvent] is sent when a player can play.
*/ */
data class PlayerHavePassEvent( data class PlayerHavePassEvent(
override val gameId: GameId, override val aggregateId: GameId,
override val player: Player, override val player: Player,
val takenCard: Card, val takenCard: Card,
override val version: Int,
) : GameEvent, ) : GameEvent,
PlayerActionEvent { PlayerActionEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -2,14 +2,18 @@ package eventDemo.app.event.event
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
* This [GameEvent] is sent when a player is ready. * This [GameEvent] is sent when a player is ready.
*/ */
data class PlayerReadyEvent( data class PlayerReadyEvent(
override val gameId: GameId, override val aggregateId: GameId,
val player: Player, val player: Player,
override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -2,14 +2,18 @@ package eventDemo.app.event.event
import eventDemo.app.entity.GameId import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player import eventDemo.app.entity.Player
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
* This [GameEvent] is sent when a player is ready. * This [GameEvent] is sent when a player is ready.
*/ */
data class PlayerWinEvent( data class PlayerWinEvent(
override val gameId: GameId, override val aggregateId: GameId,
val player: Player, val player: Player,
override val version: Int,
) : GameEvent { ) : GameEvent {
override val eventId: UUID = UUID.randomUUID() override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
} }

View File

@@ -8,7 +8,8 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GameState( data class GameState(
val gameId: GameId, override val aggregateId: GameId,
override val lastEventVersion: Int = 0,
val players: Set<Player> = emptySet(), val players: Set<Player> = emptySet(),
val currentPlayerTurn: Player? = null, val currentPlayerTurn: Player? = null,
val cardOnCurrentStack: LastCard? = null, val cardOnCurrentStack: LastCard? = null,
@@ -18,7 +19,7 @@ data class GameState(
val deck: Deck = Deck(players), val deck: Deck = Deck(players),
val isStarted: Boolean = false, val isStarted: Boolean = false,
val playerWins: Set<Player> = emptySet(), val playerWins: Set<Player> = emptySet(),
) { ) : Projection<GameId> {
@Serializable @Serializable
data class LastCard( data class LastCard(
val card: Card, val card: Card,

View File

@@ -23,27 +23,28 @@ fun GameId.buildStateFromEventStream(eventStream: GameEventStream): GameState {
} }
fun Collection<GameEvent>.buildStateFromEvents(): GameState { fun Collection<GameEvent>.buildStateFromEvents(): GameState {
val gameId = this.firstOrNull()?.gameId ?: error("Cannot build GameState from an empty list") val gameId = this.firstOrNull()?.aggregateId ?: error("Cannot build GameState from an empty list")
return fold(GameState(gameId)) { state, event -> return fold(GameState(gameId)) { state, event ->
state.apply(event) state.apply(event)
} }
} }
fun GameState.apply(event: GameEvent): GameState = fun GameState?.apply(event: GameEvent): GameState =
let { state -> (this ?: GameState(event.aggregateId)).let { state ->
val logger = KotlinLogging.logger { } val logger = KotlinLogging.logger { }
if (event is PlayerActionEvent) { if (event is PlayerActionEvent) {
if (state.currentPlayerTurn != event.player) { if (state.currentPlayerTurn != event.player) {
logger.atError { logger.atError {
message = "Inconsistent player turn. CurrentPlayerTurn: $currentPlayerTurn | Player: ${event.player}" message = "Inconsistent player turn. CurrentPlayerTurn: $state.currentPlayerTurn | Player: ${event.player}"
payload = payload =
mapOf( mapOf(
"CurrentPlayerTurn" to (currentPlayerTurn ?: "No currentPlayerTurn"), "CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
"Player" to event.player, "Player" to event.player,
) )
} }
} }
} }
when (event) { when (event) {
is CardIsPlayedEvent -> { is CardIsPlayedEvent -> {
val nextDirectionAfterPlay = val nextDirectionAfterPlay =
@@ -60,9 +61,9 @@ fun GameState.apply(event: GameEvent): GameState =
val currentPlayerAfterThePlay = val currentPlayerAfterThePlay =
if (event.card is Card.AllColorCard) { if (event.card is Card.AllColorCard) {
currentPlayerTurn state.currentPlayerTurn
} else { } else {
nextPlayer(nextDirectionAfterPlay) state.nextPlayer(nextDirectionAfterPlay)
} }
state.copy( state.copy(
@@ -98,14 +99,14 @@ fun GameState.apply(event: GameEvent): GameState =
logger.error { "taken card is not ot top of the stack: ${event.takenCard}" } logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
} }
state.copy( state.copy(
currentPlayerTurn = nextPlayerTurn, currentPlayerTurn = state.nextPlayerTurn,
deck = state.deck.takeOneCardFromStackTo(event.player), deck = state.deck.takeOneCardFromStackTo(event.player),
) )
} }
is PlayerChoseColorEvent -> { is PlayerChoseColorEvent -> {
state.copy( state.copy(
currentPlayerTurn = nextPlayerTurn, currentPlayerTurn = state.nextPlayerTurn,
colorOnCurrentStack = event.color, colorOnCurrentStack = event.color,
) )
} }
@@ -121,9 +122,11 @@ fun GameState.apply(event: GameEvent): GameState =
} }
is PlayerWinEvent -> { is PlayerWinEvent -> {
copy( state.copy(
playerWins = playerWins + event.player, playerWins = state.playerWins + event.player,
) )
} }
} }.copy(
lastEventVersion = event.version,
)
} }

View File

@@ -4,73 +4,32 @@ import eventDemo.app.entity.GameId
import eventDemo.app.event.GameEventHandler import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.GameEventStream import eventDemo.app.event.GameEventStream
import eventDemo.app.event.event.GameEvent import eventDemo.app.event.event.GameEvent
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
class GameStateRepository( class GameStateRepository(
private val eventStream: GameEventStream, private val eventStream: GameEventStream,
eventHandler: GameEventHandler, eventHandler: GameEventHandler,
private val maxSnapshotCacheSize: Int = 20, maxSnapshotCacheSize: Int = 20,
) { ) {
private val projections: ConcurrentHashMap<GameId, GameState> = ConcurrentHashMap() private val projectionsSnapshot =
private val version: AtomicInteger = AtomicInteger(0) ProjectionSnapshotRepositoryInMemory(
private val projectionsSnapshot: ConcurrentHashMap<GameEvent, GameState> = ConcurrentHashMap() applyToProjection = GameState?::apply,
private val sortedSnapshotByVersion: ConcurrentHashMap<GameEvent, Int> = ConcurrentHashMap() maxSnapshotCacheSize = maxSnapshotCacheSize,
)
init { init {
eventHandler.registerProjectionBuilder { event -> eventHandler.registerProjectionBuilder { event ->
val projection = projections[event.gameId] projectionsSnapshot.applyAndPutToCache(event)
if (projection == null) {
event
.buildStateFromEventStreamTo(eventStream)
.update()
} else {
projection
.apply(event)
.also { projections[it.gameId] = it }
.also { state ->
val newVersion = version.addAndGet(1)
saveSnapshot(event, state, newVersion)
removeOldSnapshot()
} }
} }
}
}
private fun removeOldSnapshot() {
if (projectionsSnapshot.size > maxSnapshotCacheSize) {
val numberToRemove = projectionsSnapshot.size - maxSnapshotCacheSize
sortedSnapshotByVersion
.toList()
.sortedBy { it.second }
.take(numberToRemove)
.toMap()
.keys
.forEach { event ->
sortedSnapshotByVersion.remove(event)
projectionsSnapshot.remove(event)
}
}
}
private fun saveSnapshot(
event: GameEvent,
state: GameState,
newVersion: Int,
) {
projectionsSnapshot[event] = state
sortedSnapshotByVersion[event] = newVersion
}
/** /**
* Get the last version of the [GameState] from the all eventStream. * Get the last version of the [GameState] from the all eventStream.
* *
* It fetches it from the local cache if possible, otherwise it builds it. * It fetches it from the local cache if possible, otherwise it builds it.
*/ */
fun get(gameId: GameId): GameState = fun getLast(gameId: GameId): GameState =
projections.computeIfAbsent(gameId) { projectionsSnapshot.getLast(gameId)
gameId.buildStateFromEventStream(eventStream) ?: gameId.buildStateFromEventStream(eventStream)
}
/** /**
* Get the [GameState] to the specific [event][GameEvent]. * Get the [GameState] to the specific [event][GameEvent].
@@ -79,17 +38,7 @@ class GameStateRepository(
* It fetches it from the local cache if possible, otherwise it builds it. * It fetches it from the local cache if possible, otherwise it builds it.
*/ */
fun getUntil(event: GameEvent): GameState = fun getUntil(event: GameEvent): GameState =
projectionsSnapshot.computeIfAbsent(event) { projectionsSnapshot.getUntil(event)
event.buildStateFromEventStreamTo(eventStream) ?: (eventStream.readAll(event.aggregateId).takeWhile { it != event } + event)
} .buildStateFromEvents()
private fun GameState.update() {
projections[gameId] = this
}
/**
* Build the state to the specific event
*/
private fun GameEvent.buildStateFromEventStreamTo(eventStream: GameEventStream): GameState =
run { eventStream.readAll(gameId).takeWhile { it != this } + this }.buildStateFromEvents()
} }

View File

@@ -0,0 +1,8 @@
package eventDemo.app.event.projection
import eventDemo.libs.event.AggregateId
interface Projection<ID : AggregateId> {
val aggregateId: ID
val lastEventVersion: Int
}

View File

@@ -0,0 +1,54 @@
package eventDemo.app.event.projection
import eventDemo.libs.event.AggregateId
import eventDemo.libs.event.Event
import java.util.concurrent.ConcurrentHashMap
class ProjectionSnapshotRepositoryInMemory<E : Event<ID>, P : Projection<ID>, ID : AggregateId>(
private val maxSnapshotCacheSize: Int = 20,
private val applyToProjection: P?.(event: E) -> P,
) {
private val projectionsSnapshot: ConcurrentHashMap<E, P> = ConcurrentHashMap()
fun applyAndPutToCache(event: E): P {
// lock here
return projectionsSnapshot
.filterKeys { it.aggregateId == event.aggregateId }
.toList()
.find { (e, _) -> e.version == (event.version - 1) }
?.second
.applyToProjection(event)
.also { projectionsSnapshot.put(event, it) }
.also { removeOldSnapshot() }
// Unlock here
}
private fun removeOldSnapshot() {
if (projectionsSnapshot.size > maxSnapshotCacheSize) {
val numberToRemove = projectionsSnapshot.size - maxSnapshotCacheSize
projectionsSnapshot
.keys
.sortedBy { it.version }
.take(numberToRemove)
.forEach { event ->
projectionsSnapshot.remove(event)
}
}
}
/**
* Get the last version of the [Projection] from the cache.
*/
fun getLast(aggregateId: ID): P? =
projectionsSnapshot
.filter { it.key.aggregateId == aggregateId }
.maxByOrNull { (event, _) -> event.version }
?.value
/**
* Get the [Projection] to the specific [event][Event].
* It does not contain the [events][Event] it after this one.
*/
fun getUntil(event: E): P? = projectionsSnapshot.get(event)
}

View File

@@ -36,10 +36,13 @@ class ReactionEventListener(
) { ) {
if (state.isReady && !state.isStarted) { if (state.isReady && !state.isStarted) {
val reactionEvent = val reactionEvent =
eventHandler.handle {
GameStartedEvent.new( GameStartedEvent.new(
state.gameId, id = state.aggregateId,
state.players, players = state.players,
version = it,
) )
}
logger.atInfo { logger.atInfo {
message = "Reaction event was Send $reactionEvent on reaction of: $event" message = "Reaction event was Send $reactionEvent on reaction of: $event"
payload = payload =
@@ -48,7 +51,6 @@ class ReactionEventListener(
"reactionEvent" to reactionEvent, "reactionEvent" to reactionEvent,
) )
} }
eventHandler.handle(reactionEvent)
} else { } else {
if (event is PlayerReadyEvent) { if (event is PlayerReadyEvent) {
logger.info { "All players was not ready ${state.readyPlayers}" } logger.info { "All players was not ready ${state.readyPlayers}" }
@@ -63,10 +65,14 @@ class ReactionEventListener(
val winner = state.playerHasNoCardLeft().firstOrNull() val winner = state.playerHasNoCardLeft().firstOrNull()
if (winner != null) { if (winner != null) {
val reactionEvent = val reactionEvent =
eventHandler.handle {
PlayerWinEvent( PlayerWinEvent(
state.gameId, aggregateId = state.aggregateId,
winner, player = winner,
version = it,
) )
}
logger.atInfo { logger.atInfo {
message = "Reaction event was Send $reactionEvent on reaction of: $event" message = "Reaction event was Send $reactionEvent on reaction of: $event"
payload = payload =
@@ -75,7 +81,6 @@ class ReactionEventListener(
"reactionEvent" to reactionEvent, "reactionEvent" to reactionEvent,
) )
} }
eventHandler.handle(reactionEvent)
} }
} }
} }

View File

@@ -39,7 +39,7 @@ fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
// Read the last played card on the game. // Read the last played card on the game.
get<Game.Card> { body -> get<Game.Card> { body ->
gameStateRepository gameStateRepository
.get(body.game.id) .getLast(body.game.id)
.cardOnCurrentStack .cardOnCurrentStack
?.card ?.card
?.let { call.respond(it) } ?.let { call.respond(it) }
@@ -48,7 +48,7 @@ fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
// Read the last played card on the game. // Read the last played card on the game.
get<Game.State> { body -> get<Game.State> { body ->
val state = gameStateRepository.get(body.game.id) val state = gameStateRepository.getLast(body.game.id)
call.respond(state) call.respond(state)
} }
} }

View File

@@ -11,9 +11,12 @@ import eventDemo.app.eventListener.PlayerNotificationEventListener
import eventDemo.libs.command.CommandStreamChannelBuilder import eventDemo.libs.command.CommandStreamChannelBuilder
import eventDemo.libs.event.EventBusInMemory import eventDemo.libs.event.EventBusInMemory
import eventDemo.libs.event.EventStreamInMemory import eventDemo.libs.event.EventStreamInMemory
import eventDemo.libs.event.VersionBuilder
import eventDemo.libs.event.VersionBuilderLocal
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.ktor.plugin.Koin import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger import org.koin.logger.slf4jLogger
@@ -40,6 +43,7 @@ val appKoinModule =
CommandStreamChannelBuilder<GameCommand>() CommandStreamChannelBuilder<GameCommand>()
} }
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
singleOf(::GameEventHandler) singleOf(::GameEventHandler)
singleOf(::GameCommandRunner) singleOf(::GameCommandRunner)
singleOf(::GameCommandHandler) singleOf(::GameCommandHandler)

View File

@@ -1,5 +1,6 @@
package eventDemo.libs.event package eventDemo.libs.event
import kotlinx.datetime.Instant
import java.util.UUID import java.util.UUID
/** /**
@@ -16,5 +17,7 @@ interface AggregateId {
*/ */
interface Event<ID : AggregateId> { interface Event<ID : AggregateId> {
val eventId: UUID val eventId: UUID
val gameId: ID val aggregateId: ID
val createdAt: Instant
val version: Int
} }

View File

@@ -34,11 +34,11 @@ class EventStreamInMemory<E : Event<ID>, ID : AggregateId> : EventStream<E, ID>
): R? = ): R? =
events events
.filterIsInstance(eventType.java) .filterIsInstance(eventType.java)
.lastOrNull { it.gameId == aggregateId } .lastOrNull { it.aggregateId == aggregateId }
override fun readAll(aggregateId: ID): Set<E> = override fun readAll(aggregateId: ID): Set<E> =
events events
.filter { it.gameId == aggregateId } .filter { it.aggregateId == aggregateId }
.toSet() .toSet()
} }

View File

@@ -0,0 +1,7 @@
package eventDemo.libs.event
interface VersionBuilder {
fun buildNextVersion(): Int
fun getLastVersion(): Int
}

View File

@@ -0,0 +1,11 @@
package eventDemo.libs.event
import java.util.concurrent.atomic.AtomicInteger
class VersionBuilderLocal : VersionBuilder {
private val version: AtomicInteger = AtomicInteger(0)
override fun buildNextVersion(): Int = version.addAndGet(1)
override fun getLastVersion(): Int = version.toInt()
}

View File

@@ -11,12 +11,14 @@ import eventDemo.app.notification.WelcomeToTheGameNotification
import eventDemo.configuration.appKoinModule import eventDemo.configuration.appKoinModule
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldContain
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.dsl.koinApplication import org.koin.dsl.koinApplication
import kotlin.test.assertIs import kotlin.test.assertIs
@OptIn(DelicateCoroutinesApi::class)
class GameCommandHandlerTest : class GameCommandHandlerTest :
FunSpec({ FunSpec({
test("handle a command should execute the command") { test("handle a command should execute the command") {

View File

@@ -8,6 +8,7 @@ import eventDemo.app.event.event.GameStartedEvent
import eventDemo.app.event.event.NewPlayerEvent import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.app.event.event.PlayerReadyEvent import eventDemo.app.event.event.PlayerReadyEvent
import eventDemo.app.event.event.disableShuffleDeck import eventDemo.app.event.event.disableShuffleDeck
import eventDemo.libs.event.VersionBuilderLocal
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
import kotlin.test.assertIs import kotlin.test.assertIs
@@ -17,34 +18,55 @@ class GameStateBuilderTest :
FunSpec({ FunSpec({
test("apply") { test("apply") {
disableShuffleDeck() disableShuffleDeck()
val versionBuilder = VersionBuilderLocal()
val gameId = GameId() val gameId = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
val player2 = Player(name = "Einstein") val player2 = Player(name = "Einstein")
GameState(gameId) GameState(gameId)
.run { .run {
val event = NewPlayerEvent(gameId, player1) val event =
NewPlayerEvent(
aggregateId = gameId,
player = player1,
version = versionBuilder.buildNextVersion(),
)
apply(event).also { state -> apply(event).also { state ->
state.gameId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.isReady shouldBeEqual false state.isReady shouldBeEqual false
state.isStarted shouldBeEqual false state.isStarted shouldBeEqual false
} }
}.run { }.run {
val event = NewPlayerEvent(gameId, player2) val event =
NewPlayerEvent(
aggregateId = gameId,
player = player2,
version = versionBuilder.buildNextVersion(),
)
apply(event).also { state -> apply(event).also { state ->
state.gameId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.players shouldBeEqual setOf(player1, player2) state.players shouldBeEqual setOf(player1, player2)
} }
}.run { }.run {
val event = PlayerReadyEvent(gameId, player1) val event =
PlayerReadyEvent(
aggregateId = gameId,
player = player1,
version = versionBuilder.buildNextVersion(),
)
apply(event).also { state -> apply(event).also { state ->
state.gameId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.readyPlayers shouldBeEqual setOf(player1) state.readyPlayers shouldBeEqual setOf(player1)
} }
}.run { }.run {
val event = PlayerReadyEvent(gameId, player2) val event =
PlayerReadyEvent(
aggregateId = gameId,
player = player2,
version = versionBuilder.buildNextVersion(),
)
apply(event).also { state -> apply(event).also { state ->
state.gameId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.readyPlayers shouldBeEqual setOf(player1, player2) state.readyPlayers shouldBeEqual setOf(player1, player2)
state.isReady shouldBeEqual true state.isReady shouldBeEqual true
state.isStarted shouldBeEqual false state.isStarted shouldBeEqual false
@@ -52,12 +74,13 @@ class GameStateBuilderTest :
}.run { }.run {
val event = val event =
GameStartedEvent.new( GameStartedEvent.new(
gameId, id = gameId,
setOf(player1, player2), players = setOf(player1, player2),
shuffleIsDisabled = true, shuffleIsDisabled = true,
version = versionBuilder.buildNextVersion(),
) )
apply(event).also { state -> apply(event).also { state ->
state.gameId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
state.isStarted shouldBeEqual true state.isStarted shouldBeEqual true
assertIs<Card.NumericCard>(state.deck.stack.first()).let { assertIs<Card.NumericCard>(state.deck.stack.first()).let {
it.number shouldBeEqual 6 it.number shouldBeEqual 6
@@ -66,9 +89,15 @@ class GameStateBuilderTest :
} }
}.run { }.run {
val playedCard = playableCards(player1)[0] val playedCard = playableCards(player1)[0]
val event = CardIsPlayedEvent(gameId, playedCard, player1) val event =
CardIsPlayedEvent(
aggregateId = gameId,
card = playedCard,
player = player1,
version = versionBuilder.buildNextVersion(),
)
apply(event).also { state -> apply(event).also { state ->
state.gameId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard
assertIs<Card.NumericCard>(playedCard).let { assertIs<Card.NumericCard>(playedCard).let {
it.number shouldBeEqual 0 it.number shouldBeEqual 0
@@ -77,9 +106,15 @@ class GameStateBuilderTest :
} }
}.run { }.run {
val playedCard = playableCards(player2)[0] val playedCard = playableCards(player2)[0]
val event = CardIsPlayedEvent(gameId, playedCard, player2) val event =
CardIsPlayedEvent(
aggregateId = gameId,
card = playedCard,
player = player2,
version = versionBuilder.buildNextVersion(),
)
apply(event).also { state -> apply(event).also { state ->
state.gameId shouldBeEqual gameId state.aggregateId shouldBeEqual gameId
assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard assertNotNull(state.cardOnCurrentStack).card shouldBeEqual playedCard
assertIs<Card.NumericCard>(playedCard).let { assertIs<Card.NumericCard>(playedCard).let {
it.number shouldBeEqual 7 it.number shouldBeEqual 7

View File

@@ -1,16 +1,128 @@
package eventDemo.app.event.projection package eventDemo.app.event.projection
import eventDemo.app.entity.GameId
import eventDemo.app.entity.Player
import eventDemo.app.event.GameEventHandler
import eventDemo.app.event.event.NewPlayerEvent
import eventDemo.configuration.appKoinModule
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import org.koin.core.context.stopKoin
import org.koin.dsl.koinApplication
import kotlin.test.assertNotNull
@OptIn(DelicateCoroutinesApi::class)
class GameStateRepositoryTest : class GameStateRepositoryTest :
FunSpec({ FunSpec({
xtest("GameStateRepository should build the projection when a new event occurs") { } val player1 = Player("Tesla")
val player2 = Player(name = "Einstein")
xtest("get should build the last version of the state") { } test("GameStateRepository should build the projection when a new event occurs") {
xtest("get should be concurrently secure") { } val aggregateId = GameId()
xtest("get should be concurrently secure") { } koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>()
eventHandler
.handle { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also { event ->
assertNotNull(repo.getUntil(event)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
}
stopKoin()
}
xtest("getUntil should build the state until the event") { } test("get should build the last version of the state") {
xtest("call getUntil twice should get the state from the cache") { } val aggregateId = GameId()
xtest("getUntil should be concurrently secure") { } koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>()
eventHandler
.handle { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also {
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
eventHandler
.handle { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) }
.also {
assertNotNull(repo.getLast(aggregateId)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1, player2)
}
}
}
}
test("getUntil should build the state until the event") {
repeat(10) {
val aggregateId = GameId()
koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>()
val event1 =
eventHandler
.handle { NewPlayerEvent(aggregateId = aggregateId, player = player1, version = it) }
.also { event1 ->
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
eventHandler
.handle { NewPlayerEvent(aggregateId = aggregateId, player = player2, version = it) }
.also { event2 ->
assertNotNull(repo.getUntil(event2)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1, player2)
}
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.players) shouldBeEqual setOf(player1)
}
}
}
}
}
test("getUntil should be concurrently secure") {
val aggregateId = GameId()
koinApplication { modules(appKoinModule) }.koin.apply {
val repo = get<GameStateRepository>()
val eventHandler = get<GameEventHandler>()
(1..10)
.map { r ->
GlobalScope
.launch {
repeat(100) { r2 ->
val playerX = Player("player$r$r2")
eventHandler
.handle {
NewPlayerEvent(
aggregateId = aggregateId,
player = playerX,
version = it,
)
}
}
}
}.joinAll()
repo.getLast(aggregateId).players shouldHaveSize 1000
repo.getLast(aggregateId).lastEventVersion shouldBeEqual 1000
}
}
xtest("get should be concurrently secure") { }
}) })

View File

@@ -0,0 +1,125 @@
package eventDemo.app.event.projection
import eventDemo.libs.event.AggregateId
import eventDemo.libs.event.Event
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID
import kotlin.test.assertNotNull
@OptIn(DelicateCoroutinesApi::class)
class ProjectionSnapshotRepositoryInMemoryTest :
FunSpec({
test("when call applyAndPutToCache, the getUntil method must be use the built projection cache") {
repeat(10) {
val repo = getRepoTest()
val aggregateId = IdTest()
val eventOther = Event2Test(value2 = "valOther", version = 1, aggregateId = IdTest())
repo.applyAndPutToCache(eventOther)
assertNotNull(repo.getUntil(eventOther)).also {
assertNotNull(it.value) shouldBeEqual "valOther"
}
val event1 = Event1Test(value1 = "val1", version = 1, aggregateId = aggregateId)
repo.applyAndPutToCache(event1)
assertNotNull(repo.getLast(event1.aggregateId)).also {
assertNotNull(it.value) shouldBeEqual "val1"
}
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.value) shouldBeEqual "val1"
}
val event2 = Event2Test(value2 = "val2", version = 2, aggregateId = aggregateId)
repo.applyAndPutToCache(event2)
assertNotNull(repo.getLast(event2.aggregateId)).also {
assertNotNull(it.value) shouldBeEqual "val1val2"
}
assertNotNull(repo.getUntil(event1)).also {
assertNotNull(it.value) shouldBeEqual "val1"
}
assertNotNull(repo.getUntil(event2)).also {
assertNotNull(it.value) shouldBeEqual "val1val2"
}
}
}
test("ProjectionSnapshotRepositoryInMemory should be thread safe") {
val repo = getRepoTest(2000)
val aggregateId = IdTest()
(1..10)
.map { r ->
GlobalScope.launch {
repeat(10) {
val eventX = EventXTest(num = 1, version = r, aggregateId = aggregateId)
repo.applyAndPutToCache(eventX)
}
}
}.joinAll()
assertNotNull(repo.getLast(aggregateId)).num shouldBeEqual 100
}
})
@JvmInline
private value class IdTest(
override val id: UUID = UUID.randomUUID(),
) : AggregateId
private data class ProjectionTest(
override val aggregateId: IdTest,
override val lastEventVersion: Int = 0,
var value: String? = null,
var num: Int = 0,
) : Projection<IdTest>
private sealed interface TestEvents : Event<IdTest>
private data class Event1Test(
override val eventId: UUID = UUID.randomUUID(),
override val aggregateId: IdTest,
override val createdAt: Instant = Clock.System.now(),
override val version: Int,
val value1: String,
) : TestEvents
private data class Event2Test(
override val eventId: UUID = UUID.randomUUID(),
override val aggregateId: IdTest,
override val createdAt: Instant = Clock.System.now(),
override val version: Int,
val value2: String,
) : TestEvents
private data class EventXTest(
override val eventId: UUID = UUID.randomUUID(),
override val aggregateId: IdTest,
override val createdAt: Instant = Clock.System.now(),
override val version: Int,
val num: Int,
) : TestEvents
private fun getRepoTest(maxSnapshotCacheSize: Int = 2000): ProjectionSnapshotRepositoryInMemory<TestEvents, ProjectionTest, IdTest> =
ProjectionSnapshotRepositoryInMemory(maxSnapshotCacheSize) { event ->
(this ?: ProjectionTest(event.aggregateId)).let { projection ->
when (event) {
is Event1Test -> {
projection.copy(value = (projection.value ?: "") + event.value1)
}
is Event2Test -> {
projection.copy(value = (projection.value ?: "") + event.value2)
}
is EventXTest -> {
projection.copy(num = projection.num + event.num)
}
}
}
}

View File

@@ -50,7 +50,7 @@ class GameStateRouteTest :
}.apply { }.apply {
assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val state = call.body<GameState>() val state = call.body<GameState>()
id shouldBeEqual state.gameId id shouldBeEqual state.aggregateId
state.players shouldHaveSize 0 state.players shouldHaveSize 0
state.isStarted shouldBeEqual false state.isStarted shouldBeEqual false
} }
@@ -71,19 +71,20 @@ class GameStateRouteTest :
val eventHandler by inject<GameEventHandler>() val eventHandler by inject<GameEventHandler>()
val stateRepo by inject<GameStateRepository>() val stateRepo by inject<GameStateRepository>()
runBlocking { runBlocking {
eventHandler.handle( eventHandler.handle { NewPlayerEvent(gameId, player1, it) }
NewPlayerEvent(gameId, player1), eventHandler.handle { NewPlayerEvent(gameId, player2, it) }
NewPlayerEvent(gameId, player2), eventHandler.handle { PlayerReadyEvent(gameId, player1, it) }
PlayerReadyEvent(gameId, player1), eventHandler.handle { PlayerReadyEvent(gameId, player2, it) }
PlayerReadyEvent(gameId, player2), eventHandler.handle {
GameStartedEvent.new( GameStartedEvent.new(
gameId, gameId,
setOf(player1, player2), setOf(player1, player2),
shuffleIsDisabled = true, shuffleIsDisabled = true,
), it,
) )
}
delay(100) delay(100)
lastPlayedCard = stateRepo.get(gameId).playableCards(player1).first() lastPlayedCard = stateRepo.getLast(gameId).playableCards(player1).first()
assertNotNull(lastPlayedCard) assertNotNull(lastPlayedCard)
.let { assertIs<Card.NumericCard>(lastPlayedCard) } .let { assertIs<Card.NumericCard>(lastPlayedCard) }
.let { .let {
@@ -91,13 +92,14 @@ class GameStateRouteTest :
it.color shouldBeEqual Card.Color.Red it.color shouldBeEqual Card.Color.Red
} }
delay(100) delay(100)
eventHandler.handle( eventHandler.handle {
CardIsPlayedEvent( CardIsPlayedEvent(
gameId, gameId,
assertNotNull(lastPlayedCard), assertNotNull(lastPlayedCard),
player1, player1,
), it,
) )
}
delay(100) delay(100)
} }
} }

View File

@@ -32,15 +32,18 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.koin.dsl.koinApplication import org.koin.dsl.koinApplication
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
@DelicateCoroutinesApi @DelicateCoroutinesApi
class GameStateTest : class GameStateTest :
FunSpec({ FunSpec({
test("Simulation of a game") { test("Simulation of a game") {
withTimeout(2.seconds) {
disableShuffleDeck() disableShuffleDeck()
val id = GameId() val id = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
@@ -147,7 +150,7 @@ class GameStateTest :
val state = id.buildStateFromEventStream(eventStream) val state = id.buildStateFromEventStream(eventStream)
state.gameId shouldBeEqual id state.aggregateId shouldBeEqual id
assertTrue(state.isStarted) assertTrue(state.isStarted)
state.players shouldBeEqual setOf(player1, player2) state.players shouldBeEqual setOf(player1, player2)
state.readyPlayers shouldBeEqual setOf(player1, player2) state.readyPlayers shouldBeEqual setOf(player1, player2)
@@ -155,4 +158,5 @@ class GameStateTest :
assertNotNull(state.cardOnCurrentStack) shouldBeEqual GameState.LastCard(assertNotNull(playedCard2), player2) assertNotNull(state.cardOnCurrentStack) shouldBeEqual GameState.LastCard(assertNotNull(playedCard2), player2)
} }
} }
}
}) })

View File

@@ -0,0 +1,42 @@
package eventDemo.libs.event
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
@OptIn(DelicateCoroutinesApi::class)
class VersionBuilderLocalTest :
FunSpec({
test("buildNextVersion") {
VersionBuilderLocal().run {
buildNextVersion() shouldBeEqual 1
buildNextVersion() shouldBeEqual 2
buildNextVersion() shouldBeEqual 3
}
}
test("buildNextVersion concurrently") {
val versionBuilder = VersionBuilderLocal()
(1..20)
.map {
GlobalScope.launch {
(1..1000).map {
versionBuilder.buildNextVersion()
}
}
}.joinAll()
versionBuilder.getLastVersion() shouldBeEqual 20 * 1000
}
test("getLastVersion") {
VersionBuilderLocal().run {
getLastVersion() shouldBeEqual 0
getLastVersion() shouldBeEqual 0
getLastVersion() shouldBeEqual 0
}
}
})