feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.
Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.
Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.
Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
eaedeb398c
commit
8e6dabd4a4
|
|
@ -107,3 +107,6 @@ seed/questions/rebuilding_trust_BATCH1_NOTE.md
|
||||||
# Python bytecode from the seed scripts
|
# Python bytecode from the seed scripts
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Asset-db backups must never live under assets/ — everything there ships in the APK
|
||||||
|
app/src/main/assets/database/*.bak*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
package app.closer.data.local
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-device verification that the bundled question bank in `assets/database/app.db` is one Room
|
||||||
|
* will actually load. This is the net for the class of failure that no JVM test or static check can
|
||||||
|
* catch: the asset is opened by Room at runtime via `createFromAsset` with **no migrations and no
|
||||||
|
* destructive fallback** (see `di/DatabaseModule.kt`), so a schema or identity-hash mismatch is a
|
||||||
|
* hard crash on first DB touch — for every user, on a fresh install.
|
||||||
|
*
|
||||||
|
* `build_db.py` regenerates that asset from the JSON packs in seed/questions. It previously emitted 2 of
|
||||||
|
* the 4 entity tables and no `room_master_table`, which would have crashed the app; this test exists
|
||||||
|
* so that can never ship silently again. Building the database is not enough to prove anything —
|
||||||
|
* Room opens lazily, so the test forces a real open (`openHelper.readableDatabase`), which is what
|
||||||
|
* triggers the copy and the identity check.
|
||||||
|
*
|
||||||
|
* Run on a THROWAWAY emulator: connectedDebugAndroidTest uninstalls the app-under-test, which wipes
|
||||||
|
* fixture data. Never run against the 5554/5556 fixture couple.
|
||||||
|
* adb -s emulator-5558 shell am instrument -w -e class app.closer.data.local.AssetDatabaseVerifyTest \
|
||||||
|
* closer.app.test/androidx.test.runner.AndroidJUnitRunner
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AssetDatabaseVerifyTest {
|
||||||
|
|
||||||
|
private lateinit var db: AppDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
// Mirrors DatabaseModule.provideAppDatabase, under a throwaway name so the real db is untouched.
|
||||||
|
db = Room.databaseBuilder(context, AppDatabase::class.java, "asset_verify.db")
|
||||||
|
.createFromAsset("database/app.db")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase("asset_verify.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The critical assertion: opening is what makes Room copy the asset and validate its schema +
|
||||||
|
* identity hash against the compiled entities. If the asset were missing a table or carried the
|
||||||
|
* wrong hash, this throws rather than returning.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun assetDatabase_opensAndPassesRoomSchemaValidation() {
|
||||||
|
val open = db.openHelper.readableDatabase
|
||||||
|
assertTrue("Room could not open the bundled asset database", open.isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun assetDatabase_hasQuestionsAndCategories() {
|
||||||
|
val open = db.openHelper.readableDatabase
|
||||||
|
fun count(sql: String): Int =
|
||||||
|
open.query(sql).use { if (it.moveToFirst()) it.getInt(0) else -1 }
|
||||||
|
|
||||||
|
assertTrue("no questions in the bundled bank", count("SELECT COUNT(*) FROM question") > 0)
|
||||||
|
assertTrue(
|
||||||
|
"no categories in the bundled bank",
|
||||||
|
count("SELECT COUNT(*) FROM question_category") > 0
|
||||||
|
)
|
||||||
|
// Every question must belong to a real category — the old importer's filename fallback
|
||||||
|
// silently filed packs under a bogus "unknown" category that the app never shows.
|
||||||
|
assertEquals(
|
||||||
|
"questions reference a category that does not exist",
|
||||||
|
0,
|
||||||
|
count(
|
||||||
|
"SELECT COUNT(*) FROM question q LEFT JOIN question_category c " +
|
||||||
|
"ON q.category_id = c.id WHERE c.id IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth is read as an Int by [app.closer.data.local.entity.QuestionEntity]. String depth would be
|
||||||
|
* coerced to 0 and silently break depth routing, so assert the column really is integer-typed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun assetDatabase_depthIsIntegerTyped() {
|
||||||
|
val open = db.openHelper.readableDatabase
|
||||||
|
val bad = open.query(
|
||||||
|
"SELECT COUNT(*) FROM question WHERE typeof(depth_level) <> 'integer'"
|
||||||
|
).use { if (it.moveToFirst()) it.getInt(0) else -1 }
|
||||||
|
assertEquals("depth_level has non-integer values", 0, bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the answer_config contract: the mapper reads camelCase keys, so a snake_case config
|
||||||
|
* parses to blank labels and the scale UI silently renders bare numbers instead of the labels.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun assetDatabase_scaleQuestionsCarryLabelsTheMapperCanRead() {
|
||||||
|
val open = db.openHelper.readableDatabase
|
||||||
|
open.query(
|
||||||
|
"SELECT id, answer_config FROM question WHERE type = 'scale' LIMIT 1"
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return // no scale questions in the bank; nothing to assert
|
||||||
|
val raw = cursor.getString(1)
|
||||||
|
assertTrue("scale answer_config is missing the mapper's minLabel key: $raw",
|
||||||
|
raw.contains("minLabel"))
|
||||||
|
assertTrue("scale answer_config is missing the mapper's minScale key: $raw",
|
||||||
|
raw.contains("minScale"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -33,6 +33,7 @@ import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
@ -42,6 +43,9 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||||
JSON_DIR = ROOT / "seed" / "questions"
|
JSON_DIR = ROOT / "seed" / "questions"
|
||||||
SCHEMA_DIR = ROOT / "app" / "schemas" / "app.closer.data.local.AppDatabase"
|
SCHEMA_DIR = ROOT / "app" / "schemas" / "app.closer.data.local.AppDatabase"
|
||||||
ASSET_DB = ROOT / "app" / "src" / "main" / "assets" / "database" / "app.db"
|
ASSET_DB = ROOT / "app" / "src" / "main" / "assets" / "database" / "app.db"
|
||||||
|
# Everything under assets/ is packaged into the APK, so backups must NOT live there —
|
||||||
|
# a stray .bak beside the db silently ships to every user. /build is gitignored.
|
||||||
|
BACKUP_DIR = ROOT / "build" / "db-backups"
|
||||||
|
|
||||||
VALID_TYPES = {"written", "single_choice", "multi_choice", "scale", "this_or_that"}
|
VALID_TYPES = {"written", "single_choice", "multi_choice", "scale", "this_or_that"}
|
||||||
VALID_ACCESS = {"free", "premium"}
|
VALID_ACCESS = {"free", "premium"}
|
||||||
|
|
@ -333,9 +337,11 @@ def main() -> int:
|
||||||
verify(staged, db_schema)
|
verify(staged, db_schema)
|
||||||
|
|
||||||
if replacing_asset and args.out.exists():
|
if replacing_asset and args.out.exists():
|
||||||
backup = args.out.with_suffix(".db.bak")
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup = BACKUP_DIR / f"app.db.{stamp}.bak"
|
||||||
shutil.copy2(args.out, backup)
|
shutil.copy2(args.out, backup)
|
||||||
print(f"Backed up existing asset db -> {backup.name}")
|
print(f"Backed up existing asset db -> {backup.relative_to(ROOT)}")
|
||||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(staged, args.out)
|
shutil.copy2(staged, args.out)
|
||||||
except ContentError as e:
|
except ContentError as e:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue