mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-11 21:10:01 +00:00
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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user