Eight modules each had a copy of the same configuration-context harness (ScreenSnapshots/Snapshots); M12f had to fix the same capture bug in two of them. :core:design's test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing) now hold Variant, TestClock, TestEnvironment, ScreenSnapshots with its three capture modes (Root; Screen, with the hand-advanced frame clock; ScreenWhenIdle), MainDispatcherRule and withMain. Test fixtures rather than a :core:testing module: :core:design's own tests can use them without a dependency on itself, no module is added to the §6.2 graph, and lint treats them as test sources. The architecture check rejects test fixtures outside test configurations. Modules keep only their own frame (analytics household, settle size and inline overlays, settings avatar pictures). Every golden is unchanged. kvitt.android.screenshots applies Roborazzi with outputDir src/test/screenshots and adds the screenshot test libraries; the feature plugin applies it and adds the test fixtures, so the seven feature build scripts lose their copied blocks. Removed: roborazzi-junit-rule and roborazzi-compose (never imported), the explicit lifecycle-viewmodel-compose (feature plugin, :app) and hilt-lifecycle-viewmodel-compose in :app (both still arrive through the features; release and debug runtime classpaths are identical), and the unused catalog entries androidx-hilt-navigation-compose and androidx-lifecycle-viewmodel-compose. @Before/@After setMain pairs in nine test classes use MainDispatcherRule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
Kvitt Android project
One Gradle project, modules as in SPEC §6.2 and §14.7. Build only through tools/gradle-serial.sh with
PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH (CLAUDE.md › Build and verify). The gate is tools/gradle-serial.sh check:
compile, unit tests (including every vector of contracts/test-vectors/), lint (MissingTranslation and
ExtraTranslation are errors), the architecture check and the screenshot tests.
Modules
| Module | Plugin (build-logic) | Notes |
|---|---|---|
:core:model |
kvitt.jvm.library |
UI contract (§14.2.4), domain types model.money (§14.5.1), analytics types model.analytics (§14.5.4) |
:domain |
kvitt.jvm.library |
§14.5 functions; VectorTest runs every vector file, copied by :domain:copyTestVectors |
:core:design |
kvitt.android.library + kvitt.android.compose |
KvittTheme, tokens, shared components, Canvas charts, formatters and shared strings (M6, 3080804; fixes M6b, 3a1bd14; SPEC §6.2); M6 owns the module |
:core:fakes |
kvitt.android.library |
FakeRepositories(scenario), FakeScenarios.empty; scenarios of feature X go in fakes/<x>/ (added by M7a to M7f) |
:data |
kvitt.android.library + KSP + Room 3 |
Room entities and KvittDatabase v1, schema in data/schemas/; M5 owns the module |
:adapter:fcm |
kvitt.android.library |
shell; M8 owns it |
:feature:* |
kvitt.android.feature |
screens built by M7a to M7f (landed 2026-10-18), goldens in feature/<x>/src/test/screenshots/ (full screen 411 × 891 dp at 1x, D-098); top-level screens draw their own KvittNavigationBar (SPEC §13.2, D-071); M7g does the cleanups after M6b |
:app |
kvitt.android.application + compose + hilt + Roborazzi |
Application, MainActivity, goldens in app/src/test/screenshots/; release binds :data (DataBindingsModule, e6ad858), debug binds the fakes in src/debug/.../DebugFakesModule.kt (72f21b2) until M8 deletes it; M8 owns the module |
Architecture check
architectureCheck runs in every module's check (build-logic/.../ArchitectureRules.kt holds the rules as data).
It fails on a project dependency outside the §6.2 table, on :core:fakes in any configuration that is not a test or
debug configuration, and, in :core:model and :domain, on an Android plugin, on a library outside
org.jetbrains.kotlin*, and on any import or fully qualified reference outside kotlin.*, kotlinx.* and
se.hagstedt.kvitt.* in src/main. A new module must be added to ArchitectureRules.allowed.
Feature convention
-
Each feature module exposes one composable entry point per screen. By default it obtains its ViewModel with
hiltViewModel()(fromandroidx.hilt:hilt-lifecycle-viewmodel-compose) and takes navigation as plain lambdas, for example:@Composable fun AccountDetailScreen( onOpenExpense: (ExpenseId) -> Unit, onAddExpense: (AccountId) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: AccountDetailViewModel = hiltViewModel(), )A stateless overload that takes the
*Stateand event lambdas is what previews and screenshot tests render. -
ViewModels are
@HiltViewModelclasses that receive repositories through the interfaces of:core:modelonly.:appbinds those interfaces to:datawith Hilt (release sincee6ad858, debug too once M8 deletes the temporary debug fakes module); tests and previews use:core:fakes. -
Features never reference another feature, the
NavHostor:data/:domain.:appowns theNavHostand wires the lambdas. -
Strings live in the feature's own
src/main/res/values/strings.xmlandvalues-sv/strings.xml, keys prefixed with the feature name; shared strings in:core:design;:appholds only the app name. -
Previews and screenshot tests use
FakeRepositories(scenario)from:core:fakes(debugImplementationandtestImplementation, added by the feature plugin). Fakes never reach a release build.
Screenshot tests
Roborazzi on Robolectric. roborazzi.test.verify=true in gradle.properties makes every unit-test run compare with the
committed goldens. Re-record an intended change with tools/gradle-serial.sh recordRoborazziDebug and open every
changed PNG before committing.
Golden layout (D-098, revising D-066): three variants per state, <name>_sv_light.png, <name>_sv_dark.png and
<name>_en_light.png (English covers string length, dark covers the theme; there is no en_dark), plus
<name>_sv_light_200.png at 200 % font scale for the long states. Every capture renders at 1x density: the
Robolectric qualifier is w411dp-h<height>dp-mdpi, so the Galaxy S26 viewport of 411 × 891 dp is 411 × 891 px and a
golden is a true 1x render, not a downscaled one. :app's empty_screen_<light|dark>.png are the only other names.