Test the interface as a user sees it, and let a debug build tell on itself

Fifteen rendering tests across four files are the first tests in this project that compose a screen,
and they cover the claims that used to be prose only: the app bar's star exists where there is a folder
to star and says Add or Remove according to the state, the selection bar offers Rename to several files
and Details only to one, the batch dialog spells out the names it will produce and refuses a collision
with the file named, the shared name dialog refuses a slash while it is typed, and a favourite that has
gone says "Folder not found" rather than showing a path that is not there. They run on the JVM through
Robolectric - the Compose test artifacts plus the test manifest - so they belong to the same verify.sh
gate as everything else instead of needing a device: 386 tests in total.

Writing them turned up one behaviour worth recording. The batch dialog continues the folder's series by
default, so a collision cannot be shown unless the start number is forced back to 1 - which is precisely
why the feature works in practice, and now also why two of these tests set the field explicitly.

The debug build now enables StrictMode (disk reads and writes, network, leaked closables; logged, not
fatal) and attaches LeakCanary. A file manager is the worst possible app for a blocking disk read on the
main thread, and a debug build is the last place where noticing it is still cheap. Logging rather than a
crash is deliberate: turning a slow screen into a crash on someone's phone is the worse trade. The flag
is read from the manifest, not from BuildConfig, which this build does not generate.

Performance has a number for the first time, and it immediately paid for itself. tools/measure-startup.sh
takes cold start with `am start -W` - median of several runs, one warm-up discarded because the first
launch after an install pays for dexopt - and docs/PERFORMANCE.md records 1814 ms over five runs, in the
debug build, which is the slow case by construction. StrictMode's first run then produced five violations
against that number: 492 ms resolving `app.cacheDir` inside the view-model factory, 397 ms reading free
space with StatFs for every volume inside refresh(), 477 ms of the platform loading its own typefaces,
and two 54 ms OEM font hooks. That is about 1.5 s of main-thread disk I/O in a 1.8 s start, of which
~0.9 s is ours and mechanical to move. Neither of ours is fixed here on purpose: the numbers and the two
fixes are recorded in PERFORMANCE.md, and they are the first items of the speed work.

What is not measured is stated in the same file rather than left to be assumed: folder open at 1 000 /
5 000 / 20 000 files, thumbnail paint while scrolling, and keystroke-to-result all need a `:benchmark`
module and a profileable build under the release package id, and this device waits for a confirmation
dialog on the first install of a new package id. The module is deliberately absent instead of
half-configured: build wiring nobody can run is worse than a recorded gap.

