feat(t213): guard read-only specification verification
This commit is contained in:
+78
-14
@@ -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,
|
||||
|
||||
+107
-9
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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(
|
||||
|
||||
+98
@@ -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
|
||||
)
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class ExecutionEvidenceAssociationPolicyTest {
|
||||
@Test
|
||||
fun `binds detail and specification assets to each ranked candidate`() {
|
||||
val grouped = ExecutionEvidenceAssociationPolicy.group(
|
||||
candidateOrdinals = listOf(1, 2),
|
||||
evidence = listOf(
|
||||
draft(2, ExecutionEvidenceKind.SPECIFICATION),
|
||||
draft(1, ExecutionEvidenceKind.DETAIL),
|
||||
draft(2, ExecutionEvidenceKind.DETAIL),
|
||||
draft(1, ExecutionEvidenceKind.SPECIFICATION)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2), grouped.keys.toList())
|
||||
assertEquals(
|
||||
ExecutionEvidenceKind.entries,
|
||||
grouped.getValue(1).map { it.kind }
|
||||
)
|
||||
assertEquals(
|
||||
ExecutionEvidenceKind.entries,
|
||||
grouped.getValue(2).map { it.kind }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects missing duplicate and wrong candidate assets`() {
|
||||
listOf(
|
||||
listOf(
|
||||
draft(1, ExecutionEvidenceKind.DETAIL)
|
||||
),
|
||||
listOf(
|
||||
draft(1, ExecutionEvidenceKind.DETAIL),
|
||||
draft(1, ExecutionEvidenceKind.DETAIL)
|
||||
),
|
||||
listOf(
|
||||
draft(1, ExecutionEvidenceKind.DETAIL),
|
||||
draft(2, ExecutionEvidenceKind.SPECIFICATION)
|
||||
)
|
||||
).forEach { evidence ->
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
ExecutionEvidenceAssociationPolicy.group(
|
||||
candidateOrdinals = listOf(1),
|
||||
evidence = evidence
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun draft(
|
||||
ordinal: Int,
|
||||
kind: ExecutionEvidenceKind
|
||||
) = ExecutionEvidenceDraft(
|
||||
ordinal = ordinal,
|
||||
pngBytes = byteArrayOf(ordinal.toByte(), kind.ordinal.toByte()),
|
||||
sha256 = "a".repeat(64),
|
||||
kind = kind
|
||||
)
|
||||
}
|
||||
@@ -159,7 +159,12 @@ class CandidateEvaluatorTest {
|
||||
)
|
||||
}
|
||||
|
||||
val result = evaluator.evaluate(input(candidateCount = 2))
|
||||
val result = evaluator.evaluate(
|
||||
input(
|
||||
candidateCount = 2,
|
||||
hardStatus = HardConstraintMatchStatus.MISMATCH
|
||||
)
|
||||
)
|
||||
as CandidateEvaluationResult.Completed
|
||||
|
||||
assertEquals(CandidateBatchConclusion.NO_MATCH, result.batch.conclusion)
|
||||
@@ -305,13 +310,40 @@ class CandidateEvaluatorTest {
|
||||
hardStatus = HardConstraintMatchStatus.UNKNOWN
|
||||
)
|
||||
)
|
||||
}.evaluate(input(candidateCount = 1))
|
||||
}.evaluate(
|
||||
input(
|
||||
candidateCount = 1,
|
||||
hardStatus = HardConstraintMatchStatus.UNKNOWN
|
||||
)
|
||||
)
|
||||
as CandidateEvaluationResult.Completed
|
||||
|
||||
assertTrue(CandidateTopFivePolicy.select(result.batch).isEmpty())
|
||||
assertNull(result.batch.recommendedCandidateOrdinal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `model cannot override local specification status`() = runTest {
|
||||
val response = validResponse(
|
||||
ordinal = 1,
|
||||
decision = CandidateDecision.REJECT,
|
||||
score = 0.1,
|
||||
matched = emptyList(),
|
||||
rejectionReasons = listOf("模型声称颜色不匹配"),
|
||||
hardStatus = HardConstraintMatchStatus.MISMATCH
|
||||
)
|
||||
|
||||
val result = evaluator { Result.success(response) }
|
||||
.evaluate(input(candidateCount = 1))
|
||||
as CandidateEvaluationResult.Completed
|
||||
|
||||
assertEquals(
|
||||
CandidateDecision.MANUAL_REQUIRED,
|
||||
result.batch.assessments.single().decision
|
||||
)
|
||||
assertNull(result.batch.recommendedCandidateOrdinal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoded review batch fixes order submitted to false`() = runTest {
|
||||
val result = evaluator { Result.success(validResponse(1)) }
|
||||
@@ -323,6 +355,12 @@ class CandidateEvaluatorTest {
|
||||
assertFalse(json.getBoolean("order_submitted"))
|
||||
assertTrue(json.getBoolean("manual_review_required"))
|
||||
assertEquals(1, json.getJSONArray("candidates").length())
|
||||
assertEquals(
|
||||
"e".repeat(64),
|
||||
json.getJSONArray("candidates")
|
||||
.getJSONObject(0)
|
||||
.getString("specification_evidence_sha256")
|
||||
)
|
||||
}
|
||||
|
||||
private fun evaluator(
|
||||
@@ -336,18 +374,44 @@ class CandidateEvaluatorTest {
|
||||
model = "fake-model"
|
||||
)
|
||||
|
||||
private fun input(candidateCount: Int): CandidateEvaluationInput =
|
||||
private fun input(
|
||||
candidateCount: Int,
|
||||
hardStatus: HardConstraintMatchStatus =
|
||||
HardConstraintMatchStatus.MATCH
|
||||
): CandidateEvaluationInput =
|
||||
CandidateEvaluationInput(
|
||||
requirement = requirement(),
|
||||
candidates = (1..candidateCount).map(::candidate)
|
||||
candidates = (1..candidateCount).map {
|
||||
candidate(it, hardStatus)
|
||||
}
|
||||
)
|
||||
|
||||
private fun candidate(ordinal: Int): CandidateEvaluationImage =
|
||||
private fun candidate(
|
||||
ordinal: Int,
|
||||
hardStatus: HardConstraintMatchStatus =
|
||||
HardConstraintMatchStatus.MATCH
|
||||
): CandidateEvaluationImage =
|
||||
CandidateEvaluationImage(
|
||||
ordinal = ordinal,
|
||||
mediaType = "image/png",
|
||||
bytes = byteArrayOf(ordinal.toByte()),
|
||||
sha256 = ordinal.toString(16).padStart(64, '0')
|
||||
sha256 = ordinal.toString(16).padStart(64, '0'),
|
||||
specificationBytes = byteArrayOf((ordinal + 10).toByte()),
|
||||
specificationSha256 = "e".repeat(64),
|
||||
localHardConstraintResults = listOf(
|
||||
CandidateHardConstraintResult(
|
||||
kind = SkuConstraintKind.COLOR,
|
||||
expected = "BLACK",
|
||||
status = hardStatus,
|
||||
evidence = "本地规格颜色证据"
|
||||
),
|
||||
CandidateHardConstraintResult(
|
||||
kind = SkuConstraintKind.SIZE,
|
||||
expected = "L",
|
||||
status = hardStatus,
|
||||
evidence = "本地规格尺码证据"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private fun requirement(): RequirementExtraction =
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.roubao.autopilot.vlm
|
||||
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroup
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
|
||||
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationOption
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class CandidateSpecificationHardConstraintPolicyTest {
|
||||
@Test
|
||||
fun `both explicit enabled targets match`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
HardConstraintMatchStatus.MATCH,
|
||||
HardConstraintMatchStatus.MATCH
|
||||
),
|
||||
evaluate(
|
||||
colorOptions = listOf(option("经典黑色")),
|
||||
sizeOptions = listOf(option("XXL"))
|
||||
).map { it.status }
|
||||
)
|
||||
assertTrue(
|
||||
evaluate(
|
||||
colorOptions = listOf(option("经典黑色")),
|
||||
sizeOptions = listOf(option("XXL"))
|
||||
).all {
|
||||
it.evidence.toByteArray(StandardCharsets.UTF_8).size <= 160
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complete missing target is mismatch`() {
|
||||
assertEquals(
|
||||
HardConstraintMatchStatus.MISMATCH,
|
||||
evaluate(
|
||||
colorOptions = listOf(option("白色")),
|
||||
sizeOptions = listOf(option("2XL"))
|
||||
).first().status
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled target is mismatch`() {
|
||||
assertEquals(
|
||||
HardConstraintMatchStatus.MISMATCH,
|
||||
evaluate(
|
||||
colorOptions = listOf(option("黑色", enabled = false)),
|
||||
sizeOptions = listOf(option("2XL"))
|
||||
).first().status
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated group and duplicated matching option are unknown`() {
|
||||
assertEquals(
|
||||
HardConstraintMatchStatus.UNKNOWN,
|
||||
evaluate(
|
||||
colorOptions = listOf(option("白色")),
|
||||
sizeOptions = listOf(option("2XL")),
|
||||
colorComplete = false
|
||||
).first().status
|
||||
)
|
||||
assertEquals(
|
||||
HardConstraintMatchStatus.UNKNOWN,
|
||||
evaluate(
|
||||
colorOptions = listOf(option("黑色"), option("纯黑")),
|
||||
sizeOptions = listOf(option("2XL"))
|
||||
).first().status
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing group is unknown`() {
|
||||
val evidence = specification(
|
||||
colorOptions = listOf(option("黑色")),
|
||||
sizeOptions = listOf(option("2XL"))
|
||||
).copy(groups = specification(
|
||||
colorOptions = listOf(option("黑色")),
|
||||
sizeOptions = listOf(option("2XL"))
|
||||
).groups.filter { it.kind == PinduoduoSpecificationGroupKind.COLOR })
|
||||
|
||||
val results = CandidateSpecificationHardConstraintPolicy.evaluate(
|
||||
constraints = constraints(),
|
||||
evidence = evidence,
|
||||
evidenceSha256 = "b".repeat(64)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
HardConstraintMatchStatus.UNKNOWN,
|
||||
results.last().status
|
||||
)
|
||||
}
|
||||
|
||||
private fun evaluate(
|
||||
colorOptions: List<PinduoduoSpecificationOption>,
|
||||
sizeOptions: List<PinduoduoSpecificationOption>,
|
||||
colorComplete: Boolean = true
|
||||
) = CandidateSpecificationHardConstraintPolicy.evaluate(
|
||||
constraints = constraints(),
|
||||
evidence = specification(
|
||||
colorOptions,
|
||||
sizeOptions,
|
||||
colorComplete
|
||||
),
|
||||
evidenceSha256 = "b".repeat(64)
|
||||
)
|
||||
|
||||
private fun constraints() = listOf(
|
||||
SkuHardConstraint(SkuConstraintKind.COLOR, "BLACK"),
|
||||
SkuHardConstraint(SkuConstraintKind.SIZE, "2XL")
|
||||
)
|
||||
|
||||
private fun specification(
|
||||
colorOptions: List<PinduoduoSpecificationOption>,
|
||||
sizeOptions: List<PinduoduoSpecificationOption>,
|
||||
colorComplete: Boolean = true
|
||||
) = PinduoduoSpecificationEvidence(
|
||||
signature = "specification",
|
||||
semanticTextCount = 8,
|
||||
groups = listOf(
|
||||
PinduoduoSpecificationGroup(
|
||||
PinduoduoSpecificationGroupKind.COLOR,
|
||||
"颜色分类",
|
||||
colorOptions,
|
||||
colorComplete
|
||||
),
|
||||
PinduoduoSpecificationGroup(
|
||||
PinduoduoSpecificationGroupKind.SIZE,
|
||||
"尺码",
|
||||
sizeOptions,
|
||||
true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private fun option(
|
||||
text: String,
|
||||
enabled: Boolean = true
|
||||
) = PinduoduoSpecificationOption(
|
||||
text = text,
|
||||
selected = false,
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user