Three separate mutations passed the full 257-test suite, all for the same reason: the tool was tested and the thing that calls it was not. The join half of per-job staging. Reverting ConcatWorker to the constant the audit's own D8 table names -- "joined.<ext>", one string for every join of a format -- left everything green: PerJobStagingTest drives only the conversion worker, and StagingNamesTest pins only the pure function. The new case drives two real ConcatWorkers with different ids and reads what they asked for rather than what is on disk, because ConcatEngine is native, so neither join gets past it here and the catch on the way out deletes what it staged. The recorder moves into WorkerStubs, which is what that file is for, and the enum test that already had a private copy now uses it. The process-start sweep. Deleting the one line in LibreMediaConverterApp.onCreate() -- the only reason that class exists, and the backstop for every leak discardStaged cannot reach -- left everything green too. The test stages one file a day old and one written now, calls onCreate() again, and asserts both halves: the abandoned one is collected and the live one is not. The second half is what says this is a sweep rather than the clearStaging() it replaced, which could take a file out from under a running job. The mtime is set explicitly, because "written long enough ago" is not something a test can wait for when the period is twenty-four hours. Casting the Robolectric application to LibreMediaConverterApp is an assertion in itself: it fails if android:name ever stops pointing here, in which case the swept line would be correct code that never runs. The backup and device-transfer exclusions. Reverting data_extraction_rules.xml to the template's boilerplate left the unit tests green AND lintDebug green -- it is a resource, so nothing was reading it -- and the failure it causes is one nobody meets in development. WorkManager's queue is the app's whole backup payload, and its rows name content:// grants and cacheDir paths that do not survive a transfer; reattachment queries by tag on launch, so a fresh install would come up attached to a job the user never ran on it. The test reads the compiled resource table, so what it pins is what the APK carries, and it asserts domain and path for all four entries in both sections -- an <exclude> with no path is skipped unchecked by lint's own detector, so half an entry could protect nothing. Its KDoc records the one thing it does not cover: the manifest attribute that points the system at the file. R8 / #17, R9 / #18, R11 / #20 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
99 lines
4.4 KiB
Kotlin
99 lines
4.4 KiB
Kotlin
package org.libremediaconverter
|
|
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertTrue
|
|
import org.junit.Assert.fail
|
|
import org.junit.Before
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
import org.libremediaconverter.convert.StagingSweep
|
|
import org.robolectric.RobolectricTestRunner
|
|
import org.robolectric.RuntimeEnvironment
|
|
import java.io.File
|
|
import java.util.concurrent.TimeUnit
|
|
|
|
/**
|
|
* That process start actually sweeps.
|
|
*
|
|
* [StagingSweepTest][org.libremediaconverter.convert.StagingSweepTest] pins the age rule and
|
|
* `OutputPublisherStagingTest` pins the sweep against a real filesystem; neither says anything
|
|
* about whether anything calls it, and deleting the one line that does left the whole suite green.
|
|
* That line is the only reason this Application class exists, and it is the backstop for every leak
|
|
* `discardStaged` cannot reach — a process reclaimed before a save, a worker that failed before its
|
|
* output ever became a `Converted` state, a `reset()` whose delete was cancelled with the Activity.
|
|
*
|
|
* `onCreate()` is called again rather than a second Application being built: it is what the
|
|
* framework calls at process start, the scope it launches on is already there, and the first test
|
|
* below is what pins that the framework calls it on *this* class.
|
|
*/
|
|
@RunWith(RobolectricTestRunner::class)
|
|
class AppStartSweepTest {
|
|
|
|
private lateinit var app: LibreMediaConverterApp
|
|
private lateinit var stagingDir: File
|
|
|
|
@Before
|
|
fun setUp() {
|
|
// The cast is an assertion in itself: Robolectric builds the Application named in the
|
|
// merged manifest, so this fails if `android:name` ever stops pointing here -- in which
|
|
// case the sweep below would be perfectly correct code that never runs.
|
|
app = RuntimeEnvironment.getApplication() as LibreMediaConverterApp
|
|
stagingDir = File(app.cacheDir, "conversions").apply { mkdirs() }
|
|
stagingDir.listFiles()?.forEach { it.delete() }
|
|
}
|
|
|
|
@Test
|
|
fun `the application the manifest starts is the one that sweeps`() {
|
|
assertEquals(LibreMediaConverterApp::class.java, RuntimeEnvironment.getApplication().javaClass)
|
|
}
|
|
|
|
@Test
|
|
fun `process start collects an abandoned staged file and leaves a live one alone`() {
|
|
val abandoned = stagedFile("abandoned.mp4")
|
|
val live = stagedFile("live.mp4")
|
|
// Set explicitly. Relying on a file being written "long enough ago" is not something a test
|
|
// can arrange, and the grace period is a day.
|
|
assertTrue(
|
|
abandoned.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - ONE_MINUTE_MS),
|
|
)
|
|
|
|
// Both files are still here on the way in. The Application was already constructed once
|
|
// before this test ran, so without this the sweep that call started could be the one that
|
|
// collected the file, and the assertion below would be about the wrong process start.
|
|
assertTrue(abandoned.exists() && live.exists())
|
|
|
|
app.onCreate()
|
|
|
|
awaitGone(abandoned)
|
|
// The other half, and the one that says the sweep is a sweep rather than a
|
|
// `clearStaging()`: the directory is shared by the convert tab, the join tab and
|
|
// ConcatEngine's list file, so deleting everything could take a file from a running job.
|
|
assertTrue("a file written moments ago belongs to a live job", live.exists())
|
|
}
|
|
|
|
/**
|
|
* Waits for [file] to be deleted.
|
|
*
|
|
* The sweep runs on `Dispatchers.IO`, deliberately: it lists a directory and stats every entry
|
|
* on the path that decides how long the launcher icon stays unresponsive. So there is nothing
|
|
* to join, and the wait is a bounded poll — long enough for a directory listing, short enough
|
|
* that a sweep which never happens fails rather than hangs.
|
|
*/
|
|
private fun awaitGone(file: File) {
|
|
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS)
|
|
while (System.nanoTime() < deadline) {
|
|
if (!file.exists()) return
|
|
Thread.sleep(POLL_INTERVAL_MS)
|
|
}
|
|
fail("process start left ${file.name} in staging; nothing swept it")
|
|
}
|
|
|
|
private fun stagedFile(name: String): File = File(stagingDir, name).apply { writeBytes(ByteArray(4096)) }
|
|
|
|
private companion object {
|
|
const val ONE_MINUTE_MS = 60L * 1000
|
|
const val AWAIT_TIMEOUT_SECONDS = 10L
|
|
const val POLL_INTERVAL_MS = 5L
|
|
}
|
|
}
|