One app fixture for the tests, and ViewModels built where they are used
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m31s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m41s
Android / Build the server image (push) Successful in 1s

The tests' `app` fixture, the same two lines in eleven files, now lives once in
tests/conftest.py.

On Android each ViewModel carried a companion `factory` that only wrapped its
constructor in viewModelFactory { initializer { … } }. MainActivity now builds
each one with Compose's viewModel { … } builder, so the five factories and their
imports are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 21:25:05 -04:00
co-authored by Claude Opus 5.5
parent 463b22bbe4
commit b74dc7c1fe
18 changed files with 21 additions and 145 deletions
@@ -219,15 +219,16 @@ private fun App(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val board: BoardViewModel = val board: BoardViewModel =
viewModel( viewModel {
factory = BoardViewModel(
BoardViewModel.factory(core, readFile = files::read) { core,
// Any store write can have moved the next reminder. Called on // Any store write can have moved the next reminder. Called on the IO
// the IO dispatcher by the view model, which is where it has to // dispatcher by the view model, which is where it has to be — this
// be — this reads every note carrying a reminder. // reads every note carrying a reminder.
Reminders.refresh(context, core) onRemindersChanged = { Reminders.refresh(context, core) },
}, readFile = files::read,
) )
}
// Consumed, not just read: without clearing it, every later recomposition // Consumed, not just read: without clearing it, every later recomposition
// would reopen the same note and make the editor impossible to leave. // would reopen the same note and make the editor impossible to leave.
@@ -251,7 +252,7 @@ private fun App(
// A pull can rewrite every note the board is holding, so a sync that changed // A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both. // anything tells it to reload. Wired here, at the one place that owns both.
val sync: SyncViewModel = val sync: SyncViewModel =
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh)) viewModel { SyncViewModel(core, onStoreChanged = board::refresh) }
// Screen visibility is view STATE, not view-model state: it is about what is on // Screen visibility is view STATE, not view-model state: it is about what is on
// the display, and nothing in the store cares. Saveable so a rotation does not // the display, and nothing in the store cares. Saveable so a rotation does not
@@ -266,14 +267,14 @@ private fun App(
// board may be LOOKING at one that a delete or a merge just removed. Both are // board may be LOOKING at one that a delete or a merge just removed. Both are
// `refreshLabels`, which also leaves a lens whose tag stopped existing. // `refreshLabels`, which also leaves a lens whose tag stopped existing.
val tags: TagsViewModel = val tags: TagsViewModel =
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels)) viewModel { TagsViewModel(core, onStoreChanged = board::refreshLabels) }
// A share landing changes the note's `shared` flag, which the board's card chip // A share landing changes the note's `shared` flag, which the board's card chip
// reads, so the board reloads after one. // reads, so the board reloads after one.
val share: ShareViewModel = val share: ShareViewModel =
viewModel(factory = ShareViewModel.factory(core, onStoreChanged = board::refresh)) viewModel { ShareViewModel(core, onStoreChanged = board::refresh) }
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context)) val update: UpdateViewModel = viewModel { UpdateViewModel(core, context.applicationContext) }
val settings = remember(context) { SyncSettings(context) } val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) } var automatic by remember { mutableStateOf(settings.automatic) }
@@ -5,10 +5,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note import com.fabledsword.inkwell.core.Note
@@ -660,13 +657,6 @@ class BoardViewModel(
/** The board and the Archive together — everything not in Trash. */ /** The board and the Archive together — everything not in Trash. */
private const val VIEW_ALL = "all" private const val VIEW_ALL = "all"
fun factory(
core: Inkwell,
readFile: (Uri) -> PickedFile,
onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory =
viewModelFactory { initializer { BoardViewModel(core, onRemindersChanged, readFile) } }
} }
} }
@@ -4,10 +4,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.CoreException import com.fabledsword.inkwell.core.CoreException
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Member import com.fabledsword.inkwell.core.Member
@@ -132,11 +129,4 @@ class ShareViewModel(
} }
} }
} }
companion object {
fun factory(
core: Inkwell,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory = viewModelFactory { initializer { ShareViewModel(core, onStoreChanged) } }
}
} }
@@ -4,10 +4,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.Compatibility import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.ProbeResult import com.fabledsword.inkwell.core.ProbeResult
@@ -315,13 +312,6 @@ class SyncViewModel(
fun dismissSyncError() { fun dismissSyncError() {
state = state.copy(syncError = null) state = state.copy(syncError = null)
} }
companion object {
fun factory(
core: Inkwell,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory = viewModelFactory { initializer { SyncViewModel(core, onStoreChanged) } }
}
} }
/** /**
@@ -4,10 +4,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label import com.fabledsword.inkwell.core.Label
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -163,11 +160,4 @@ class TagsViewModel(
onStoreChanged() onStoreChanged()
} }
} }
companion object {
fun factory(
core: Inkwell,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory = viewModelFactory { initializer { TagsViewModel(core, onStoreChanged) } }
}
} }
@@ -5,10 +5,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.AppUpdate import com.fabledsword.inkwell.AppUpdate
import com.fabledsword.inkwell.UpdateOutcome import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.inkwell.core.ClientUpdate import com.fabledsword.inkwell.core.ClientUpdate
@@ -214,14 +211,6 @@ class UpdateViewModel(
fun dismissError() { fun dismissError() {
state = state.copy(error = null) state = state.copy(error = null)
} }
companion object {
fun factory(
core: Inkwell,
context: Context,
): ViewModelProvider.Factory =
viewModelFactory { initializer { UpdateViewModel(core, context.applicationContext) } }
}
} }
private const val FALLBACK = "The update couldn't be checked." private const val FALLBACK = "The update couldn't be checked."
+7
View File
@@ -1,5 +1,6 @@
import pytest import pytest
from inkwell.app import create_app
from inkwell.config import Config from inkwell.config import Config
@@ -10,3 +11,9 @@ def _isolated_data_dir(tmp_path, monkeypatch):
signing key from env-or-random, not a file), but this stays defensive.""" signing key from env-or-random, not a file), but this stays defensive."""
monkeypatch.setattr(Config, "DATA_DIR", str(tmp_path / "data")) monkeypatch.setattr(Config, "DATA_DIR", str(tmp_path / "data"))
yield yield
@pytest.fixture
def app():
"""A fresh app for a test that drives routes through `app.test_client()`."""
return create_app()
-10
View File
@@ -1,13 +1,3 @@
import pytest
from inkwell.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_settings_requires_auth(app): async def test_settings_requires_auth(app):
# require_admin returns 401 before any DB access when unauthenticated. # require_admin returns 401 before any DB access when unauthenticated.
client = app.test_client() client = app.test_client()
-7
View File
@@ -1,13 +1,6 @@
import pytest
from inkwell.app import create_app from inkwell.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_health_ok(app): async def test_health_ok(app):
client = app.test_client() client = app.test_client()
resp = await client.get("/api/health") resp = await client.get("/api/health")
-6
View File
@@ -4,7 +4,6 @@ from pathlib import Path
import pytest import pytest
from inkwell import client_dist from inkwell import client_dist
from inkwell.app import create_app
from inkwell.client_dist import ( from inkwell.client_dist import (
BY_ID, BY_ID,
PLATFORMS, PLATFORMS,
@@ -54,11 +53,6 @@ def _empty_baked_client(tmp_path, monkeypatch):
yield yield
@pytest.fixture
def app():
return create_app()
def place( def place(
platform_id: str = "android", platform_id: str = "android",
payload: bytes = PAYLOAD, payload: bytes = PAYLOAD,
-10
View File
@@ -1,13 +1,3 @@
import pytest
from inkwell.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_create_device_requires_auth(app): async def test_create_device_requires_auth(app):
# No session cookie and no bearer header → 401 before any DB access. # No session cookie and no bearer header → 401 before any DB access.
client = app.test_client() client = app.test_client()
-6
View File
@@ -1,7 +1,6 @@
import pytest import pytest
from inkwell import installer, settings from inkwell import installer, settings
from inkwell.app import create_app
from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address
# DB-free, like test_client_dist: the route reads `public_url` from the live cache # DB-free, like test_client_dist: the route reads `public_url` from the live cache
@@ -17,11 +16,6 @@ def _defaults():
settings.reset_live() settings.reset_live()
@pytest.fixture
def app():
return create_app()
def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once(): def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once():
"""The forge-served script and the server-served one are the same file; this is """The forge-served script and the server-served one are the same file; this is
the one line that differs. Edit it and the server stops serving rather than the one line that differs. Edit it and the server stops serving rather than
-8
View File
@@ -1,17 +1,9 @@
import uuid import uuid
import pytest
from inkwell.app import create_app
from inkwell.models.label import Label from inkwell.models.label import Label
from inkwell.serialize import serialize_label from inkwell.serialize import serialize_label
@pytest.fixture
def app():
return create_app()
def test_serialize_label_shape(): def test_serialize_label_shape():
# The base {id, name, color} shape the labels API and (S4) sync deltas both build on. # The base {id, name, color} shape the labels API and (S4) sync deltas both build on.
lid = uuid.uuid4() lid = uuid.uuid4()
-6
View File
@@ -5,7 +5,6 @@ from datetime import datetime, timezone
import pytest import pytest
from inkwell.app import create_app
from inkwell.common import coerce_bool, parse_dt from inkwell.common import coerce_bool, parse_dt
from inkwell.colors import NOTE_COLORS from inkwell.colors import NOTE_COLORS
from inkwell.models.note import Note from inkwell.models.note import Note
@@ -34,11 +33,6 @@ from inkwell.notes.recurrence import next_occurrence, normalize_recurrence
from inkwell.notes.tags import parse_tags, split_body_tags from inkwell.notes.tags import parse_tags, split_body_tags
@pytest.fixture
def app():
return create_app()
def test_all_note_routes_registered(app): def test_all_note_routes_registered(app):
# Guards the notes package split: every route handler must still be attached to the # Guards the notes package split: every route handler must still be attached to the
# blueprint. A route whose module isn't imported by notes/__init__ would silently # blueprint. A route whose module isn't imported by notes/__init__ would silently
-6
View File
@@ -10,7 +10,6 @@ import pytest
from inkwell import ratelimit from inkwell import ratelimit
from inkwell.settings import live from inkwell.settings import live
from inkwell.app import create_app
from inkwell.ratelimit import SlidingWindow from inkwell.ratelimit import SlidingWindow
@@ -28,11 +27,6 @@ def _clean_counters():
ratelimit.reset_all() ratelimit.reset_all()
@pytest.fixture
def app():
return create_app()
def test_under_the_limit_is_not_blocked(): def test_under_the_limit_is_not_blocked():
w = window(3, 60) w = window(3, 60)
for i in range(3): for i in range(3):
-8
View File
@@ -4,14 +4,6 @@ Asserted on /api/health because it is the one route that needs no database and n
session — the headers are set in an after_request hook, so any response proves the session — the headers are set in an after_request hook, so any response proves the
hook, and this suite has no Postgres. hook, and this suite has no Postgres.
""" """
import pytest
from inkwell.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_content_security_policy_locks_scripts_to_self(app): async def test_content_security_policy_locks_scripts_to_self(app):
-8
View File
@@ -1,8 +1,5 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import pytest
from inkwell.app import create_app
from inkwell.sync import ( from inkwell.sync import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
MAX_LIMIT, MAX_LIMIT,
@@ -17,11 +14,6 @@ from inkwell.sync import (
) )
@pytest.fixture
def app():
return create_app()
async def test_changes_requires_auth(app): async def test_changes_requires_auth(app):
client = app.test_client() client = app.test_client()
resp = await client.get("/api/sync/changes") resp = await client.get("/api/sync/changes")
-6
View File
@@ -2,15 +2,9 @@ import ipaddress
import pytest import pytest
from inkwell.app import create_app
from inkwell.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url from inkwell.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url
@pytest.fixture
def app():
return create_app()
def test_is_public_ip_blocks_internal_ranges(): def test_is_public_ip_blocks_internal_ranges():
assert is_public_ip(ipaddress.ip_address("8.8.8.8")) assert is_public_ip(ipaddress.ip_address("8.8.8.8"))
assert is_public_ip(ipaddress.ip_address("2606:4700:4700::1111")) assert is_public_ip(ipaddress.ip_address("2606:4700:4700::1111"))