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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user