Gate green (map current, detekt, 386 tests, lint), and CHANGELOG, README, AGENTS.md, ROADMAP,
PERFORMANCE.md, EDGE_CASE_TESTING and the regenerated map carry it.
This commit is contained in:
2026-10-17 09:23:11 +02:00
parent b1bc805af0
commit 0bbee25ab3
15 changed files with 637 additions and 8 deletions
+9
View File
@@ -201,6 +201,15 @@ dependencies {
testImplementation(libs.junit)
// Runs the real Android framework on the JVM, so the data layer is testable without a device.
testImplementation(libs.robolectric)
// Compose rendering tests: the only shape of test here that asserts what a user actually *sees*.
// They run on the JVM through Robolectric too, so they belong to the same gate as everything else
// rather than needing a device. The manifest artifact supplies the activity they are hosted in.
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Leak detection, debug builds only. It installs itself; there is no code to add, and nothing of
// it reaches a release build (the dependency is on the debug variant alone).
debugImplementation(libs.leakcanary.android)
// Lets a ViewModel test drive `viewModelScope` on a deterministic dispatcher.
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -2,6 +2,8 @@ package de.softwareapp_hb.filemanager
import android.app.Application
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.StrictMode
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.video.VideoFrameDecoder
@@ -64,6 +66,33 @@ class FileManagerApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
/*
* StrictMode, in a debug build only.
*
* A file manager is the worst possible app for a disk read on the main thread — every
* listing, `stat` and thumbnail is one — and a debug build is the only place where noticing
* it is still cheap. `penaltyLog` and nothing stronger on purpose: the point is to see the
* violation in logcat while using the app, not to turn a slow screen into a crash on a
* device someone is relying on. Checked through the manifest flag rather than BuildConfig,
* which this build does not generate.
*/
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedClosableObjects()
.penaltyLog()
.build(),
)
}
storage = StorageProvider(this)
notifier = ServiceOperationNotifier(this)
deleteRequests = MediaStoreDeleter(this).factory()
@@ -0,0 +1,135 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* What the two name dialogs say before anything is pressed.
*
* The claim these protect is the one the project keeps making in prose: a rename that cannot be done
* is *refused with the reason* rather than discovered as a failure afterwards, and a name that is not
* one path segment never reaches the file operations at all.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class BrowserDialogsTest {
@get:Rule
val compose = createComposeRule()
private val downloads = StoragePath.of("/storage/emulated/0/Download")
private fun entry(name: String) =
FileEntry(downloads.child(name), isDirectory = false, sizeBytes = 4L, lastModifiedMillis = 0L)
private fun batchRename(
entries: List<FileEntry>,
namesInFolder: List<String>,
/** Forced start number: the dialog otherwise carries the folder's series on, which is the point. */
start: String? = null,
) {
compose.setContent {
FileManagerTheme {
BatchRenameDialog(
entries = entries,
namesInFolder = namesInFolder,
onDismiss = {},
onConfirm = { _, _ -> },
)
}
}
// The name field is the first of the two; the start number is the second.
compose.onAllNodes(hasSetTextAction())[0].performTextInput("Holiday")
if (start != null) {
compose.onAllNodes(hasSetTextAction())[1].performTextClearance()
compose.onAllNodes(hasSetTextAction())[1].performTextInput(start)
}
}
@Test
fun `the batch dialog spells the names out before anything moves`() {
batchRename(
entries = listOf(entry("a.txt"), entry("b.txt")),
namesInFolder = listOf("a.txt", "b.txt"),
)
compose.onNodeWithText("Will be renamed to Holiday (1).txt, Holiday (2).txt").assertExists()
compose.onNodeWithText("Rename").assertIsEnabled()
}
@Test
fun `a name the folder already holds is named, and the button is off`() {
// The start is forced back to 1 on purpose: left to itself the dialog continues the folder's
// series (from "Holiday (1)"), which is exactly what makes a collision avoidable in practice.
batchRename(
entries = listOf(entry("a.txt"), entry("b.txt")),
namesInFolder = listOf("a.txt", "b.txt", "Holiday (1).txt"),
start = "1",
)
compose.onNodeWithText("Holiday (1).txt is already in this folder").assertExists()
compose.onNodeWithText("Rename").assertIsNotEnabled()
}
@Test
fun `files that already hold the names are said to be done`() {
batchRename(
entries = listOf(entry("Holiday (1).txt"), entry("Holiday (2).txt")),
namesInFolder = listOf("Holiday (1).txt", "Holiday (2).txt"),
start = "1",
)
compose.onNodeWithText("Every selected file already has that name").assertExists()
}
@Test
fun `a name with a slash is refused while it is typed`() {
compose.setContent {
FileManagerTheme {
NameDialog(
title = "New folder",
confirmLabel = "Create",
initialValue = "notes/final.txt",
onDismiss = {},
onConfirm = {},
)
}
}
compose.onNodeWithText("A name cannot contain a slash").assertExists()
compose.onNodeWithText("Create").assertIsNotEnabled()
}
@Test
fun `a plain name is accepted`() {
compose.setContent {
FileManagerTheme {
NameDialog(
title = "New folder",
confirmLabel = "Create",
initialValue = "final.txt",
onDismiss = {},
onConfirm = {},
)
}
}
compose.onNodeWithText("A name cannot contain a slash").assertDoesNotExist()
compose.onNodeWithText("Create").assertIsEnabled()
}
}
@@ -0,0 +1,91 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import de.softwareapp_hb.filemanager.core.FileCategory
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The two kinds of row the home screen puts above a volume: a starred folder and a library.
*
* Both carry a claim that is easy to lose in a refactor — a favourite shows *where* it is, and says
* so when it is not there any more; a library shows a count only when the index has one, because a
* zero would claim the device holds none.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class BrowserRootsTest {
@get:Rule
val compose = createComposeRule()
private val volume = StoragePath.of("/storage/emulated/0")
@Test
fun `a starred folder shows its label, where it is, and how to remove it`() {
compose.setContent {
FileManagerTheme {
BookmarkRow(
label = "Internal storage",
bookmark = Bookmark(volume),
onClick = {},
onRemove = {},
)
}
}
compose.onNodeWithText("Internal storage").assertExists()
compose.onNodeWithText("/storage/emulated/0").assertExists()
compose.onNodeWithContentDescription("Remove from favourites").assertExists()
}
@Test
fun `a favourite that has gone says so instead of its path`() {
compose.setContent {
FileManagerTheme {
BookmarkRow(
label = "Holiday",
bookmark = Bookmark(volume.child("Holiday"), exists = false),
onClick = {},
onRemove = {},
)
}
}
compose.onNodeWithText("Folder not found").assertExists()
compose.onNodeWithText("/storage/emulated/0/Holiday").assertDoesNotExist()
}
@Test
fun `a library the index knows nothing about shows no count`() {
compose.setContent {
FileManagerTheme {
LibraryRow(category = FileCategory.IMAGES, count = null, onClick = {})
}
}
compose.onNodeWithText("Images").assertExists()
// A zero would be a claim about the device; "no count" is the honest version of "no answer".
compose.onNodeWithText("item", substring = true).assertDoesNotExist()
}
@Test
fun `a library with an index behind it shows what it holds`() {
compose.setContent {
FileManagerTheme {
LibraryRow(category = FileCategory.IMAGES, count = 3, onClick = {})
}
}
compose.onNodeWithText("3 items").assertExists()
}
}
@@ -0,0 +1,93 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* What the app bar offers, as a user sees it.
*
* The star is the one action whose *presence* is a rule rather than a layout choice — it exists only
* where there is a folder to come back to — and it is drawn from two booleans that the view model
* supplies, so getting them across is worth a test that renders the bar rather than one that reads
* the state.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class BrowserTopBarTest {
@get:Rule
val compose = createComposeRule()
private fun topBar(
canBookmark: Boolean = true,
bookmarked: Boolean = false,
searchOpen: Boolean = false,
) = compose.setContent {
FileManagerTheme {
BrowserTopBar(
title = "Download",
canNavigateUp = true,
canBookmark = canBookmark,
bookmarked = bookmarked,
query = "",
searchOpen = searchOpen,
searchScope = SearchScope.FOLDER,
scopeChoosable = true,
canPaste = false,
onUp = {},
onQuery = {},
onSearchOpen = {},
onSearchScope = {},
onSort = {},
onPaste = {},
onToggleBookmark = {},
onRefresh = {},
onUsage = {},
onDuplicates = {},
onTrash = {},
)
}
}
@Test
fun `inside a folder the star invites the folder to be starred`() {
topBar(canBookmark = true, bookmarked = false)
compose.onNodeWithContentDescription("Add to favourites").assertIsDisplayed()
compose.onNodeWithText("Download").assertIsDisplayed()
}
@Test
fun `a starred folder says so instead`() {
topBar(canBookmark = true, bookmarked = true)
compose.onNodeWithContentDescription("Remove from favourites").assertIsDisplayed()
}
@Test
fun `a library or the root list has nothing to star`() {
topBar(canBookmark = false)
compose.onNodeWithContentDescription("Add to favourites").assertDoesNotExist()
compose.onNodeWithContentDescription("Remove from favourites").assertDoesNotExist()
}
@Test
fun `the star gives way to the search field`() {
// The field takes the whole bar while it is open; a star squeezed in beside it would be a
// button with nowhere to go.
topBar(canBookmark = true, searchOpen = true)
compose.onNodeWithContentDescription("Add to favourites").assertDoesNotExist()
}
}
@@ -0,0 +1,79 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Which actions a selection is offered — the rule that Rename means one thing for a single file and
* another for several, and that "the details" of twelve files is not a screen.
*
* Presence rather than position: the bar scrolls horizontally, so an action can be off-screen at a
* given width and still correctly offered.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SelectionBarTest {
@get:Rule
val compose = createComposeRule()
private val downloads = StoragePath.of("/storage/emulated/0/Download")
private fun entry(name: String) =
FileEntry(downloads.child(name), isDirectory = false, sizeBytes = 4L, lastModifiedMillis = 0L)
private fun bar(selected: Int) {
val entries = listOf(entry("a.txt"), entry("b.txt"), entry("c.txt")).take(selected)
compose.setContent {
FileManagerTheme {
SelectionBar(
state = BrowserUiState(
path = downloads,
entries = entries,
selection = entries.map { it.path }.toSet(),
),
onCopy = {},
onCut = {},
onDelete = {},
onDetails = {},
onRename = {},
onOpenWith = {},
onShare = {},
onCompress = {},
onExtract = {},
onSelectAll = {},
onClear = {},
)
}
}
}
@Test
fun `several files are offered a rename and not the details`() {
bar(selected = 3)
compose.onNodeWithContentDescription("Rename").assertExists()
compose.onNodeWithContentDescription("Details").assertDoesNotExist()
compose.onNodeWithText("3 items").assertExists()
}
@Test
fun `a single file keeps both`() {
bar(selected = 1)
compose.onNodeWithContentDescription("Rename").assertExists()
compose.onNodeWithContentDescription("Details").assertExists()
compose.onNodeWithText("1 item").assertExists()
}
}