feat(android): evaluate candidates before human confirmation
This commit is contained in:
+135
@@ -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()
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
+45
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user