diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index aff5ded..d025052 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -1660,9 +1660,14 @@ class PddProductDetailCollector( } val headingCount = screen.panelHeadingCount val dimensionCount = screen.dimensions.sumOf { it.values.size } - if (pass > 0 && priorHeadingCount > 0 && - (headingCount < priorHeadingCount || (priorDimensionCount > 0 && dimensionCount == 0)) - ) { + // 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", "回顶滑动导致规格标题消失,已停止滑动") } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt index 9389fa6..268c65d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt @@ -3,24 +3,38 @@ package cn.ilapage.goauto.agent.automation import org.json.JSONObject /** - * #334: a spec probe that produced zero color/size dimensions must fail - * explicitly rather than being reported as `spec_probe_completed` with an - * empty result. An empty probe previously reached the server as a normal - * "no matchable spec" outcome (`PURCHASE_SPEC_NOT_MATCHED`), which hid the - * real cause (an over-swiped spec panel losing its headings, see - * `PddProductDetailCollector.moveSpecPanelToTop`). + * #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 empty-dimension payloads unrelated to this device - * bug. The check instead runs once, at the point where + * 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 payload into the outcome that gets persisted and reported. + * 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 { @@ -38,14 +52,44 @@ object PurchaseSpecProbePolicy { } } + /** 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 with zero collected dimensions - * into an explicit failure. Any other outcome (including real failures, - * or a probe that did collect dimensions) is returned unchanged. + * 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 demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome { + 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) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index acab072..f8fba2b 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -655,7 +655,7 @@ 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.demoteIfEmpty(rawOutcome) + 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) @@ -695,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) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 7b5657c..e91e37b 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -1037,6 +1037,32 @@ class PddProductDetailCollectorTest { 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( @@ -1822,14 +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/values are hidden, only the size heading is visible) - // until one DOWN (top-restore) swipe has happened. + // #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 @@ -1912,8 +1945,10 @@ class PddProductDetailCollectorTest { ?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)] val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20) - if (!continuationPage && !hideColorNow && !panelCollapsedNow) { + 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 -> @@ -1969,7 +2004,7 @@ class PddProductDetailCollectorTest { ) } } - 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") } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt index eb52de0..f3bc7c1 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt @@ -40,14 +40,14 @@ class PurchaseSpecProbePolicyTest { } @Test - fun `demoteIfEmpty replaces an empty spec_probe_completed outcome with an explicit failure`() { + 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.demoteIfEmpty(outcome) + val demoted = PurchaseSpecProbePolicy.demote(outcome) assertEquals("failed", demoted.resultType) assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode) @@ -55,24 +55,66 @@ class PurchaseSpecProbePolicyTest { } @Test - fun `demoteIfEmpty leaves a non-empty probe result unchanged`() { + 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.demoteIfEmpty(outcome) + val demoted = PurchaseSpecProbePolicy.demote(outcome) assertEquals(outcome, demoted) } @Test - fun `demoteIfEmpty leaves other outcome types unchanged`() { + fun `demote leaves other outcome types unchanged`() { val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败") - val demoted = PurchaseSpecProbePolicy.demoteIfEmpty(outcome) + 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")) + } }