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:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/main/kotlin/eventDemo/app/event/EventHandler.kt
Normal file
13
src/main/kotlin/eventDemo/app/event/EventHandler.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package eventDemo.app.event.projection
|
||||||
|
|
||||||
|
import eventDemo.libs.event.AggregateId
|
||||||
|
|
||||||
|
interface Projection<ID : AggregateId> {
|
||||||
|
val aggregateId: ID
|
||||||
|
val lastEventVersion: Int
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt
Normal file
7
src/main/kotlin/eventDemo/libs/event/VersionBuilder.kt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package eventDemo.libs.event
|
||||||
|
|
||||||
|
interface VersionBuilder {
|
||||||
|
fun buildNextVersion(): Int
|
||||||
|
|
||||||
|
fun getLastVersion(): Int
|
||||||
|
}
|
||||||
11
src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt
Normal file
11
src/main/kotlin/eventDemo/libs/event/VersionBuilderLocal.kt
Normal 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()
|
||||||
|
}
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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") { }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user