feat(android): evaluate candidates before human confirmation

This commit is contained in:
QiuSW
2026-07-25 21:54:52 +08:00
parent 32d5310000
commit 84f2da3cf7
25 changed files with 2127 additions and 43 deletions
@@ -0,0 +1,135 @@
package com.roubao.autopilot.pinduoduo
import java.io.File
import java.nio.file.Files
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CandidateEvidenceSourceTest {
@Test
fun `loads only declared sequential evidence after metadata validation`() = runTest {
withEvidenceRoot { root ->
val first = writeCandidate(root, ordinal = 1, width = 1080, height = 2400)
val second = writeCandidate(root, ordinal = 2, width = 1080, height = 2400)
val result = CandidateEvidenceSource(root).load(listOf(first, second))
assertTrue(result.isSuccess)
assertEquals(listOf(1, 2), result.getOrThrow().map { it.ordinal })
}
}
@Test
fun `rejects non allowlisted filename before reading`() = runTest {
withEvidenceRoot { root ->
val evidence = writeCandidate(root, ordinal = 1, width = 10, height = 20)
.copy(screenshotFileName = "../candidate-01.png")
val result = CandidateEvidenceSource(root).load(listOf(evidence))
assertTrue(result.isFailure)
}
}
@Test
fun `rejects missing duplicate or non sequential ordinals`() = runTest {
withEvidenceRoot { root ->
val first = writeCandidate(root, ordinal = 1, width = 10, height = 20)
val second = writeCandidate(root, ordinal = 2, width = 10, height = 20)
assertTrue(
CandidateEvidenceSource(root).load(listOf(second)).isFailure
)
assertTrue(
CandidateEvidenceSource(root).load(listOf(first, first)).isFailure
)
}
}
@Test
fun `rejects byte hash and png dimension tampering`() = runTest {
withEvidenceRoot { root ->
val evidence = writeCandidate(root, ordinal = 1, width = 10, height = 20)
val source = CandidateEvidenceSource(root)
assertTrue(
source.load(
listOf(evidence.copy(screenshotByteCount = evidence.screenshotByteCount + 1))
).isFailure
)
assertTrue(
source.load(
listOf(evidence.copy(screenshotSha256 = "0".repeat(64)))
).isFailure
)
assertTrue(
source.load(
listOf(evidence.copy(screenshotWidth = 11))
).isFailure
)
}
}
@Test
fun `rejects invalid png header and declared size above limit`() = runTest {
withEvidenceRoot { root ->
val evidence = writeCandidate(root, ordinal = 1, width = 10, height = 20)
File(root, evidence.screenshotFileName).writeBytes(ByteArray(24))
assertTrue(CandidateEvidenceSource(root).load(listOf(evidence)).isFailure)
assertTrue(
CandidateEvidenceSource(root).load(
listOf(evidence.copy(screenshotByteCount = 8 * 1024 * 1024 + 1))
).isFailure
)
}
}
private suspend fun withEvidenceRoot(block: suspend (File) -> Unit) {
val root = Files.createTempDirectory("candidate-evidence-test").toFile()
try {
block(root)
} finally {
root.deleteRecursively()
}
}
private fun writeCandidate(
root: File,
ordinal: Int,
width: Int,
height: Int
): PinduoduoCandidateEvidence {
val bytes = pngHeader(width, height)
val fileName = "candidate-%02d.png".format(ordinal)
File(root, fileName).writeBytes(bytes)
return PinduoduoCandidateEvidence(
ordinal = ordinal,
cardSignature = "card-$ordinal",
cardSemanticTextCount = 3,
detailSignature = "detail-$ordinal",
detailSemanticTextCount = 4,
screenshotFileName = fileName,
screenshotSha256 = PinduoduoEvidenceHash.sha256(bytes),
screenshotByteCount = bytes.size,
screenshotWidth = width,
screenshotHeight = height
)
}
private fun pngHeader(width: Int, height: Int): ByteArray =
byteArrayOf(
0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
(width ushr 24).toByte(),
(width ushr 16).toByte(),
(width ushr 8).toByte(),
width.toByte(),
(height ushr 24).toByte(),
(height ushr 16).toByte(),
(height ushr 8).toByte(),
height.toByte()
)
}
@@ -85,7 +85,7 @@ class PinduoduoPageClassifierTest {
}
@Test
fun `results require the exact expected query`() {
fun `different result query is recognized but not accepted as current results`() {
val snapshot = classify(
element(
contentDescription = "搜索",
@@ -97,7 +97,7 @@ class PinduoduoPageClassifierTest {
element(text = "价格")
)
assertEquals(PinduoduoPage.UNKNOWN, snapshot.page)
assertEquals(PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY, snapshot.page)
}
@Test
@@ -107,6 +107,51 @@ class PinduoduoSearchAutomationTest {
assertEquals(PinduoduoPage.SEARCH_RESULTS, driver.page)
}
@Test
fun `dynamic requirement query replaces an existing results query`() = runTest {
val driver = FakeDriver(page = PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY)
val dynamicQuery = "动态任务搜索词"
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
keyword = dynamicQuery,
forceKeywordEntry = true,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.openSearchCalls)
assertEquals(dynamicQuery, driver.enteredKeyword)
assertTrue(driver.searchSubmitted)
}
@Test
fun `dynamic requirement query returns from detail before replacing query`() =
runTest {
val driver = FakeDriver(page = PinduoduoPage.PRODUCT_DETAIL)
val dynamicQuery = "动态任务搜索词"
val runner = WorkflowRunner(
PinduoduoSearchAutomation(
driver = driver,
keyword = dynamicQuery,
forceKeywordEntry = true,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.returnFromCandidateCalls)
assertEquals(1, driver.openSearchCalls)
assertEquals(dynamicQuery, driver.enteredKeyword)
}
private class FakeDriver(
var page: PinduoduoPage,
private val safetyStopReason: SafetyStopReason? = null,