feat: add domain core and verification harness
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
kotlin.srcDir("src/main/java")
|
||||
}
|
||||
test {
|
||||
kotlin.srcDir("src/test/java")
|
||||
}
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnit()
|
||||
testLogging {
|
||||
events("failed", "skipped")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package brainwave.core.model
|
||||
|
||||
data class HexagramContent(
|
||||
val kingWenNumber: Int,
|
||||
val name: String,
|
||||
val symbol: String,
|
||||
val judgmentOriginal: String,
|
||||
val judgmentPlain: String,
|
||||
val lineTextsBottomUp: List<String>,
|
||||
val linePlainBottomUp: List<String>,
|
||||
val specialUsageText: String?,
|
||||
val sourceRefs: List<String>,
|
||||
) {
|
||||
init {
|
||||
require(kingWenNumber in 1..64) { "King Wen number must be between 1 and 64" }
|
||||
require(name.isNotBlank()) { "Hexagram name must not be blank" }
|
||||
require(symbol.isNotBlank()) { "Hexagram symbol must not be blank" }
|
||||
require(judgmentOriginal.isNotBlank()) { "Original judgment must not be blank" }
|
||||
require(judgmentPlain.isNotBlank()) { "Plain judgment must not be blank" }
|
||||
require(lineTextsBottomUp.size == 6 && lineTextsBottomUp.all(String::isNotBlank)) {
|
||||
"Original line texts must contain six non-blank bottom-up entries"
|
||||
}
|
||||
require(linePlainBottomUp.size == 6 && linePlainBottomUp.all(String::isNotBlank)) {
|
||||
"Plain line texts must contain six non-blank bottom-up entries"
|
||||
}
|
||||
require(specialUsageText == null || specialUsageText.isNotBlank()) {
|
||||
"Special usage text must be non-blank or null"
|
||||
}
|
||||
require(sourceRefs.isNotEmpty() && sourceRefs.all(String::isNotBlank)) {
|
||||
"Source references must contain at least one non-blank id"
|
||||
}
|
||||
require(sourceRefs.distinct().size == sourceRefs.size) {
|
||||
"Source references must be unique"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package brainwave.data.content
|
||||
|
||||
import brainwave.core.model.HexagramContent
|
||||
|
||||
interface HexagramContentRepository {
|
||||
val contentVersion: String
|
||||
|
||||
fun contentFor(
|
||||
kingWenNumber: Int,
|
||||
requestedContentVersion: String = contentVersion,
|
||||
): HexagramContent
|
||||
}
|
||||
|
||||
class ContentIntegrityException(message: String) : IllegalStateException(message)
|
||||
@@ -0,0 +1,80 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
data class CastComputation internal constructor(
|
||||
val roundsBottomUp: List<CastRound>,
|
||||
val lineValuesBottomUp: List<LineValue>,
|
||||
val primaryPatternBottomUp: HexagramPattern,
|
||||
val movingLinePositions: List<Int>,
|
||||
val transformedPatternBottomUp: HexagramPattern,
|
||||
val primaryHexagramId: HexagramId,
|
||||
val transformedHexagramId: HexagramId,
|
||||
)
|
||||
|
||||
object CastEngine {
|
||||
const val METHOD_VERSION = "coin-v1"
|
||||
const val COIN_CONVENTION = "character=2;reverse=3;bottom-up"
|
||||
|
||||
fun cast(roundsBottomUp: List<CastRound>): CastComputation {
|
||||
require(roundsBottomUp.size == HexagramPattern.LINE_COUNT) {
|
||||
"A complete cast must contain exactly six rounds, but contained ${roundsBottomUp.size}"
|
||||
}
|
||||
|
||||
val rounds = roundsBottomUp.toList()
|
||||
val lineValues = rounds.map(CastRound::lineValue)
|
||||
val primaryPattern = HexagramPattern.of(lineValues.map(LineValue::polarity))
|
||||
val movingPositions = lineValues.mapIndexedNotNull { index, lineValue ->
|
||||
if (lineValue.isMoving) index + 1 else null
|
||||
}
|
||||
val transformedPattern = HexagramPattern.of(lineValues.map(LineValue::transformedPolarity))
|
||||
|
||||
return CastComputation(
|
||||
roundsBottomUp = rounds,
|
||||
lineValuesBottomUp = lineValues,
|
||||
primaryPatternBottomUp = primaryPattern,
|
||||
movingLinePositions = movingPositions,
|
||||
transformedPatternBottomUp = transformedPattern,
|
||||
primaryHexagramId = HexagramCatalog.idFor(primaryPattern),
|
||||
transformedHexagramId = HexagramCatalog.idFor(transformedPattern),
|
||||
)
|
||||
}
|
||||
}
|
||||
data class CastMetadata(
|
||||
val contentVersion: String,
|
||||
val createdAt: String,
|
||||
) {
|
||||
init {
|
||||
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
||||
require(createdAt.isNotBlank()) { "Created-at metadata must not be blank" }
|
||||
}
|
||||
}
|
||||
|
||||
data class CastResult private constructor(
|
||||
val methodVersion: String,
|
||||
val coinConvention: String,
|
||||
val roundsBottomUp: List<CastRound>,
|
||||
val lineValuesBottomUp: List<LineValue>,
|
||||
val primaryPatternBottomUp: HexagramPattern,
|
||||
val movingLinePositions: List<Int>,
|
||||
val transformedPatternBottomUp: HexagramPattern,
|
||||
val primaryHexagramId: HexagramId,
|
||||
val transformedHexagramId: HexagramId,
|
||||
val contentVersion: String,
|
||||
val createdAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun record(computation: CastComputation, metadata: CastMetadata): CastResult =
|
||||
CastResult(
|
||||
methodVersion = CastEngine.METHOD_VERSION,
|
||||
coinConvention = CastEngine.COIN_CONVENTION,
|
||||
roundsBottomUp = computation.roundsBottomUp.toList(),
|
||||
lineValuesBottomUp = computation.lineValuesBottomUp.toList(),
|
||||
primaryPatternBottomUp = computation.primaryPatternBottomUp,
|
||||
movingLinePositions = computation.movingLinePositions.toList(),
|
||||
transformedPatternBottomUp = computation.transformedPatternBottomUp,
|
||||
primaryHexagramId = computation.primaryHexagramId,
|
||||
transformedHexagramId = computation.transformedHexagramId,
|
||||
contentVersion = metadata.contentVersion,
|
||||
createdAt = metadata.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
data class CastRecordDto(
|
||||
val schemaVersion: Int,
|
||||
val methodVersion: String,
|
||||
val coinConvention: String,
|
||||
val roundsBottomUp: List<List<Int>>,
|
||||
val lineValuesBottomUp: List<Int>,
|
||||
val primaryPatternBottomUp: String,
|
||||
val movingLinePositions: List<Int>,
|
||||
val transformedPatternBottomUp: String,
|
||||
val primaryHexagramId: Int,
|
||||
val transformedHexagramId: Int,
|
||||
val contentVersion: String,
|
||||
val createdAt: String,
|
||||
) {
|
||||
fun toDomain(): CastResult {
|
||||
require(schemaVersion == CURRENT_SCHEMA_VERSION) {
|
||||
"Unsupported cast record schema version: $schemaVersion"
|
||||
}
|
||||
require(methodVersion == CastEngine.METHOD_VERSION) {
|
||||
"Unsupported casting method version: $methodVersion"
|
||||
}
|
||||
require(coinConvention == CastEngine.COIN_CONVENTION) {
|
||||
"Unsupported coin convention: $coinConvention"
|
||||
}
|
||||
|
||||
val rounds = roundsBottomUp.map(CastRound::fromScores)
|
||||
val result = CastResult.record(
|
||||
computation = CastEngine.cast(rounds),
|
||||
metadata = CastMetadata(contentVersion = contentVersion, createdAt = createdAt),
|
||||
)
|
||||
|
||||
require(lineValuesBottomUp == result.lineValuesBottomUp.map(LineValue::score)) {
|
||||
"Stored line values do not match the preserved coin rounds"
|
||||
}
|
||||
require(primaryPatternBottomUp == result.primaryPatternBottomUp.encoded()) {
|
||||
"Stored primary pattern does not match the preserved coin rounds"
|
||||
}
|
||||
require(movingLinePositions == result.movingLinePositions) {
|
||||
"Stored moving-line positions do not match the preserved coin rounds"
|
||||
}
|
||||
require(transformedPatternBottomUp == result.transformedPatternBottomUp.encoded()) {
|
||||
"Stored transformed pattern does not match the preserved coin rounds"
|
||||
}
|
||||
require(primaryHexagramId == result.primaryHexagramId.value) {
|
||||
"Stored primary hexagram id does not match the King Wen lookup"
|
||||
}
|
||||
require(transformedHexagramId == result.transformedHexagramId.value) {
|
||||
"Stored transformed hexagram id does not match the King Wen lookup"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CURRENT_SCHEMA_VERSION = 1
|
||||
|
||||
fun fromDomain(result: CastResult): CastRecordDto =
|
||||
CastRecordDto(
|
||||
schemaVersion = CURRENT_SCHEMA_VERSION,
|
||||
methodVersion = result.methodVersion,
|
||||
coinConvention = result.coinConvention,
|
||||
roundsBottomUp = result.roundsBottomUp.map { round -> round.coins.map(CoinSide::score) },
|
||||
lineValuesBottomUp = result.lineValuesBottomUp.map(LineValue::score),
|
||||
primaryPatternBottomUp = result.primaryPatternBottomUp.encoded(),
|
||||
movingLinePositions = result.movingLinePositions.toList(),
|
||||
transformedPatternBottomUp = result.transformedPatternBottomUp.encoded(),
|
||||
primaryHexagramId = result.primaryHexagramId.value,
|
||||
transformedHexagramId = result.transformedHexagramId.value,
|
||||
contentVersion = result.contentVersion,
|
||||
createdAt = result.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
enum class CoinSide(val score: Int) {
|
||||
CHARACTER(2),
|
||||
REVERSE(3),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromScore(score: Int): CoinSide =
|
||||
entries.singleOrNull { it.score == score }
|
||||
?: throw IllegalArgumentException("Coin score must be 2 or 3, but was $score")
|
||||
}
|
||||
}
|
||||
enum class Polarity {
|
||||
YIN,
|
||||
YANG,
|
||||
;
|
||||
|
||||
fun opposite(): Polarity = if (this == YIN) YANG else YIN
|
||||
}
|
||||
|
||||
enum class LineValue(
|
||||
val score: Int,
|
||||
val polarity: Polarity,
|
||||
val isMoving: Boolean,
|
||||
) {
|
||||
OLD_YIN(6, Polarity.YIN, true),
|
||||
YOUNG_YANG(7, Polarity.YANG, false),
|
||||
YOUNG_YIN(8, Polarity.YIN, false),
|
||||
OLD_YANG(9, Polarity.YANG, true),
|
||||
;
|
||||
|
||||
val transformedPolarity: Polarity
|
||||
get() = if (isMoving) polarity.opposite() else polarity
|
||||
|
||||
companion object {
|
||||
fun fromScore(score: Int): LineValue =
|
||||
entries.singleOrNull { it.score == score }
|
||||
?: throw IllegalArgumentException("Line score must be between 6 and 9, but was $score")
|
||||
}
|
||||
}
|
||||
|
||||
data class CastRound private constructor(val coins: List<CoinSide>) {
|
||||
init {
|
||||
require(coins.size == COINS_PER_ROUND) {
|
||||
"Each cast round must contain exactly $COINS_PER_ROUND coins, but contained ${coins.size}"
|
||||
}
|
||||
}
|
||||
|
||||
val lineValue: LineValue
|
||||
get() = LineValue.fromScore(coins.sumOf(CoinSide::score))
|
||||
|
||||
companion object {
|
||||
const val COINS_PER_ROUND = 3
|
||||
|
||||
fun of(coins: List<CoinSide>): CastRound = CastRound(coins.toList())
|
||||
|
||||
fun of(first: CoinSide, second: CoinSide, third: CoinSide): CastRound =
|
||||
of(listOf(first, second, third))
|
||||
|
||||
fun fromScores(scores: List<Int>): CastRound = of(scores.map(CoinSide::fromScore))
|
||||
|
||||
fun fromLineValue(lineValue: LineValue): CastRound =
|
||||
when (lineValue) {
|
||||
LineValue.OLD_YIN -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.CHARACTER)
|
||||
LineValue.YOUNG_YANG -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.REVERSE)
|
||||
LineValue.YOUNG_YIN -> of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.REVERSE)
|
||||
LineValue.OLD_YANG -> of(CoinSide.REVERSE, CoinSide.REVERSE, CoinSide.REVERSE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HexagramPattern private constructor(val linesBottomUp: List<Polarity>) {
|
||||
init {
|
||||
require(linesBottomUp.size == LINE_COUNT) {
|
||||
"A hexagram must contain exactly $LINE_COUNT lines, but contained ${linesBottomUp.size}"
|
||||
}
|
||||
}
|
||||
|
||||
fun encoded(): String = linesBottomUp.joinToString(separator = "") {
|
||||
if (it == Polarity.YANG) "1" else "0"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LINE_COUNT = 6
|
||||
|
||||
fun of(linesBottomUp: List<Polarity>): HexagramPattern =
|
||||
HexagramPattern(linesBottomUp.toList())
|
||||
|
||||
fun decode(encoded: String): HexagramPattern {
|
||||
require(encoded.length == LINE_COUNT && encoded.all { it == '0' || it == '1' }) {
|
||||
"Encoded hexagram must contain exactly six 0/1 characters"
|
||||
}
|
||||
return of(encoded.map { if (it == '1') Polarity.YANG else Polarity.YIN })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class HexagramId(val value: Int) {
|
||||
init {
|
||||
require(value in 1..64) { "King Wen hexagram id must be between 1 and 64" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
object HexagramCatalog {
|
||||
private enum class Trigram(val patternBottomUp: String) {
|
||||
QIAN("111"),
|
||||
DUI("110"),
|
||||
LI("101"),
|
||||
ZHEN("100"),
|
||||
XUN("011"),
|
||||
KAN("010"),
|
||||
GEN("001"),
|
||||
KUN("000"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromPattern(patternBottomUp: String): Trigram =
|
||||
entries.singleOrNull { it.patternBottomUp == patternBottomUp }
|
||||
?: error("Unknown trigram pattern: $patternBottomUp")
|
||||
}
|
||||
}
|
||||
|
||||
private data class TrigramPair(val upper: Trigram, val lower: Trigram)
|
||||
|
||||
private val kingWenIds = mapOf(
|
||||
TrigramPair(Trigram.QIAN, Trigram.QIAN) to 1,
|
||||
TrigramPair(Trigram.KUN, Trigram.KUN) to 2,
|
||||
TrigramPair(Trigram.KAN, Trigram.ZHEN) to 3,
|
||||
TrigramPair(Trigram.GEN, Trigram.KAN) to 4,
|
||||
TrigramPair(Trigram.KAN, Trigram.QIAN) to 5,
|
||||
TrigramPair(Trigram.QIAN, Trigram.KAN) to 6,
|
||||
TrigramPair(Trigram.KUN, Trigram.KAN) to 7,
|
||||
TrigramPair(Trigram.KAN, Trigram.KUN) to 8,
|
||||
TrigramPair(Trigram.XUN, Trigram.QIAN) to 9,
|
||||
TrigramPair(Trigram.QIAN, Trigram.DUI) to 10,
|
||||
TrigramPair(Trigram.KUN, Trigram.QIAN) to 11,
|
||||
TrigramPair(Trigram.QIAN, Trigram.KUN) to 12,
|
||||
TrigramPair(Trigram.QIAN, Trigram.LI) to 13,
|
||||
TrigramPair(Trigram.LI, Trigram.QIAN) to 14,
|
||||
TrigramPair(Trigram.KUN, Trigram.GEN) to 15,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.KUN) to 16,
|
||||
TrigramPair(Trigram.DUI, Trigram.ZHEN) to 17,
|
||||
TrigramPair(Trigram.GEN, Trigram.XUN) to 18,
|
||||
TrigramPair(Trigram.KUN, Trigram.DUI) to 19,
|
||||
TrigramPair(Trigram.XUN, Trigram.KUN) to 20,
|
||||
TrigramPair(Trigram.LI, Trigram.ZHEN) to 21,
|
||||
TrigramPair(Trigram.GEN, Trigram.LI) to 22,
|
||||
TrigramPair(Trigram.GEN, Trigram.KUN) to 23,
|
||||
TrigramPair(Trigram.KUN, Trigram.ZHEN) to 24,
|
||||
TrigramPair(Trigram.QIAN, Trigram.ZHEN) to 25,
|
||||
TrigramPair(Trigram.GEN, Trigram.QIAN) to 26,
|
||||
TrigramPair(Trigram.GEN, Trigram.ZHEN) to 27,
|
||||
TrigramPair(Trigram.DUI, Trigram.XUN) to 28,
|
||||
TrigramPair(Trigram.KAN, Trigram.KAN) to 29,
|
||||
TrigramPair(Trigram.LI, Trigram.LI) to 30,
|
||||
TrigramPair(Trigram.DUI, Trigram.GEN) to 31,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.XUN) to 32,
|
||||
TrigramPair(Trigram.QIAN, Trigram.GEN) to 33,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.QIAN) to 34,
|
||||
TrigramPair(Trigram.LI, Trigram.KUN) to 35,
|
||||
TrigramPair(Trigram.KUN, Trigram.LI) to 36,
|
||||
TrigramPair(Trigram.XUN, Trigram.LI) to 37,
|
||||
TrigramPair(Trigram.LI, Trigram.DUI) to 38,
|
||||
TrigramPair(Trigram.KAN, Trigram.GEN) to 39,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.KAN) to 40,
|
||||
TrigramPair(Trigram.GEN, Trigram.DUI) to 41,
|
||||
TrigramPair(Trigram.XUN, Trigram.ZHEN) to 42,
|
||||
TrigramPair(Trigram.DUI, Trigram.QIAN) to 43,
|
||||
TrigramPair(Trigram.QIAN, Trigram.XUN) to 44,
|
||||
TrigramPair(Trigram.DUI, Trigram.KUN) to 45,
|
||||
TrigramPair(Trigram.KUN, Trigram.XUN) to 46,
|
||||
TrigramPair(Trigram.DUI, Trigram.KAN) to 47,
|
||||
TrigramPair(Trigram.KAN, Trigram.XUN) to 48,
|
||||
TrigramPair(Trigram.DUI, Trigram.LI) to 49,
|
||||
TrigramPair(Trigram.LI, Trigram.XUN) to 50,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.ZHEN) to 51,
|
||||
TrigramPair(Trigram.GEN, Trigram.GEN) to 52,
|
||||
TrigramPair(Trigram.XUN, Trigram.GEN) to 53,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.DUI) to 54,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.LI) to 55,
|
||||
TrigramPair(Trigram.LI, Trigram.GEN) to 56,
|
||||
TrigramPair(Trigram.XUN, Trigram.XUN) to 57,
|
||||
TrigramPair(Trigram.DUI, Trigram.DUI) to 58,
|
||||
TrigramPair(Trigram.XUN, Trigram.KAN) to 59,
|
||||
TrigramPair(Trigram.KAN, Trigram.DUI) to 60,
|
||||
TrigramPair(Trigram.XUN, Trigram.DUI) to 61,
|
||||
TrigramPair(Trigram.ZHEN, Trigram.GEN) to 62,
|
||||
TrigramPair(Trigram.KAN, Trigram.LI) to 63,
|
||||
TrigramPair(Trigram.LI, Trigram.KAN) to 64,
|
||||
)
|
||||
|
||||
init {
|
||||
check(kingWenIds.size == 64) { "King Wen table must contain all 64 trigram pairs" }
|
||||
check(kingWenIds.values.toSet() == (1..64).toSet()) {
|
||||
"King Wen table must contain every id from 1 through 64 exactly once"
|
||||
}
|
||||
}
|
||||
|
||||
fun idFor(pattern: HexagramPattern): HexagramId {
|
||||
val encoded = pattern.encoded()
|
||||
val lower = Trigram.fromPattern(encoded.substring(0, 3))
|
||||
val upper = Trigram.fromPattern(encoded.substring(3, 6))
|
||||
return HexagramId(kingWenIds.getValue(TrigramPair(upper, lower)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package brainwave.data.content
|
||||
|
||||
import brainwave.core.model.HexagramContent
|
||||
|
||||
class FakeHexagramContentRepository(
|
||||
override val contentVersion: String = "fixture-only-v1",
|
||||
entries: List<HexagramContent>,
|
||||
) : HexagramContentRepository {
|
||||
private val entriesById: Map<Int, HexagramContent>
|
||||
|
||||
init {
|
||||
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
|
||||
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
|
||||
"Fake content entries must have unique King Wen numbers"
|
||||
}
|
||||
entriesById = entries.associateBy(HexagramContent::kingWenNumber)
|
||||
}
|
||||
|
||||
override fun contentFor(
|
||||
kingWenNumber: Int,
|
||||
requestedContentVersion: String,
|
||||
): HexagramContent {
|
||||
if (requestedContentVersion != contentVersion) {
|
||||
throw ContentIntegrityException(
|
||||
"Requested content version '$requestedContentVersion' is unavailable; loaded '$contentVersion'",
|
||||
)
|
||||
}
|
||||
return entriesById[kingWenNumber]
|
||||
?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package brainwave.data.content
|
||||
|
||||
import brainwave.core.model.HexagramContent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class HexagramContentRepositoryTest {
|
||||
@Test
|
||||
fun `fake returns content only for its exact version and id`() {
|
||||
val qian = fixture(1)
|
||||
val repository = FakeHexagramContentRepository(entries = listOf(qian))
|
||||
|
||||
assertEquals(qian, repository.contentFor(1))
|
||||
assertFails("unavailable") { repository.contentFor(1, "another-version") }
|
||||
assertFails("No validated content") { repository.contentFor(2) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fake rejects duplicate ids instead of silently overwriting`() {
|
||||
assertFails("unique King Wen numbers") {
|
||||
FakeHexagramContentRepository(entries = listOf(fixture(1), fixture(1)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content model enforces six bottom-up texts and sources`() {
|
||||
assertFails("six non-blank") {
|
||||
fixture(1).copy(lineTextsBottomUp = List(5) { "fixture" })
|
||||
}
|
||||
assertFails("at least one") {
|
||||
fixture(1).copy(sourceRefs = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixture(id: Int) = HexagramContent(
|
||||
kingWenNumber = id,
|
||||
name = "fixture-$id",
|
||||
symbol = "fixture-symbol-$id",
|
||||
judgmentOriginal = "fixture original",
|
||||
judgmentPlain = "fixture plain",
|
||||
lineTextsBottomUp = List(6) { index -> "fixture original line ${index + 1}" },
|
||||
linePlainBottomUp = List(6) { index -> "fixture plain line ${index + 1}" },
|
||||
specialUsageText = null,
|
||||
sourceRefs = listOf("fixture-source"),
|
||||
)
|
||||
|
||||
private fun assertFails(messageFragment: String, block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
fail("Expected failure containing '$messageFragment'")
|
||||
} catch (error: IllegalArgumentException) {
|
||||
assertTrue(error.message.orEmpty().contains(messageFragment))
|
||||
} catch (error: ContentIntegrityException) {
|
||||
assertTrue(error.message.orEmpty().contains(messageFragment))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package brainwave.domain.casting
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class CastEngineTest {
|
||||
@Test
|
||||
fun `all eight coin combinations map to the correct line values`() {
|
||||
val counts = mutableMapOf<LineValue, Int>()
|
||||
|
||||
for (encoded in 0 until 8) {
|
||||
val coins = List(3) { index ->
|
||||
if (encoded and (1 shl index) == 0) CoinSide.CHARACTER else CoinSide.REVERSE
|
||||
}
|
||||
val expected = LineValue.fromScore(coins.sumOf(CoinSide::score))
|
||||
val actual = CastRound.of(coins).lineValue
|
||||
assertEquals(expected, actual)
|
||||
counts[actual] = counts.getOrDefault(actual, 0) + 1
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
mapOf(
|
||||
LineValue.OLD_YIN to 1,
|
||||
LineValue.YOUNG_YANG to 3,
|
||||
LineValue.YOUNG_YIN to 3,
|
||||
LineValue.OLD_YANG to 1,
|
||||
),
|
||||
counts,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `line values expose correct polarity movement and transformation`() {
|
||||
assertLine(LineValue.OLD_YIN, Polarity.YIN, moving = true, Polarity.YANG)
|
||||
assertLine(LineValue.YOUNG_YANG, Polarity.YANG, moving = false, Polarity.YANG)
|
||||
assertLine(LineValue.YOUNG_YIN, Polarity.YIN, moving = false, Polarity.YIN)
|
||||
assertLine(LineValue.OLD_YANG, Polarity.YANG, moving = true, Polarity.YIN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a round must preserve exactly three coins`() {
|
||||
assertFails("exactly 3 coins") { CastRound.of(emptyList()) }
|
||||
assertFails("exactly 3 coins") {
|
||||
CastRound.of(List(4) { CoinSide.CHARACTER })
|
||||
}
|
||||
assertEquals(
|
||||
listOf(2, 3, 2),
|
||||
CastRound.of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.CHARACTER)
|
||||
.coins
|
||||
.map(CoinSide::score),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cast requires exactly six rounds`() {
|
||||
assertFails("exactly six rounds") {
|
||||
CastEngine.cast(List(5) { roundFor(LineValue.YOUNG_YANG) })
|
||||
}
|
||||
assertFails("exactly six rounds") {
|
||||
CastEngine.cast(List(7) { roundFor(LineValue.YOUNG_YANG) })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `documented fixtures preserve bottom-up order and King Wen ids`() {
|
||||
assertFixture(listOf(7, 7, 7, 7, 7, 7), primary = 1, transformed = 1, moving = emptyList())
|
||||
assertFixture(listOf(8, 8, 8, 8, 8, 8), primary = 2, transformed = 2, moving = emptyList())
|
||||
assertFixture(listOf(9, 9, 9, 9, 9, 9), primary = 1, transformed = 2, moving = (1..6).toList())
|
||||
assertFixture(listOf(6, 6, 6, 6, 6, 6), primary = 2, transformed = 1, moving = (1..6).toList())
|
||||
assertFixture(listOf(7, 8, 8, 8, 8, 8), primary = 24, transformed = 24, moving = emptyList())
|
||||
assertFixture(listOf(9, 8, 8, 8, 8, 8), primary = 24, transformed = 2, moving = listOf(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all 4096 line sequences compute consistently`() {
|
||||
val primaryIds = mutableSetOf<Int>()
|
||||
|
||||
for (encoded in 0 until 4096) {
|
||||
val lineValues = List(6) { position ->
|
||||
LineValue.entries[(encoded shr (position * 2)) and 0b11]
|
||||
}
|
||||
val result = CastEngine.cast(lineValues.map(::roundFor))
|
||||
|
||||
assertEquals(lineValues, result.lineValuesBottomUp)
|
||||
assertEquals(lineValues.map(LineValue::polarity), result.primaryPatternBottomUp.linesBottomUp)
|
||||
assertEquals(
|
||||
lineValues.map(LineValue::transformedPolarity),
|
||||
result.transformedPatternBottomUp.linesBottomUp,
|
||||
)
|
||||
assertEquals(
|
||||
lineValues.mapIndexedNotNull { index, line -> if (line.isMoving) index + 1 else null },
|
||||
result.movingLinePositions,
|
||||
)
|
||||
assertTrue(result.primaryHexagramId.value in 1..64)
|
||||
assertTrue(result.transformedHexagramId.value in 1..64)
|
||||
primaryIds += result.primaryHexagramId.value
|
||||
}
|
||||
|
||||
assertEquals((1..64).toSet(), primaryIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all 64 polarity patterns map one-to-one to King Wen ids`() {
|
||||
val ids = (0 until 64).map { encoded ->
|
||||
val pattern = HexagramPattern.of(
|
||||
List(6) { position ->
|
||||
if (encoded and (1 shl position) == 0) Polarity.YIN else Polarity.YANG
|
||||
},
|
||||
)
|
||||
HexagramCatalog.idFor(pattern).value
|
||||
}
|
||||
|
||||
assertEquals(64, ids.toSet().size)
|
||||
assertEquals((1..64).toSet(), ids.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record metadata is separate from deterministic computation`() {
|
||||
val rounds = listOf(9, 8, 8, 8, 8, 8).map(::roundFor)
|
||||
val computation = CastEngine.cast(rounds)
|
||||
val first = CastResult.record(computation, CastMetadata("content-test", "2026-01-01T00:00:00Z"))
|
||||
val second = CastResult.record(computation, CastMetadata("content-test", "2026-02-01T00:00:00Z"))
|
||||
|
||||
assertNotEquals(first.createdAt, second.createdAt)
|
||||
assertEquals(first.roundsBottomUp, second.roundsBottomUp)
|
||||
assertEquals(first.lineValuesBottomUp, second.lineValuesBottomUp)
|
||||
assertEquals(first.primaryHexagramId, second.primaryHexagramId)
|
||||
assertEquals(first.transformedHexagramId, second.transformedHexagramId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `versioned dto round-trips all preserved coins and derived values`() {
|
||||
val computation = CastEngine.cast(listOf(6, 7, 8, 9, 7, 8).map(::roundFor))
|
||||
val original = CastResult.record(
|
||||
computation,
|
||||
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
|
||||
)
|
||||
|
||||
val restored = CastRecordDto.fromDomain(original).toDomain()
|
||||
|
||||
assertEquals(original, restored)
|
||||
assertEquals(18, restored.roundsBottomUp.sumOf { it.coins.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `versioned dto rejects corrupted derived values`() {
|
||||
val original = CastResult.record(
|
||||
CastEngine.cast(List(6) { roundFor(LineValue.YOUNG_YANG) }),
|
||||
CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"),
|
||||
)
|
||||
val corrupted = CastRecordDto.fromDomain(original).copy(primaryHexagramId = 2)
|
||||
|
||||
assertFails("does not match") { corrupted.toDomain() }
|
||||
}
|
||||
|
||||
private fun assertFixture(
|
||||
scoresBottomUp: List<Int>,
|
||||
primary: Int,
|
||||
transformed: Int,
|
||||
moving: List<Int>,
|
||||
) {
|
||||
val result = CastEngine.cast(scoresBottomUp.map(::roundFor))
|
||||
assertEquals(primary, result.primaryHexagramId.value)
|
||||
assertEquals(transformed, result.transformedHexagramId.value)
|
||||
assertEquals(moving, result.movingLinePositions)
|
||||
assertEquals(scoresBottomUp, result.lineValuesBottomUp.map(LineValue::score))
|
||||
}
|
||||
|
||||
private fun assertLine(
|
||||
lineValue: LineValue,
|
||||
polarity: Polarity,
|
||||
moving: Boolean,
|
||||
transformed: Polarity,
|
||||
) {
|
||||
assertEquals(polarity, lineValue.polarity)
|
||||
assertEquals(moving, lineValue.isMoving)
|
||||
assertEquals(transformed, lineValue.transformedPolarity)
|
||||
if (moving) assertNotEquals(polarity, transformed) else assertEquals(polarity, transformed)
|
||||
}
|
||||
|
||||
private fun roundFor(score: Int): CastRound = roundFor(LineValue.fromScore(score))
|
||||
|
||||
private fun roundFor(lineValue: LineValue): CastRound = CastRound.fromLineValue(lineValue)
|
||||
|
||||
private fun assertFails(messageFragment: String, block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
fail("Expected IllegalArgumentException containing '$messageFragment'")
|
||||
} catch (error: IllegalArgumentException) {
|
||||
assertTrue(
|
||||
"Expected '${error.message}' to contain '$messageFragment'",
|
||||
error.message.orEmpty().contains(messageFragment),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user