Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e6812bc4 | ||
|
|
7f1df16b7d | ||
|
|
56f24e1dbc |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 73
|
||||
versionName = "0.9.60"
|
||||
versionCode = 74
|
||||
versionName = "0.9.61"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+59
-1
@@ -1602,6 +1602,41 @@ class PddProductDetailCollector(
|
||||
.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* #334: the panel already shows its topmost color heading (the first
|
||||
* parsed dimension is "color") with values visible, so no restore swipe
|
||||
* is required. Swiping an already-top panel drags the bottom sheet
|
||||
* further and can make the color/size headings vanish.
|
||||
*/
|
||||
private fun isSpecPanelAtTop(screen: ParsedPddScreen): Boolean {
|
||||
val first = screen.dimensions.firstOrNull() ?: return false
|
||||
return first.key == "color" && first.values.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun recordSpecPanelTopSwipe(
|
||||
reason: AgentDiagnosticReason,
|
||||
screen: ParsedPddScreen,
|
||||
swipeCount: Int,
|
||||
headingCountBefore: Int?,
|
||||
headingCountAfter: Int?,
|
||||
) {
|
||||
if (taskId <= 0) return
|
||||
diagnostic(
|
||||
AgentDiagnosticEvent(
|
||||
taskId = taskId,
|
||||
// A dedicated PAGE_STABILITY event on purpose: SPEC_PANEL_ENTRY
|
||||
// already carries the panel-open/restore diagnostics elsewhere,
|
||||
// and several tests assert a single SPEC_PANEL_ENTRY event per run.
|
||||
stage = AgentDiagnosticStage.PAGE_STABILITY,
|
||||
reason = reason,
|
||||
attempt = swipeCount,
|
||||
targetClassName = "type=${screen.specPanelType.name};headBefore=${headingCountBefore ?: -1};" +
|
||||
"headAfter=${headingCountAfter ?: -1};dim=${screen.dimensions.sumOf { it.values.size }}",
|
||||
clickableAncestorDepth = screen.panelHeadingCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun moveSpecPanelToTop(
|
||||
goodsId: String,
|
||||
config: PddCollectorConfig,
|
||||
@@ -1611,17 +1646,40 @@ class PddProductDetailCollector(
|
||||
): PddCollectorResult? {
|
||||
var previous: List<String>? = null
|
||||
var stable = 0
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) {
|
||||
var priorHeadingCount = -1
|
||||
var priorDimensionCount = -1
|
||||
var swipes = 0
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) { pass ->
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
if (pass == 0 && isSpecPanelAtTop(screen)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY, screen, swipes, null, screen.panelHeadingCount)
|
||||
return null
|
||||
}
|
||||
val headingCount = screen.panelHeadingCount
|
||||
val dimensionCount = screen.dimensions.sumOf { it.values.size }
|
||||
// A normal scroll-to-top can legitimately drop the panel's
|
||||
// heading count (lower dimensions scroll out of view) without
|
||||
// losing any values, so heading count alone must never trigger
|
||||
// this. Only a full loss of every collected spec value, or the
|
||||
// panel no longer being recognized as open, indicates the sheet
|
||||
// was dragged too far.
|
||||
val panelNoLongerRecognized = !screen.specPanelOpen || screen.specPanelType == SpecPanelType.UNKNOWN
|
||||
if (pass > 0 && priorDimensionCount > 0 && (dimensionCount == 0 || panelNoLongerRecognized)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_COLLAPSED, screen, swipes, priorHeadingCount, headingCount)
|
||||
return failure("SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
}
|
||||
priorHeadingCount = headingCount
|
||||
priorDimensionCount = dimensionCount
|
||||
val signature = viewportSignature(screen)
|
||||
stable = if (previous != null && signature == previous) stable + 1 else 0
|
||||
previous = signature
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
||||
val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null
|
||||
if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null
|
||||
swipes++
|
||||
pause(350)
|
||||
}
|
||||
return null
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* #334: a spec probe that produced zero color/size dimensions, or that never
|
||||
* collected any dimensions because the collector itself failed (e.g. an
|
||||
* over-swiped spec panel losing its headings — see
|
||||
* [PddProductDetailCollector.moveSpecPanelToTop]), must fail explicitly
|
||||
* rather than being reported as a normal `spec_probe_completed` empty
|
||||
* result or a generic "no matchable spec" outcome.
|
||||
*
|
||||
* This lives outside [PurchaseRehearsalExecutor] on purpose: the executor's
|
||||
* `probeSpecs` callback is opaque by contract (any JSON payload the caller
|
||||
* wants to hand to the server), and its own unit tests exercise that
|
||||
* contract with canned payloads unrelated to this device bug. The checks
|
||||
* instead run once, at the point where
|
||||
* [cn.ilapage.goauto.agent.service.AgentForegroundService] turns the real
|
||||
* collector result into the outcome that gets persisted and reported.
|
||||
* `collectPurchaseProbe` encodes a failed collector run as a
|
||||
* `probeFailureCode`/`probeFailureMessage` JSON payload (still routed
|
||||
* through the same opaque `String?` probe callback) instead of `null`, so
|
||||
* [demote] can tell it apart from a genuinely empty-but-successful probe.
|
||||
*/
|
||||
object PurchaseSpecProbePolicy {
|
||||
const val EMPTY_PROBE_CODE = "PURCHASE_SPEC_PROBE_EMPTY"
|
||||
const val EMPTY_PROBE_MESSAGE = "规格探测未读取到任何颜色或尺码"
|
||||
const val FAILED_PROBE_CODE = "PURCHASE_SPEC_PROBE_FAILED"
|
||||
|
||||
/** JSON field names used by [collectPurchaseProbeFailurePayload]-style encodings. */
|
||||
const val PROBE_FAILURE_CODE_FIELD = "probeFailureCode"
|
||||
const val PROBE_FAILURE_MESSAGE_FIELD = "probeFailureMessage"
|
||||
|
||||
/** Purchaser-readable messages for known collector failure codes; anything else falls back to a generic message that still includes the raw code. */
|
||||
private val knownCollectorFailureMessages = mapOf(
|
||||
"SPEC_PANEL_TOP_COLLAPSED" to "规格探测时规格面板被拖动,规格标题消失",
|
||||
)
|
||||
|
||||
/** True when the probe JSON's `dimensions` array has no entries, or entries with no values. */
|
||||
fun isDimensionsEmpty(probedSpecsJson: String?): Boolean {
|
||||
if (probedSpecsJson.isNullOrBlank()) return true
|
||||
return try {
|
||||
val dimensions = JSONObject(probedSpecsJson).optJSONArray("dimensions") ?: return true
|
||||
var total = 0
|
||||
for (i in 0 until dimensions.length()) {
|
||||
total += dimensions.optJSONObject(i)?.optJSONArray("values")?.length() ?: 0
|
||||
}
|
||||
total == 0
|
||||
} catch (_: Exception) {
|
||||
// Malformed payload cannot be trusted as a real, non-empty probe result.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a collector failure code from a probe JSON payload built by [encodeCollectorFailure], or null when the payload is not a failure encoding. */
|
||||
fun extractCollectorFailureCode(probedSpecsJson: String?): String? {
|
||||
if (probedSpecsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
JSONObject(probedSpecsJson).optString(PROBE_FAILURE_CODE_FIELD, "").takeIf(String::isNotBlank)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the opaque probe JSON payload used to carry an explicit collector failure through the `probeSpecs: () -> String?` callback. */
|
||||
fun encodeCollectorFailure(goodsId: String, collectorCode: String, collectorMessage: String): String =
|
||||
JSONObject()
|
||||
.put("goodsId", goodsId)
|
||||
.put(PROBE_FAILURE_CODE_FIELD, collectorCode)
|
||||
.put(PROBE_FAILURE_MESSAGE_FIELD, collectorMessage)
|
||||
.toString()
|
||||
|
||||
private fun failureMessageFor(collectorCode: String): String =
|
||||
knownCollectorFailureMessages[collectorCode] ?: "商品规格探测失败:$collectorCode"
|
||||
|
||||
/**
|
||||
* Demotes a `spec_probe_completed` outcome that either carries an
|
||||
* encoded collector failure, or collected zero dimensions, into an
|
||||
* explicit failure. Any other outcome (including real failures reported
|
||||
* some other way, or a probe that did collect dimensions) is returned
|
||||
* unchanged.
|
||||
*/
|
||||
fun demote(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome {
|
||||
if (outcome.resultType != "spec_probe_completed") return outcome
|
||||
extractCollectorFailureCode(outcome.probedSpecs)?.let { collectorCode ->
|
||||
return PurchaseExecutionOutcome("failed", FAILED_PROBE_CODE, failureMessageFor(collectorCode))
|
||||
}
|
||||
if (!isDimensionsEmpty(outcome.probedSpecs)) return outcome
|
||||
return PurchaseExecutionOutcome("failed", EMPTY_PROBE_CODE, EMPTY_PROBE_MESSAGE)
|
||||
}
|
||||
|
||||
/** @see demote */
|
||||
@Deprecated("Use demote(outcome), which also handles encoded collector failures.", ReplaceWith("demote(outcome)"))
|
||||
fun demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome = demote(outcome)
|
||||
}
|
||||
@@ -30,6 +30,10 @@ enum class AgentDiagnosticReason {
|
||||
SPEC_PANEL_RESTORE_LIMIT,
|
||||
SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE,
|
||||
SPEC_PANEL_RESTORE_GESTURE_FAILED,
|
||||
/** #334: the spec panel already showed the topmost color heading; the top-restore swipe was skipped. */
|
||||
SPEC_PANEL_TOP_ALREADY,
|
||||
/** #334: a top-restore swipe made spec headings or dimensions vanish (the bottom sheet was dragged); swiping stopped. */
|
||||
SPEC_PANEL_TOP_COLLAPSED,
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
SPEC_ENTRY_CLICK_NO_EFFECT,
|
||||
SIZE_FOUND,
|
||||
|
||||
+12
-1
@@ -38,6 +38,7 @@ import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
@@ -591,7 +592,7 @@ class AgentForegroundService : Service() {
|
||||
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
|
||||
updateNotification("$taskLabel #${task.taskId}")
|
||||
|
||||
val outcome = if (!snapshotHashValid) {
|
||||
val rawOutcome = if (!snapshotHashValid) {
|
||||
PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效")
|
||||
} else {
|
||||
var parseFailure: PurchaseExecutionOutcome? = null
|
||||
@@ -652,6 +653,9 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// #334: a spec probe that read zero colors/sizes must fail explicitly
|
||||
// instead of being reported as a normal, empty spec_probe_completed.
|
||||
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
@@ -691,6 +695,13 @@ class AgentForegroundService : Service() {
|
||||
collector = collector,
|
||||
)
|
||||
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule)
|
||||
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
|
||||
// collapse into a generic "spec probe failed" outcome. Carry the
|
||||
// real code/message through the opaque probeSpecs callback so
|
||||
// PurchaseSpecProbePolicy.demote can surface it explicitly.
|
||||
if (!result.successful) {
|
||||
return PurchaseSpecProbePolicy.encodeCollectorFailure(task.pddGoodsId, result.code, result.message)
|
||||
}
|
||||
val payload = result.payload ?: return null
|
||||
return JSONObject()
|
||||
.put("goodsId", task.pddGoodsId)
|
||||
|
||||
+133
-17
@@ -974,6 +974,95 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(driver.captureCount > driver.clickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel already at top skips the restore swipe and still collects colors and sizes`() {
|
||||
// Default fixture state already renders the color heading first (at
|
||||
// top). #334: moveSpecPanelToTop must not issue any DOWN swipe in
|
||||
// that case, since dragging an already-top panel can lose headings
|
||||
// on a real device.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(0, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel not at top restores with the original swipe behavior before collecting`() {
|
||||
// The color heading is hidden until one DOWN (top-restore) swipe has
|
||||
// happened, simulating a panel scrolled past its headings.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec headings vanishing after a restore swipe stops swiping and fails explicitly`() {
|
||||
// The panel starts not-at-top (color heading hidden) so the restore
|
||||
// swipe runs; after that swipe every heading and value disappears,
|
||||
// simulating the bottom sheet being dragged off screen. #334
|
||||
// requires the collector to stop and fail instead of reporting an
|
||||
// empty successful result.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelCollapsesAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertFalse(result.successful)
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", result.code)
|
||||
assertEquals(1, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lower heading scrolling out of view after a restore swipe does not fail collection`() {
|
||||
// Three-dimension panel not at top (color heading is present but has
|
||||
// no values yet, so it does not become dimensions.first()). After the
|
||||
// restore swipe, color values return but the third, lower "容量"
|
||||
// heading scrolls out of view — a normal scroll-to-top effect. Total
|
||||
// heading count drops, but color and size values are present the
|
||||
// whole time, so this must NOT be treated as a collapsed panel.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
extraDimension = true,
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelDropsExtraDimensionAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
@@ -1044,7 +1133,9 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(result.successful)
|
||||
assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null })
|
||||
// #334: the panel already shows the color heading at top, so
|
||||
// moveSpecPanelToTop must not issue a needless DOWN restore swipe.
|
||||
assertTrue(driver.swipes.none { it.first == SwipeDirection.DOWN })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
|
||||
}
|
||||
@@ -1757,6 +1848,21 @@ class PddProductDetailCollectorTest {
|
||||
private val selectedSummaryPrefix: String = "已选",
|
||||
private val imageColorCards: Boolean = false,
|
||||
private val navigateAwayAfterColorClick: Boolean = false,
|
||||
// #334: simulates a spec panel that is not scrolled to top yet: the
|
||||
// color heading is present but has no values yet (so it is not the
|
||||
// first parsed dimension), until one DOWN (top-restore) swipe has
|
||||
// happened, after which color values render normally.
|
||||
private val specPanelHidesColorInitially: Boolean = false,
|
||||
// #334: simulates a bottom sheet dragged past its headings by a
|
||||
// restore swipe — once at least one DOWN swipe has happened, every
|
||||
// spec heading and value disappears from the panel.
|
||||
private val specPanelCollapsesAfterTopSwipe: Boolean = false,
|
||||
// #334: simulates a normal scroll-to-top on a multi-dimension panel
|
||||
// where a lower, non-spec-value-bearing heading (here the extra
|
||||
// "容量" dimension) scrolls out of view once the color heading is
|
||||
// restored to top. Heading count drops but color/size values are
|
||||
// untouched, so this must NOT be treated as a collapsed panel.
|
||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -1770,6 +1876,7 @@ class PddProductDetailCollectorTest {
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
private var verticalPage = 0
|
||||
private var downSwipeCount = 0
|
||||
private var priceRead = 0
|
||||
private var panelOpen = !startWithPanelClosed
|
||||
private var reviewOpen = false
|
||||
@@ -1832,12 +1939,16 @@ class PddProductDetailCollectorTest {
|
||||
node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel),
|
||||
)
|
||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
||||
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage) {
|
||||
if (!continuationPage && !panelCollapsedNow) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.forEachIndexed { index, color ->
|
||||
@@ -1877,21 +1988,23 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
if (!continuationPage && !panelCollapsedNow) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
if (!panelCollapsedNow) {
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (extraDimension) {
|
||||
if (extraDimension && !(specPanelDropsExtraDimensionAfterTopSwipe && downSwipeCount >= 1)) {
|
||||
nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll")
|
||||
nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll")
|
||||
}
|
||||
@@ -1966,7 +2079,10 @@ class PddProductDetailCollectorTest {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
SwipeDirection.DOWN -> {
|
||||
verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
downSwipeCount++
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseSpecProbePolicyTest {
|
||||
|
||||
@Test
|
||||
fun `zero dimensions is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("""{"dimensions":[]}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with no values is treated as empty`() {
|
||||
assertTrue(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":[]},{"key":"size","name":"尺码","values":[]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null blank or malformed payload is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(null))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(""))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("not json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with at least one value is not empty`() {
|
||||
assertFalse(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote replaces an empty spec_probe_completed outcome with an explicit failure`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode)
|
||||
assertEquals("规格探测未读取到任何颜色或尺码", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves a non-empty probe result unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves other outcome types unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode reads the encoded collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", PurchaseSpecProbePolicy.extractCollectorFailureCode(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode returns null for a normal probe payload`() {
|
||||
assertEquals(
|
||||
null,
|
||||
PurchaseSpecProbePolicy.extractCollectorFailureCode(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
assertEquals(null, PurchaseSpecProbePolicy.extractCollectorFailureCode(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote surfaces an encoded SPEC_PANEL_TOP_COLLAPSED collector failure with a purchaser-readable message`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertEquals("规格探测时规格面板被拖动,规格标题消失", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote falls back to a generic message for an unrecognized collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertTrue(demoted.message.contains("RULE_NOT_MATCHED"))
|
||||
}
|
||||
}
|
||||
@@ -647,12 +647,25 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
|
||||
request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: attemptID, Source: aimatching.SourceAI}
|
||||
if !complete {
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, "规格探测结果没有包含所需的可选颜色或尺码"))
|
||||
// #334: a probe that read zero colors and zero sizes (both dimensions
|
||||
// empty) is a distinct, more specific failure than "candidates present
|
||||
// but none matched the target" — it usually means the spec panel was
|
||||
// over-swiped off the device screen. The Agent now fails this case
|
||||
// explicitly before submitting, but resolveProbedSpecs keeps the same
|
||||
// explicit code as a defense in depth for any spec_probe_completed
|
||||
// result that still arrives empty (e.g. older Agent builds).
|
||||
reason := "规格探测结果没有包含所需的可选颜色或尺码"
|
||||
code, message := "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
|
||||
if len(candidates.Colors) == 0 && len(candidates.Sizes) == 0 {
|
||||
reason = "规格探测未读取到任何颜色或尺码"
|
||||
code, message = "PURCHASE_SPEC_PROBE_EMPTY", "规格探测未读取到任何颜色或尺码"
|
||||
}
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
|
||||
if marshalErr != nil {
|
||||
return TaskPayload{}, internal(marshalErr)
|
||||
}
|
||||
decision.NoMatch, decision.Decision = true, snapshot
|
||||
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
|
||||
decision.FailureCode, decision.FailureMessage = code, message
|
||||
} else {
|
||||
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
|
||||
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
|
||||
|
||||
@@ -592,6 +592,47 @@ func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *te
|
||||
}
|
||||
}
|
||||
|
||||
// #334: a probe with zero colors and zero sizes is a distinct, more specific
|
||||
// failure ("规格探测未读取到任何颜色或尺码") than the generic "candidates
|
||||
// present but none matched" message, so operators can tell an over-swiped
|
||||
// device probe apart from an ordinary spec mismatch.
|
||||
func TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matcher := &liveProbeMatcher{err: errors.New("AI should not be called for an empty probe")}
|
||||
s := testService(db)
|
||||
s.Matcher = matcher
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("empty probe did not fail closed without calling AI: %+v calls=%d err=%v", resolved, matcher.calls, err)
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_PROBE_EMPTY" {
|
||||
t.Fatalf("empty probe did not use the explicit empty-probe code: %+v", saved)
|
||||
}
|
||||
if saved.ErrorMessage == nil || *saved.ErrorMessage != "规格探测未读取到任何颜色或尺码" {
|
||||
t.Fatalf("empty probe did not use the explicit empty-probe message: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
|
||||
Reference in New Issue
Block a user