feat(t213): guard read-only specification verification

This commit is contained in:
QiuSW
2026-07-27 23:06:21 +08:00
parent 1316b9bb32
commit 9a8388fac6
25 changed files with 1540 additions and 207 deletions
@@ -18,14 +18,25 @@ class CandidateEvidenceSourceTest {
assertTrue(result.isSuccess)
assertEquals(listOf(1, 2), result.getOrThrow().map { it.ordinal })
assertTrue(
result.getOrThrow().all {
it.detailPngBytes.isNotEmpty() &&
it.specificationPngBytes.isNotEmpty()
}
)
}
}
@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 original =
writeCandidate(root, ordinal = 1, width = 10, height = 20)
val evidence = original.copy(
detailAsset = original.detailAsset.copy(
fileName = "../candidate-01-detail.png"
)
)
val result = CandidateEvidenceSource(root).load(listOf(evidence))
@@ -56,17 +67,33 @@ class CandidateEvidenceSourceTest {
assertTrue(
source.load(
listOf(evidence.copy(screenshotByteCount = evidence.screenshotByteCount + 1))
listOf(
evidence.copy(
detailAsset = evidence.detailAsset.copy(
byteCount = evidence.detailAsset.byteCount + 1
)
)
)
).isFailure
)
assertTrue(
source.load(
listOf(evidence.copy(screenshotSha256 = "0".repeat(64)))
listOf(
evidence.copy(
detailAsset = evidence.detailAsset.copy(
sha256 = "0".repeat(64)
)
)
)
).isFailure
)
assertTrue(
source.load(
listOf(evidence.copy(screenshotWidth = 11))
listOf(
evidence.copy(
detailAsset = evidence.detailAsset.copy(width = 11)
)
)
).isFailure
)
}
@@ -76,12 +103,18 @@ class CandidateEvidenceSourceTest {
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))
File(root, evidence.detailAsset.fileName).writeBytes(ByteArray(24))
assertTrue(CandidateEvidenceSource(root).load(listOf(evidence)).isFailure)
assertTrue(
CandidateEvidenceSource(root).load(
listOf(evidence.copy(screenshotByteCount = 8 * 1024 * 1024 + 1))
listOf(
evidence.copy(
detailAsset = evidence.detailAsset.copy(
byteCount = 8 * 1024 * 1024 + 1
)
)
)
).isFailure
)
}
@@ -103,22 +136,53 @@ class CandidateEvidenceSourceTest {
height: Int
): PinduoduoCandidateEvidence {
val bytes = pngHeader(width, height)
val fileName = "candidate-%02d.png".format(ordinal)
File(root, fileName).writeBytes(bytes)
val detailFileName = "candidate-%02d-detail.png".format(ordinal)
val specificationFileName =
"candidate-%02d-specification.png".format(ordinal)
File(root, detailFileName).writeBytes(bytes)
File(root, specificationFileName).writeBytes(bytes)
val asset = PinduoduoEvidenceAsset(
fileName = detailFileName,
sha256 = PinduoduoEvidenceHash.sha256(bytes),
byteCount = bytes.size,
width = width,
height = height
)
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
detailAsset = asset,
specification = specification(),
specificationAsset = asset.copy(fileName = specificationFileName)
)
}
private fun specification() = PinduoduoSpecificationEvidence(
signature = "specification",
semanticTextCount = 6,
groups = listOf(
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.COLOR,
title = "颜色分类",
options = listOf(
PinduoduoSpecificationOption("黑色", false, true)
),
complete = true
),
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.SIZE,
title = "尺码",
options = listOf(
PinduoduoSpecificationOption("L", false, true)
),
complete = true
)
)
)
private fun pngHeader(width: Int, height: Int): ByteArray =
byteArrayOf(
0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
@@ -156,6 +156,31 @@ class PinduoduoCandidateAutomationTest {
assertTrue(automation.evidence.value.isEmpty())
}
@Test
fun `missing safe specification entry blocks without purchase fallback`() =
runTest {
val driver = FakeCandidateDriver(
cardPages = listOf(listOf(card("a"))),
failOpenSpecifications = true
)
val automation = PinduoduoCandidateAutomation(
driver = driver,
pagePollIntervalMillis = 1
)
automation.reset()
val result = automation.execute(
PinduoduoCandidateWorkflow.steps().last()
)
assertEquals(
AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE),
result
)
assertEquals(1, driver.openSpecificationCalls)
assertTrue(driver.savedEvidence.isEmpty())
}
private fun card(signature: String) = PinduoduoCandidateCard(
signature = signature,
semanticTextCount = 3,
@@ -166,6 +191,7 @@ class PinduoduoCandidateAutomationTest {
private val cardPages: List<List<PinduoduoCandidateCard>>,
private val safetyStopReason: SafetyStopReason? = null,
private val failCapture: Boolean = false,
private val failOpenSpecifications: Boolean = false,
private val transitionDelaySnapshots: Int = 0
) : PinduoduoCandidateDriver {
private var cardPageIndex = 0
@@ -176,6 +202,7 @@ class PinduoduoCandidateAutomationTest {
val openedSignatures = mutableListOf<String>()
val savedEvidence = mutableListOf<PinduoduoCandidateEvidence>()
var scrollCalls = 0
var openSpecificationCalls = 0
override suspend fun snapshot(): PinduoduoUiSnapshot {
if (pendingPage != null) {
@@ -204,24 +231,60 @@ class PinduoduoCandidateAutomationTest {
return true
}
override suspend fun captureCandidate(
ordinal: Int,
card: PinduoduoCandidateCard
): PinduoduoCandidateEvidence? {
override suspend fun captureDetail(): PinduoduoCandidateCapture? {
if (failCapture) {
return null
}
return PinduoduoCandidateCapture(
detail = PinduoduoCandidateDetailEvidence("detail", 10),
screenshot = PinduoduoScreenshotCapture(
pngBytes = byteArrayOf(1),
width = 1080,
height = 2400
)
)
}
override suspend fun openSpecifications(): Boolean {
openSpecificationCalls += 1
if (failOpenSpecifications) {
return false
}
transitionTo(PinduoduoPage.SPECIFICATION_PANEL)
return true
}
override suspend fun captureSpecifications():
PinduoduoSpecificationCapture =
PinduoduoSpecificationCapture(
evidence = specification(),
screenshot = PinduoduoScreenshotCapture(
pngBytes = byteArrayOf(2),
width = 1080,
height = 2400
)
)
override suspend fun closeSpecifications(): Boolean {
transitionTo(PinduoduoPage.PRODUCT_DETAIL)
return true
}
override suspend fun saveCandidate(
ordinal: Int,
card: PinduoduoCandidateCard,
detail: PinduoduoCandidateCapture,
specifications: PinduoduoSpecificationCapture
): PinduoduoCandidateEvidence? {
return PinduoduoCandidateEvidence(
ordinal = ordinal,
cardSignature = card.signature,
cardSemanticTextCount = card.semanticTextCount,
detailSignature = "detail-${card.signature}",
detailSemanticTextCount = 10,
screenshotFileName = "candidate-%02d.png".format(ordinal),
screenshotSha256 = "hash-$ordinal",
screenshotByteCount = 100 + ordinal,
screenshotWidth = 1080,
screenshotHeight = 2400
detailAsset = asset(ordinal, "detail"),
specification = specifications.evidence,
specificationAsset = asset(ordinal, "specification")
).also(savedEvidence::add)
}
@@ -242,6 +305,7 @@ class PinduoduoCandidateAutomationTest {
openedSignatures.clear()
savedEvidence.clear()
scrollCalls = 0
openSpecificationCalls = 0
cardPageIndex = 0
page = PinduoduoPage.SEARCH_RESULTS
pendingPage = null
@@ -256,5 +320,39 @@ class PinduoduoCandidateAutomationTest {
delayedSnapshotsRemaining = transitionDelaySnapshots
}
}
private fun asset(
ordinal: Int,
suffix: String
) = PinduoduoEvidenceAsset(
fileName = "candidate-%02d-%s.png".format(ordinal, suffix),
sha256 = "a".repeat(64),
byteCount = 1,
width = 1080,
height = 2400
)
private fun specification() = PinduoduoSpecificationEvidence(
signature = "specification",
semanticTextCount = 6,
groups = listOf(
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.COLOR,
title = "颜色分类",
options = listOf(
PinduoduoSpecificationOption("黑色", false, true)
),
complete = true
),
PinduoduoSpecificationGroup(
kind = PinduoduoSpecificationGroupKind.SIZE,
title = "尺码",
options = listOf(
PinduoduoSpecificationOption("L", false, true)
),
complete = true
)
)
)
}
}
@@ -202,6 +202,41 @@ class PinduoduoPageClassifierTest {
assertNull(snapshot.safetyStopReason)
}
@Test
fun `specification panel requires color size and options`() {
val snapshot = classify(
element(text = "颜色分类"),
element(text = "黑色", clickable = true),
element(text = "尺码"),
element(text = "L", clickable = true)
)
assertEquals(PinduoduoPage.SPECIFICATION_PANEL, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `cart and order pages are never classified as product detail`() {
val cart = classify(
element(text = "购物车"),
element(text = "去结算")
)
val order = classify(
element(contentDescription = "返回", clickable = true),
element(text = "颜色分类"),
element(text = "黑色", clickable = true),
element(text = "尺码"),
element(text = "L", clickable = true),
element(text = "确认订单")
)
assertEquals(PinduoduoPage.UNKNOWN, cart.page)
assertEquals(
SafetyStopReason.PAYMENT_BOUNDARY,
order.safetyStopReason
)
}
@Test
fun `other foreground package is unknown and has no inferred blocker`() {
val snapshot = PinduoduoPageClassifier.classify(
@@ -0,0 +1,98 @@
package com.roubao.autopilot.pinduoduo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoSpecificationParserTest {
@Test
fun `parses bounded color and size groups without clicking options`() {
val evidence = PinduoduoSpecificationParser.parse(
listOf(
element("颜色分类", top = 100),
element("黑色", top = 140, clickable = true, selected = true),
element("白色", top = 180, clickable = true),
element("尺码", top = 240),
element("L", top = 280, clickable = true),
element("XL", top = 320, clickable = true, enabled = false)
)
)
requireNotNull(evidence)
assertEquals(
PinduoduoSpecificationGroupKind.entries.toSet(),
evidence.groups.map { it.kind }.toSet()
)
assertTrue(evidence.groups.all { it.complete })
assertTrue(evidence.groups.first().options.first().selected)
assertFalse(evidence.groups.last().options.last().enabled)
}
@Test
fun `scrollable group is marked incomplete`() {
val evidence = PinduoduoSpecificationParser.parse(
listOf(
element("颜色分类", top = 100),
element("黑色", top = 140, clickable = true),
element("", top = 150, scrollable = true),
element("尺码", top = 240),
element("L", top = 280, clickable = true)
)
)
requireNotNull(evidence)
assertFalse(evidence.groups.first().complete)
assertTrue(evidence.groups.last().complete)
}
@Test
fun `missing group and transaction controls are rejected`() {
assertNull(
PinduoduoSpecificationParser.parse(
listOf(
element("颜色分类", top = 100),
element("立即购买", top = 140, clickable = true)
)
)
)
}
@Test
fun `safe entry allowlist excludes purchase controls`() {
assertTrue(
PinduoduoSpecificationParser.isSafeEntryText("请选择规格")
)
assertTrue(
PinduoduoSpecificationParser.isSafeEntryText("已选:黑色,L")
)
assertTrue(
PinduoduoSpecificationParser.isSafeEntryText("颜色\n款式")
)
assertFalse(
PinduoduoSpecificationParser.isSafeEntryText("免拼购买")
)
}
private fun element(
text: String,
top: Int,
clickable: Boolean = false,
enabled: Boolean = true,
selected: Boolean = false,
scrollable: Boolean = false
) = PinduoduoUiElement(
text = text,
contentDescription = null,
className = "android.widget.TextView",
resourceId = null,
clickable = clickable,
editable = false,
enabled = enabled,
visibleToUser = true,
selected = selected,
scrollable = scrollable,
boundsTop = top
)
}