feat(apps): shared runtime — app state store, synthesized SFX bank, tilt helper

Phase 2 infrastructure for the official-app program:
- Room v6 app_state table (AppStateEntity/Dao/MIGRATION_5_6) behind
  AppStateRepository, wired into the graph for save/resume and board snapshots.
- Sfx.kt: deterministic LCG noise, tone/sweep/noise/arpeggio generators, and a
  cached 16-cue SfxBank for games (synthesized only; no sampled audio).
- AppSensors.kt: rememberTilt() accelerometer helper (level, tilt calculator,
  motion games).
- OfficialCatalog: the seven dead-service mocks now carry installedIds so the
  marketplace resolves them.
- Tests: AppStateStoreTest (round-trip + reopen), SfxTest (determinism,
  inventory), OfficialAppsTest (register⇔catalog⇔registry⇔spec gate).

186/186 unit tests green (JDK 21).
This commit is contained in:
John Smith
2026-09-11 03:53:22 -04:00
parent 529f3aef2d
commit 1acdf2d64b
12 changed files with 502 additions and 10 deletions
@@ -51,6 +51,7 @@ class DoradoGraph(
val cloudSignInCallback: com.heretek.dorado_hd.cloud.CloudSignInCallback,
val cloudUpdates: com.heretek.dorado_hd.cloud.CloudUpdateService,
val playCounts: com.heretek.dorado_hd.analysis.PlayCountStore,
val appState: com.heretek.dorado_hd.data.repo.AppStateRepository,
)
class DoradoApp : Application() {
@@ -120,11 +121,12 @@ class DoradoApp : Application() {
)
val cloudUpdates = com.heretek.dorado_hd.cloud.CloudUpdateService(settings = { latestSettings.get() })
val equalizer = com.heretek.dorado_hd.media.EqualizerController()
val appState = com.heretek.dorado_hd.data.repo.AppStateRepository(db)
graph = DoradoGraph(
library, quickplay, settings, settings.settings, controller, nav,
artistImages, artistBios, notes, calendar, alarms, radio, podcasts, games, deviceLink, analysis, mixes, scrobble, lyrics,
cloudSignIn, cloudSignInCallback, cloudUpdates, playCountStore,
cloudSignIn, cloudSignInCallback, cloudUpdates, playCountStore, appState,
)
appScope.launch {
@@ -328,6 +328,21 @@ interface GameScoreDao {
suspend fun insert(score: GameScoreEntity): Long
}
@Dao
interface AppStateDao {
@Query("SELECT * FROM app_state WHERE app = :app")
suspend fun get(app: String): AppStateEntity?
@Query("SELECT * FROM app_state WHERE app = :app")
fun observe(app: String): Flow<AppStateEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun put(state: AppStateEntity)
@Query("DELETE FROM app_state WHERE app = :app")
suspend fun delete(app: String)
}
@Dao
interface TrackFeatureDao {
@Query("SELECT * FROM track_features")
@@ -24,8 +24,9 @@ import androidx.room.RoomDatabase
TrackFeatureEntity::class,
ScrobbleEntity::class,
PlayCountEntity::class,
AppStateEntity::class,
],
version = 5,
version = 6,
exportSchema = false,
)
abstract class DoradoDatabase : RoomDatabase() {
@@ -44,6 +45,7 @@ abstract class DoradoDatabase : RoomDatabase() {
abstract fun trackFeatureDao(): TrackFeatureDao
abstract fun scrobbleDao(): ScrobbleDao
abstract fun playCountDao(): PlayCountDao
abstract fun appStateDao(): AppStateDao
companion object {
@Volatile
@@ -52,7 +54,7 @@ abstract class DoradoDatabase : RoomDatabase() {
fun get(context: Context): DoradoDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(context, DoradoDatabase::class.java, "dorado_hd.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.build()
.also { instance = it }
}
@@ -165,6 +165,18 @@ data class GameScoreEntity(
val playedAt: Long,
)
/**
* Per-app opaque state blob (official-app reimplementation program, Phase 2).
* Games and stateful utilities persist a self-describing string here so save /
* resume, board snapshots, and settings travel with the app slug.
*/
@Entity(tableName = "app_state")
data class AppStateEntity(
@PrimaryKey val app: String,
val value: String,
val updatedAt: Long,
)
/** Cached M9.3 audio-feature vector per track (metadata prior or DSP-refined). */
@Entity(tableName = "track_features")
data class TrackFeatureEntity(
@@ -152,3 +152,21 @@ val MIGRATION_4_5 = object : Migration(4, 5) {
)
}
}
/**
* Room v5 → v6: official-app reimplementation program. A per-app opaque state
* blob (`app_state`) backs save/resume, board snapshots and app settings.
* Existing data untouched.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS `app_state` (
`app` TEXT NOT NULL,
`value` TEXT NOT NULL,
`updatedAt` INTEGER NOT NULL,
PRIMARY KEY(`app`)
)""",
)
}
}
@@ -50,8 +50,8 @@ object OfficialCatalog {
OfficialApp("Dr Optics Light Lab", "DrOptics.exe", GAMES, "Manipulate mirrors, lenses, and polygons."),
OfficialApp("Drum Machine", "DrumMachine.exe", MUSIC, "Create simple drum patterns.", "drummachine"),
OfficialApp("Echoes", "Echoes.exe", GAMES, "Action puzzle game."),
OfficialApp("Email", "ZuneHDEmail.exe", NETWORKING, "Hotmail, Gmail and Exchange."),
OfficialApp("Facebook", "Facebook.exe", SOCIAL, "Connect and share."),
OfficialApp("Email", "ZuneHDEmail.exe", NETWORKING, "Hotmail, Gmail and Exchange.", "email"),
OfficialApp("Facebook", "Facebook.exe", SOCIAL, "Connect and share.", "facebook"),
OfficialApp("Fan Prediction", "FanPrediction.exe", GAMES, "Predict college and pro sports outcomes."),
OfficialApp("Finger Physics", "FingerPhysics.exe", GAMES, "10 stages, 90 levels of puzzle awesomeness."),
OfficialApp("Fingerpaint", "Fingerpaint.exe", GAMES, "Doodle to your heart's content."),
@@ -62,7 +62,7 @@ object OfficialCatalog {
OfficialApp("Labyrinth", "Labyrinth.exe", GAMES, "Lead your marble through a colorful world."),
OfficialApp("Level", "Level.exe", UTILITIES, "Spirit level and surface level in one.", "level"),
OfficialApp("Lucky Lanes Bowling", "bowling.exe", GAMES, "Exhibition, blackjack and golf modes."),
OfficialApp("MSN Money", "MSNMoney.exe", READING, "Comprehensive money and finance news."),
OfficialApp("MSN Money", "MSNMoney.exe", READING, "Comprehensive money and finance news.", "msnmoney"),
OfficialApp("Metronome", "Metronome.exe", MUSIC, "Quick and easy while practicing.", "metronome"),
OfficialApp("Music Quiz", "MusicQuiz.exe", MUSIC, "How well do you know your music?", "musicquiz"),
OfficialApp("Notes", "Notepad.exe", UTILITIES, "Keep track of anything and everything.", "notes"),
@@ -87,13 +87,13 @@ object OfficialCatalog {
OfficialApp("Tiles", "Tiles.exe", GAMES, "Classic tile-matching puzzle."),
OfficialApp("Trash Throw", "TrashThrow.exe", GAMES, "The perfect time waster."),
OfficialApp("Tug-O-War", "Tuginator.exe", GAMES, "Fun pick-up and play."),
OfficialApp("Twitter", "Twitter.exe", SOCIAL, "Real-time information network."),
OfficialApp("Twitter", "Twitter.exe", SOCIAL, "Real-time information network.", "twitter"),
OfficialApp("Vans Sk8 Pool Service", "Vans.exe", GAMES, "Skate the rooftops and abandoned lots."),
OfficialApp("Vine Climb", "VineClimb.exe", GAMES, "A hungry monkey climbs the vines."),
OfficialApp("Weather", "Weather.exe", READING, "Forecasts and conditions around the world."),
OfficialApp("Messenger", "Microsoft.Live.Messenger.Client.exe", NETWORKING, "Stay in touch with Messenger."),
OfficialApp("Weather", "Weather.exe", READING, "Forecasts and conditions around the world.", "weather"),
OfficialApp("Messenger", "Microsoft.Live.Messenger.Client.exe", NETWORKING, "Stay in touch with Messenger.", "messenger"),
OfficialApp("WordMonger", "WordMonger.exe", GAMES, "Word game that's fresh, original and fun."),
OfficialApp("Zune Reader", "ZuneReaderHD.Core.exe", READING, "Read your favorite books on the go."),
OfficialApp("Zune Reader", "ZuneReaderHD.Core.exe", READING, "Read your favorite books on the go.", "zunereader"),
)
fun byInstalled(id: String): OfficialApp? = all.firstOrNull { it.installedId == id }
@@ -1,6 +1,7 @@
package com.heretek.dorado_hd.data.repo
import com.heretek.dorado_hd.data.db.AlarmEntity
import com.heretek.dorado_hd.data.db.AppStateEntity
import com.heretek.dorado_hd.data.db.AppointmentEntity
import com.heretek.dorado_hd.data.db.GameScoreEntity
import com.heretek.dorado_hd.data.db.NoteEntity
@@ -89,3 +90,15 @@ class GameRepository(private val db: DoradoDatabase) {
),
)
}
/**
* Opaque per-app state (Phase 2 of the official-app program). Engines serialize
* their own compact format; the store only moves strings in and out of Room.
*/
class AppStateRepository(private val db: DoradoDatabase) {
fun observe(app: String): Flow<String?> = db.appStateDao().observe(app).map { it?.value }
suspend fun get(app: String): String? = db.appStateDao().get(app)?.value
suspend fun put(app: String, value: String) =
db.appStateDao().put(AppStateEntity(app, value, System.currentTimeMillis()))
suspend fun clear(app: String) = db.appStateDao().delete(app)
}
@@ -0,0 +1,70 @@
package com.heretek.dorado_hd.ui.apps
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import kotlin.math.atan2
import kotlin.math.sqrt
/** Smoothed device tilt in degrees; [available] is false without hardware. */
data class TiltState(
val rollDeg: Float = 0f,
val pitchDeg: Float = 0f,
val available: Boolean = false,
)
/**
* Shared accelerometer helper for mini-apps (level, tilt-mode calculator,
* motion games). Follows the Level app's existing convention: roll/pitch from
* the gravity vector, UI-rate sampling, exponential smoothing.
*/
@Composable
fun rememberTilt(enabled: Boolean = true, smoothing: Float = 0.85f): State<TiltState> {
val context = LocalContext.current
val state = remember { mutableStateOf(TiltState()) }
DisposableEffect(enabled, context) {
if (!enabled) {
state.value = TiltState()
return@DisposableEffect onDispose { }
}
val manager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
val sensor = manager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (manager == null || sensor == null) {
state.value = TiltState(available = false)
return@DisposableEffect onDispose { }
}
val smooth = FloatArray(3)
var primed = false
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val raw = event.values
if (!primed) {
smooth[0] = raw[0]; smooth[1] = raw[1]; smooth[2] = raw[2]
primed = true
}
for (i in 0..2) {
smooth[i] = smoothing * smooth[i] + (1f - smoothing) * raw[i]
}
val x = smooth[0]
val y = smooth[1]
val z = smooth[2]
val roll = Math.toDegrees(atan2(y.toDouble(), z.toDouble())).toFloat()
val pitch = Math.toDegrees(atan2(-x.toDouble(), sqrt((y * y + z * z).toDouble()))).toFloat()
state.value = TiltState(roll, pitch, available = true)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME)
onDispose { manager.unregisterListener(listener) }
}
return state
}
@@ -0,0 +1,144 @@
package com.heretek.dorado_hd.ui.apps
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.sin
/**
* Deterministic white noise (LCG) so synthesized game SFX are reproducible in
* tests and identical for every player. The generator functions in `Synth.kt`
* predate this and use `Math.random()`; new apps should prefer [Noise].
*/
class Noise(seed: Int = 0x51ED270B.toInt()) {
private var state: Int = seed
fun next(): Double {
state = state * 1103515245 + 12345
return ((state ushr 16) and 0x7FFF) / 16383.5 - 1.0
}
}
private fun envelope(t: Double, decay: Double): Double = exp(-decay * t)
/** Short sine blip; the workhorse UI/game cue. */
fun sfxTone(
freqHz: Double,
durationMs: Int,
sampleRate: Int = 22050,
decay: Double = 5.0,
gain: Double = 0.35,
): DoubleArray {
val n = (durationMs * sampleRate / 1000).coerceAtLeast(1)
val out = DoubleArray(n)
val w = 2 * PI * freqHz / sampleRate
for (i in 0 until n) {
val t = i.toDouble() / n
out[i] = sin(i * w) * envelope(t, decay) * gain
}
return out
}
/** Pitch sweep (jump, whoosh, power-up). */
fun sfxSweep(
fromHz: Double,
toHz: Double,
durationMs: Int,
sampleRate: Int = 22050,
gain: Double = 0.35,
decay: Double = 2.5,
): DoubleArray {
val n = (durationMs * sampleRate / 1000).coerceAtLeast(1)
val out = DoubleArray(n)
var phase = 0.0
for (i in 0 until n) {
val t = i.toDouble() / n
val freq = fromHz + (toHz - fromHz) * t
phase += 2 * PI * freq / sampleRate
out[i] = sin(phase) * envelope(t, decay) * gain
}
return out
}
/** Noise burst with a low-pass shelf (hit, explosion, splat). */
fun sfxNoise(
durationMs: Int,
sampleRate: Int = 22050,
gain: Double = 0.4,
decay: Double = 4.0,
seed: Int = 1,
lowPass: Double = 0.5,
): DoubleArray {
val n = (durationMs * sampleRate / 1000).coerceAtLeast(1)
val out = DoubleArray(n)
val rng = Noise(seed)
var prev = 0.0
for (i in 0 until n) {
val t = i.toDouble() / n
val raw = rng.next()
prev += lowPass * (raw - prev)
out[i] = prev * envelope(t, decay) * gain
}
return out
}
/** Two-note cue (success / selection confirm). */
fun sfxArpeggio(
freqs: List<Double>,
noteMs: Int = 90,
sampleRate: Int = 22050,
gain: Double = 0.3,
): DoubleArray {
val parts = freqs.map { f -> sfxTone(f, noteMs, sampleRate, decay = 3.0, gain = gain) }
val total = parts.sumOf { it.size }
val out = DoubleArray(total)
var pos = 0
for (p in parts) {
p.copyInto(out, pos)
pos += p.size
}
return out
}
/** Every cue the [SfxBank] can synthesize. */
val GAME_SFX_NAMES: List<String> = listOf(
"select", "back", "tick", "click", "score", "coin", "win", "lose",
"hit", "explode", "pop", "jump", "whoosh", "rotate", "error", "toss",
)
/**
* Named, cached SFX bank for the official-app games. Sounds are synthesized
* on first use; nothing is loaded from disk and no sampled audio ships.
*/
class SfxBank(private val synth: MiniSynth, val sampleRate: Int = 22050) {
private val cache = mutableMapOf<String, DoubleArray>()
fun samples(name: String): DoubleArray = cache.getOrPut(name) {
when (name) {
"select" -> sfxTone(880.0, 60, sampleRate, decay = 6.0, gain = 0.3)
"back" -> sfxTone(440.0, 70, sampleRate, decay = 5.0, gain = 0.3)
"tick" -> sfxNoise(24, sampleRate, gain = 0.32, decay = 8.0, seed = 11, lowPass = 0.9)
"click" -> sfxNoise(18, sampleRate, gain = 0.28, decay = 9.0, seed = 3, lowPass = 0.95)
"score" -> sfxArpeggio(listOf(660.0, 990.0), sampleRate = sampleRate)
"coin" -> sfxArpeggio(listOf(988.0, 1319.0), noteMs = 55, sampleRate = sampleRate)
"win" -> sfxArpeggio(listOf(523.0, 659.0, 784.0, 1047.0), noteMs = 110, sampleRate = sampleRate)
"lose" -> sfxSweep(392.0, 130.0, 380, sampleRate, gain = 0.32, decay = 1.4)
"hit" -> sfxNoise(90, sampleRate, gain = 0.45, decay = 6.0, seed = 7, lowPass = 0.35)
"explode" -> sfxNoise(320, sampleRate, gain = 0.5, decay = 3.2, seed = 5, lowPass = 0.18)
"pop" -> sfxTone(720.0, 45, sampleRate, decay = 10.0, gain = 0.35)
"jump" -> sfxSweep(300.0, 760.0, 160, sampleRate, gain = 0.3, decay = 2.0)
"whoosh" -> sfxNoise(180, sampleRate, gain = 0.3, decay = 3.5, seed = 13, lowPass = 0.08)
"rotate" -> sfxSweep(520.0, 340.0, 90, sampleRate, gain = 0.28, decay = 4.0)
"error" -> sfxTone(160.0, 140, sampleRate, decay = 3.0, gain = 0.35)
"toss" -> sfxSweep(500.0, 200.0, 120, sampleRate, gain = 0.25, decay = 3.0)
else -> sfxTone(600.0, 50, sampleRate, gain = 0.28)
}
}
fun play(name: String) {
synth.scope.launch { synth.playSamples(samples(name)) }
}
/** Every banked cue; used by tests to lock the inventory. */
fun names(): List<String> = GAME_SFX_NAMES
}
@@ -0,0 +1,67 @@
package com.heretek.dorado_hd
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.heretek.dorado_hd.data.db.DoradoDatabase
import com.heretek.dorado_hd.data.repo.AppStateRepository
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AppStateStoreTest {
@Test
fun `app state round-trips, overwrites and clears`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val db = Room.inMemoryDatabaseBuilder(context, DoradoDatabase::class.java)
.allowMainThreadQueries()
.build()
try {
val store = AppStateRepository(db)
assertNull(store.get("solitaire"))
store.put("solitaire", "deal=123")
assertEquals("deal=123", store.get("solitaire"))
assertEquals("deal=123", store.observe("solitaire").first())
assertTrue(store.get("solitaire")!!.isNotEmpty())
store.put("solitaire", "deal=456")
assertEquals("deal=456", store.get("solitaire"))
store.put("chess", "moves=e2e4")
assertEquals("deal=456", store.get("solitaire"))
assertEquals("moves=e2e4", store.get("chess"))
store.clear("solitaire")
assertNull(store.get("solitaire"))
assertEquals("moves=e2e4", store.get("chess"))
} finally {
db.close()
}
}
@Test
fun `state persists across a reopened database file`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val name = "app-state-test.db"
context.deleteDatabase(name)
try {
val first = Room.databaseBuilder(context, DoradoDatabase::class.java, name).build()
AppStateRepository(first).put("notesdraft", "hello")
first.close()
val second = Room.databaseBuilder(context, DoradoDatabase::class.java, name).build()
assertEquals("hello", AppStateRepository(second).get("notesdraft"))
second.close()
} finally {
context.deleteDatabase(name)
}
}
}
@@ -0,0 +1,91 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.official.OfficialCatalog
import com.heretek.dorado_hd.ui.apps.DoradoApps
import java.io.File
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Consistency gate for the official-app reimplementation program
* (`docs/official-apps.json` ⇔ `OfficialCatalog` ⇔ `DoradoApps` ⇔ specs).
*
* The register is the program's single source of truth: every official
* package is listed there with a wave, status and spec. This test keeps the
* Kotlin catalog and the app registry from drifting away from it.
*/
@RunWith(RobolectricTestRunner::class)
class OfficialAppsTest {
private val appDir: File
get() = if (File("src/main/java").exists()) File(".") else File("..")
private val repoRoot: File
get() = appDir.absoluteFile.normalize().parentFile ?: File(".")
private val register: JSONArray by lazy {
JSONArray(File(repoRoot, "docs/official-apps.json").readText())
}
private fun rows(): List<Map<String, String?>> =
(0 until register.length()).map { i ->
val o = register.getJSONObject(i)
o.keys().asSequence().associateWith { k ->
if (o.isNull(k)) null else o.optString(k).ifEmpty { null }
}
}
@Test
fun `register covers all 62 official packages with unique slugs`() {
assertEquals(62, register.length())
val slugs = rows().map { it["slug"] }
assertEquals(slugs.size, slugs.toSet().size)
assertTrue(slugs.all { !it.isNullOrBlank() })
}
@Test
fun `kotlin catalog matches the register title and exe for every entry`() {
val registerByTitle = rows().associateBy { it["title"] }
for (app in OfficialCatalog.all) {
val row = registerByTitle[app.title]
assertNotNull("register is missing '${app.title}'", row)
assertEquals("exe mismatch for ${app.title}", row!!["exe"], app.exe)
}
assertEquals(62, OfficialCatalog.all.size)
}
@Test
fun `every installed id in the register resolves to a registered mini-app`() {
val unresolved = rows()
.mapNotNull { it["installedId"] }
.distinct()
.filter { DoradoApps.byId(it) == null }
assertTrue("installedId without a DoradoApps entry: $unresolved", unresolved.isEmpty())
}
@Test
fun `native and mock apps have a spec and a resolvable id`() {
val problems = mutableListOf<String>()
for (row in rows().filter { it["status"] == "native" || it["status"] == "mock" }) {
val slug = row["slug"] ?: continue
val id = row["installedId"]
if (id.isNullOrBlank()) problems += "$slug: missing installedId"
if (id != null && DoradoApps.byId(id) == null) problems += "$slug: no registry entry"
if (!File(repoRoot, "docs/apps/$slug.md").exists()) problems += "$slug: missing spec"
}
assertTrue("official-app consistency problems: $problems", problems.isEmpty())
}
@Test
fun `every wave and status value is known`() {
val waves = setOf("W1", "W2", "W3", "W4", "W5", "W6", "W7")
val statuses = setOf("native", "mock", "todo")
val bad = rows().filter { it["wave"] !in waves || it["status"] !in statuses }
assertTrue("unknown wave/status: $bad", bad.isEmpty())
}
}
@@ -0,0 +1,58 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.ui.apps.GAME_SFX_NAMES
import com.heretek.dorado_hd.ui.apps.Noise
import com.heretek.dorado_hd.ui.apps.sfxArpeggio
import com.heretek.dorado_hd.ui.apps.sfxNoise
import com.heretek.dorado_hd.ui.apps.sfxSweep
import com.heretek.dorado_hd.ui.apps.sfxTone
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Pure-JVM checks for the synthesized SFX bank (no Android dependency). */
class SfxTest {
@Test
fun `noise is deterministic per seed and differs across seeds`() {
val a = Noise(42)
val b = Noise(42)
val c = Noise(43)
val first = (1..64).map { a.next() }
val second = (1..64).map { b.next() }
assertEquals(first, second)
assertNotEquals(first, (1..64).map { c.next() })
assertTrue(first.all { it in -1.0..1.0 })
}
@Test
fun `tone length follows duration and is reproducible`() {
val one = sfxTone(440.0, 100)
val two = sfxTone(440.0, 100)
assertEquals(2205, one.size)
assertArrayEquals(one, two, 0.0)
assertTrue(one.any { it != 0.0 })
}
@Test
fun `sweep, noise and arpeggio produce bounded non-silent buffers`() {
val sweep = sfxSweep(200.0, 900.0, 150)
val noise = sfxNoise(120, seed = 9)
val chord = sfxArpeggio(listOf(440.0, 660.0))
for (buf in listOf(sweep, noise, chord)) {
assertTrue(buf.isNotEmpty())
assertTrue(buf.all { it in -1.0..1.0 })
assertTrue(buf.any { kotlin.math.abs(it) > 0.001 })
}
assertEquals(150 * 22050 / 1000, sweep.size)
}
@Test
fun `sfx bank inventory is unique and complete`() {
assertEquals(16, GAME_SFX_NAMES.size)
assertEquals(GAME_SFX_NAMES.size, GAME_SFX_NAMES.toSet().size)
assertTrue(GAME_SFX_NAMES.contains("win") && GAME_SFX_NAMES.contains("explode"))
}
}