173 lines
8.5 KiB
Plaintext
173 lines
8.5 KiB
Plaintext
package dev.fileworkbench.testing
|
|
|
|
import dev.fileworkbench.core.PreviewException
|
|
import dev.fileworkbench.core.browser.*
|
|
import java.io.Closeable
|
|
import java.io.IOException
|
|
|
|
// Deliberately small doubles, not Android stubs for compiling the application.
|
|
// The production listing block below is inserted verbatim by test-folder-listing.py.
|
|
private object Document { const val COLUMN_DOCUMENT_ID = "document_id" }
|
|
private object DocumentsContract {
|
|
const val EXTRA_LOADING = "loading"
|
|
const val EXTRA_INFO = "info"
|
|
const val EXTRA_ERROR = "error"
|
|
fun buildDocumentUriUsingTree(tree: String, id: String) = "$tree/document/$id"
|
|
}
|
|
private class Extras(private val values: Map<String, Any?> = emptyMap()) {
|
|
fun getBoolean(key: String, fallback: Boolean) = values[key] as? Boolean ?: fallback
|
|
fun getString(key: String) = values[key] as? String
|
|
}
|
|
// Test-only representations for the diagnostic expression. They do not simulate
|
|
// Android URI grants, provider availability or OS behaviour.
|
|
private data class TestUri(val value: String, val authority: String? = "fixture.provider")
|
|
private object Build { object VERSION { const val SDK_INT = 36 } }
|
|
private object UriReadGrants {
|
|
var retainedRead = false
|
|
var failLookup = false
|
|
@Suppress("UNUSED_PARAMETER")
|
|
fun hasPersistentRead(resolver: TestResolver, tree: String): Boolean {
|
|
if (failLookup) throw SecurityException("fixture grant lookup failure")
|
|
return retainedRead
|
|
}
|
|
}
|
|
private data class Row(val id: String?, val name: String = "sample.txt")
|
|
private class TestCursor(
|
|
val rows: List<Row>,
|
|
val extras: Extras = Extras(),
|
|
private val failOnMove: Boolean = false,
|
|
) : Closeable {
|
|
var index = -1
|
|
var closeCount = 0
|
|
fun moveToNext(): Boolean {
|
|
if (failOnMove) throw IOException("fixture read failure")
|
|
return ++index < rows.size
|
|
}
|
|
fun string(column: String): String? {
|
|
check(column == Document.COLUMN_DOCUMENT_ID)
|
|
return rows[index].id
|
|
}
|
|
override fun close() { closeCount++ }
|
|
}
|
|
private class TestSignal(private val cancelled: Boolean = false) {
|
|
fun throwIfCanceled() { if (cancelled) throw java.util.concurrent.CancellationException("fixture cancellation") }
|
|
}
|
|
private class TestResolver(private val cursor: TestCursor?) {
|
|
var queries = 0
|
|
@Suppress("UNUSED_PARAMETER")
|
|
fun query(uri: TestUri, projection: Array<String>, selection: String?, arguments: Array<String>?, order: String?, signal: TestSignal): TestCursor? {
|
|
queries++
|
|
return cursor
|
|
}
|
|
}
|
|
private fun entry(cursor: TestCursor, uri: String) = BrowserEntry(uri, cursor.rows[cursor.index].name, "text/plain")
|
|
private fun readListing(resolver: TestResolver, signal: TestSignal = TestSignal()): BrowserListing = run {
|
|
val tree = "fixture:root"
|
|
val children = TestUri("fixture:root/children")
|
|
val PROJECTION = arrayOf(Document.COLUMN_DOCUMENT_ID)
|
|
/* PRODUCTION_LISTING_BLOCK */
|
|
}
|
|
private inline fun <reified T : Throwable> expectThrows(block: () -> Unit): T {
|
|
try { block() } catch (e: Throwable) {
|
|
check(e is T) { "Expected ${T::class.java.simpleName}, got $e" }
|
|
return e
|
|
}
|
|
error("Expected ${T::class.java.simpleName}")
|
|
}
|
|
private fun read(cursor: TestCursor): BrowserListing {
|
|
val resolver = TestResolver(cursor)
|
|
try { return readListing(resolver) } finally {
|
|
check(cursor.closeCount == 1) { "Cursor must close exactly once" }
|
|
check(resolver.queries == 1) { "Listing must issue exactly one query" }
|
|
}
|
|
}
|
|
fun main() {
|
|
var passed = 0
|
|
var failed = 0
|
|
fun test(name: String, block: () -> Unit) {
|
|
try { block(); passed++; println("PASS $name") }
|
|
catch (e: Throwable) { failed++; println("FAIL $name: ${e.javaClass.simpleName}: ${e.message}") }
|
|
}
|
|
test("normal rows without extras are not rejected") {
|
|
val listing = read(TestCursor(listOf(Row("file"))))
|
|
check(listing.entries.map { it.name } == listOf("sample.txt"))
|
|
check(!listing.truncated && !listing.providerLoading && listing.information == null)
|
|
}
|
|
test("empty cursor without extras is a valid empty directory") {
|
|
val listing = read(TestCursor(emptyList()))
|
|
check(listing.entries.isEmpty() && !listing.truncated && !listing.providerLoading && listing.information == null)
|
|
}
|
|
test("information-only cursor keeps rows and message") {
|
|
val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("info" to "Cached files"))))
|
|
check(listing.entries.size == 1 && listing.information == "Cached files")
|
|
}
|
|
test("loading cursor without error keeps partial rows") {
|
|
val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("loading" to true))))
|
|
check(listing.entries.size == 1 && listing.providerLoading && listing.information == null)
|
|
}
|
|
test("loading cursor may initially contain no rows") {
|
|
val listing = read(TestCursor(emptyList(), Extras(mapOf("loading" to true))))
|
|
check(listing.entries.isEmpty() && listing.providerLoading && listing.information == null)
|
|
}
|
|
test("provider error takes precedence over info without discarding available rows") {
|
|
val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("info" to "Cached files", "error" to "Some files are unavailable"))))
|
|
check(listing.entries.size == 1 && listing.information == "Some files are unavailable")
|
|
}
|
|
test("actual null query result still raises the no-listing error") {
|
|
val resolver = TestResolver(null)
|
|
val error = expectThrows<PreviewException> { readListing(resolver) }
|
|
check(error.message?.startsWith("[SAF-D1/NULL_CURSOR]") == true)
|
|
check(error.message?.contains("Provider: fixture.provider") == true)
|
|
check(error.message?.contains("Android API: 36; retained read grant: false") == true)
|
|
check(error.message?.contains("fixture:root/children") == false)
|
|
check(resolver.queries == 1)
|
|
}
|
|
test("cursor closes when row iteration throws") {
|
|
val cursor = TestCursor(listOf(Row("file")), failOnMove = true)
|
|
expectThrows<IOException> { read(cursor) }
|
|
check(cursor.closeCount == 1)
|
|
}
|
|
test("cursor closes on cancellation during iteration") {
|
|
val cursor = TestCursor(listOf(Row("file")))
|
|
expectThrows<java.util.concurrent.CancellationException> { readListing(TestResolver(cursor), TestSignal(cancelled = true)) }
|
|
check(cursor.closeCount == 1)
|
|
}
|
|
test("entry limit still marks an oversized directory truncated") {
|
|
val listing = read(TestCursor((0..BrowserLimits.MAX_ENTRIES).map { Row("file-$it") }))
|
|
check(listing.entries.size == BrowserLimits.MAX_ENTRIES && listing.truncated)
|
|
}
|
|
test("exactly the entry limit is not marked truncated") {
|
|
val listing = read(TestCursor((0 until BrowserLimits.MAX_ENTRIES).map { Row("file-$it") }))
|
|
check(listing.entries.size == BrowserLimits.MAX_ENTRIES && !listing.truncated)
|
|
}
|
|
test("duplicate provider IDs remain deduplicated") {
|
|
val listing = read(TestCursor(listOf(Row("file"), Row("file"))))
|
|
check(listing.entries.size == 1)
|
|
}
|
|
test("missing and oversized document IDs are skipped") {
|
|
val listing = read(TestCursor(listOf(Row(null), Row("x".repeat(8193)), Row("file"))))
|
|
check(listing.entries.size == 1 && listing.entries.single().id.endsWith("/file"))
|
|
}
|
|
test("provider messages still use the shared label sanitizer") {
|
|
val listing = read(TestCursor(emptyList(), Extras(mapOf("error" to "Warning\nunsafe\u202E"))))
|
|
check(listing.information == "Warning_unsafe_")
|
|
}
|
|
test("diagnostic reflects a retained read grant without treating it as successful access") {
|
|
UriReadGrants.retainedRead = true
|
|
try {
|
|
val error = expectThrows<PreviewException> { readListing(TestResolver(null)) }
|
|
check(error.message?.contains("retained read grant: true") == true)
|
|
} finally { UriReadGrants.retainedRead = false }
|
|
}
|
|
test("unavailable permission diagnostic cannot replace the original null-cursor error") {
|
|
UriReadGrants.failLookup = true
|
|
try {
|
|
val error = expectThrows<PreviewException> { readListing(TestResolver(null)) }
|
|
check(error.message?.startsWith("[SAF-D1/NULL_CURSOR]") == true)
|
|
check(error.message?.contains("retained read grant: unknown") == true)
|
|
} finally { UriReadGrants.failLookup = false }
|
|
}
|
|
println("Folder-listing host regression: $passed passed, $failed failed. NOT an Android/device test.")
|
|
check(failed == 0) { "$failed regression checks failed" }
|
|
}
|