Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c67f3fdd0 | ||
|
|
6f1af70d0d | ||
|
|
e26743c817 | ||
|
|
e5feab8d6b | ||
|
|
0ec8497672 | ||
|
|
954552ed11 | ||
|
|
9e5f09abc5 | ||
|
|
fcd6426f80 | ||
|
|
15ccab3960 |
@@ -30,6 +30,9 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = "-370-diag1"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
+92
-9
@@ -167,6 +167,8 @@ data class ParsedPddScreen(
|
||||
val quantityInputCount: Int = 0,
|
||||
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
|
||||
val finalSelectionSummaries: List<String> = emptyList(),
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
@@ -265,7 +267,7 @@ object PddScreenParser {
|
||||
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
|
||||
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, forFinalConfirmation: Boolean = false): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
val descendants = descendants(node, visibleNodes)
|
||||
@@ -422,9 +424,12 @@ object PddScreenParser {
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
.groupBy(VisibleSpecValue::rawText)
|
||||
.map { (_, sameLabel) ->
|
||||
sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) }
|
||||
.let { physicalOptions ->
|
||||
// Keep optionBlock's physical parent/child aggregation in both modes.
|
||||
// Only final confirmation must detect separate same-label option blocks.
|
||||
if (forFinalConfirmation) physicalOptions else physicalOptions
|
||||
.groupBy(VisibleSpecValue::rawText)
|
||||
.map { (_, sameLabel) -> sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } }
|
||||
}
|
||||
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey, heading.label, values))
|
||||
}
|
||||
@@ -471,6 +476,7 @@ object PddScreenParser {
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
var unprefixedSummaryCandidates = emptyList<String>()
|
||||
val unprefixedSummary = if (structuredSelectionPanel) {
|
||||
val quantity = quantityInputs.single()
|
||||
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
|
||||
@@ -486,6 +492,7 @@ object PddScreenParser {
|
||||
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
|
||||
}.map { it.label }.distinct()
|
||||
if (candidates.isNotEmpty()) {
|
||||
unprefixedSummaryCandidates = candidates
|
||||
summary = candidates.singleOrNull()
|
||||
break
|
||||
}
|
||||
@@ -574,6 +581,12 @@ object PddScreenParser {
|
||||
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||
quantityInputCount = quantityInputs.size,
|
||||
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||
finalSelectionSummaries = if (forFinalConfirmation) {
|
||||
// Use own node labels, not labels propagated into clickable ancestors.
|
||||
(visibleNodes.map(SnapshotNode::label).filter { label ->
|
||||
textAliases.selection.selectedPrefixes.any { label.startsWith(it) }
|
||||
} + unprefixedSummaryCandidates).distinct()
|
||||
} else emptyList(),
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
@@ -1392,6 +1405,12 @@ class PddProductDetailCollector(
|
||||
var diagnosticInitialSelectedSizes = 0
|
||||
var diagnosticSelectedSummaryPresent = false
|
||||
var diagnosticHorizontalSwipes = 0
|
||||
var diagnosticVerticalSwipes = 0
|
||||
var diagnosticTrailingEmptyReads = 0
|
||||
var diagnosticHorizontalUnknown = false
|
||||
// Exact seed sets can reconcile a later stable visit to the same row.
|
||||
// These signatures stay in memory; only the aggregate tri-state is saved.
|
||||
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
|
||||
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
||||
val imageAttempts = mutableSetOf<String>()
|
||||
|
||||
@@ -1448,6 +1467,13 @@ class PddProductDetailCollector(
|
||||
initialSelectedSizeCount = diagnosticInitialSelectedSizes,
|
||||
selectedSummaryPresent = diagnosticSelectedSummaryPresent,
|
||||
horizontalSwipes = diagnosticHorizontalSwipes,
|
||||
verticalSwipes = diagnosticVerticalSwipes,
|
||||
trailingEmptyReads = diagnosticTrailingEmptyReads,
|
||||
horizontalIncomplete = when {
|
||||
diagnosticHorizontalRows.values.any { it == true } -> true
|
||||
diagnosticHorizontalUnknown || diagnosticHorizontalRows.values.any { it == null } -> null
|
||||
else -> false
|
||||
},
|
||||
elapsedMs = now() - diagnosticStartedAt,
|
||||
)
|
||||
}
|
||||
@@ -1464,6 +1490,8 @@ class PddProductDetailCollector(
|
||||
val initialRows = colorRows(initial)
|
||||
observeColorDiscovery(initial, initialRows)
|
||||
if (initialRows.isEmpty()) {
|
||||
diagnosticTrailingEmptyReads = 1
|
||||
diagnosticHorizontalUnknown = true
|
||||
finishColorDiscovery(
|
||||
if (diagnosticNonClickableCandidates > 0) AgentDiagnosticReason.COLOR_VALUES_NOT_CLICKABLE
|
||||
else AgentDiagnosticReason.COLOR_EDGE_REACHED,
|
||||
@@ -1471,7 +1499,8 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
val attempted = mutableSetOf<String>()
|
||||
fun collectVisibleColors(): PddCollectorResult? {
|
||||
fun collectVisibleColors(onFirstRows: ((List<List<VisibleSpecValue>>) -> Unit)? = null): PddCollectorResult? {
|
||||
var firstRead = true
|
||||
while (true) {
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
@@ -1479,6 +1508,10 @@ class PddProductDetailCollector(
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
||||
val rows = colorRows(screen)
|
||||
if (firstRead) {
|
||||
onFirstRows?.invoke(rows)
|
||||
firstRead = false
|
||||
}
|
||||
observeColorDiscovery(screen, rows)
|
||||
rows.flatten().filter { SpecValueNormalizer.isSafeColor(it.text) }.forEach { value ->
|
||||
colors[value.text] = colors[value.text] == true || value.available
|
||||
@@ -1531,8 +1564,18 @@ class PddProductDetailCollector(
|
||||
rowSeeds.forEachIndexed { rowIndex, seed ->
|
||||
val moveRight = rowIndex % 2 == 0
|
||||
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
|
||||
var diagnosticPreviousSignature: List<String>? = null
|
||||
var diagnosticStableReads = 0
|
||||
var diagnosticMoved = false
|
||||
var diagnosticRowUncertain = false
|
||||
var diagnosticRowIncomplete: Boolean? = null
|
||||
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
|
||||
collectVisibleColors()?.let { return it }
|
||||
var diagnosticBeforeClicks: List<String>? = null
|
||||
collectVisibleColors { observedRows ->
|
||||
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
|
||||
.singleOrNull()?.sortedBy { it.node.bounds.left }
|
||||
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
|
||||
}?.let { return it }
|
||||
screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
@@ -1541,21 +1584,49 @@ class PddProductDetailCollector(
|
||||
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
|
||||
?.takeIf { row -> row.any { it.text in seed } }
|
||||
if (matchedRow == null) {
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
|
||||
break
|
||||
}
|
||||
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
|
||||
.let { if (moveRight) it else it.reversed() }
|
||||
val signature = optionSignature(currentRow)
|
||||
// Observe the frame immediately after the prior swipe,
|
||||
// before clicking newly visible values. A click reflow or
|
||||
// ambiguous row match cannot prove horizontal movement.
|
||||
if (diagnosticBeforeClicks == null || diagnosticBeforeClicks != signature) {
|
||||
diagnosticRowUncertain = true
|
||||
diagnosticHorizontalUnknown = true
|
||||
}
|
||||
if (!diagnosticRowUncertain && diagnosticPreviousSignature != null && signature != diagnosticPreviousSignature) {
|
||||
diagnosticMoved = true
|
||||
}
|
||||
diagnosticStableReads = if (signature == diagnosticPreviousSignature) diagnosticStableReads + 1 else 0
|
||||
diagnosticPreviousSignature = signature
|
||||
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
|
||||
horizontalSignatureReads[signature] = signatureReads
|
||||
if (signatureReads > config.limits.getValue("stableEdgeReads")) break
|
||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) break
|
||||
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
|
||||
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticRowIncomplete = false
|
||||
}
|
||||
break
|
||||
}
|
||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
||||
if (!driver.swipeSpec(direction, currentRow.first().node)) break
|
||||
if (!driver.swipeSpec(direction, currentRow.first().node)) {
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
diagnosticHorizontalSwipes++
|
||||
pause(350)
|
||||
}
|
||||
// An uncertain revisit cannot erase previously observed
|
||||
// unresolved movement; only reliable stability clears it.
|
||||
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
|
||||
?: diagnosticHorizontalRows[seed]?.takeIf { it }
|
||||
}
|
||||
collectVisibleColors()?.let { return it }
|
||||
screen = parse(goodsId, config, evidence)
|
||||
@@ -1564,6 +1635,10 @@ class PddProductDetailCollector(
|
||||
observeColorDiscovery(screen, rows)
|
||||
}
|
||||
|
||||
// One observation per outer discovery round, never per click or
|
||||
// stable-price reread. Empty parsed rows do not prove an edge.
|
||||
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
|
||||
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
|
||||
if (!discoverVertically) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
break
|
||||
@@ -1586,6 +1661,7 @@ class PddProductDetailCollector(
|
||||
}
|
||||
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
|
||||
if (anchor == null) {
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
|
||||
break
|
||||
}
|
||||
@@ -1593,6 +1669,7 @@ class PddProductDetailCollector(
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
|
||||
break
|
||||
}
|
||||
diagnosticVerticalSwipes++
|
||||
pause(350)
|
||||
}
|
||||
finishColorDiscovery()
|
||||
@@ -1621,6 +1698,9 @@ class PddProductDetailCollector(
|
||||
initialSelectedSizeCount: Int,
|
||||
selectedSummaryPresent: Boolean,
|
||||
horizontalSwipes: Int,
|
||||
verticalSwipes: Int,
|
||||
trailingEmptyReads: Int,
|
||||
horizontalIncomplete: Boolean?,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (taskId <= 0) return
|
||||
@@ -1639,6 +1719,9 @@ class PddProductDetailCollector(
|
||||
initialSelectedSizeCount = initialSelectedSizeCount,
|
||||
selectedSummaryPresent = selectedSummaryPresent,
|
||||
horizontalSwipeCount = horizontalSwipes,
|
||||
colorVerticalSwipeCount = verticalSwipes,
|
||||
colorTrailingEmptyReadCount = trailingEmptyReads,
|
||||
colorHorizontalIncomplete = horizontalIncomplete,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Current-capture evidence only. No clicks, historical selection proof, or whole-screen text fallback. */
|
||||
internal object PurchaseFinalSpecVerifier {
|
||||
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
|
||||
|
||||
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) {
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
|
||||
val visited = mutableSetOf<String>()
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && visited.add(current.path)) {
|
||||
if (current.path == container.path) return true
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return false
|
||||
}
|
||||
fun contained(node: SnapshotNode, container: SnapshotNode) =
|
||||
node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.bounds.left >= container.bounds.left && node.bounds.right <= container.bounds.right &&
|
||||
node.bounds.top >= container.bounds.top && node.bounds.bottom <= container.bounds.bottom
|
||||
|
||||
val original = PddScreenParser.parse(snapshot, config, input.goodsId, null, forFinalConfirmation = true)
|
||||
val fullPanel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
|
||||
// #331/#335 sheet ancestry, as represented by SpecPanelFixtures.sheet and taskOptionDedupSheet.
|
||||
// The inner scroll list is not the full sheet: summary and quantity may be its siblings.
|
||||
val scoped = fullPanel?.let { panel ->
|
||||
val nodes = snapshot.nodes.filter { node ->
|
||||
(within(node, panel) && contained(node, panel)) ||
|
||||
(node.parentPath == null && node.label.isBlank() && !node.clickable)
|
||||
}
|
||||
PddScreenParser.parse(snapshot.copy(nodes = nodes), config, input.goodsId, null, forFinalConfirmation = true)
|
||||
.takeIf { it.specPanelOpen }
|
||||
}
|
||||
val optionScope = if (scoped != null) fullPanel else original.specPanelContainer
|
||||
val dimensions = (scoped ?: original).dimensions.groupBy(VisibleDimension::key).mapValues { (_, groups) ->
|
||||
val options = groups.flatMap(VisibleDimension::values).filter { option ->
|
||||
optionScope != null && within(option.node, optionScope) && contained(option.node, optionScope)
|
||||
}
|
||||
// A blank clickable wrapper and its single text child may survive optionBlock separately.
|
||||
// The #331 size fixture exposes exactly the same rectangle for both. Aggregate only a
|
||||
// same-value ancestor chain with that physical rectangle, never same-label siblings.
|
||||
options.groupBy { option ->
|
||||
options.filter { other -> other.node.bounds == option.node.bounds &&
|
||||
normalize(other.text) == normalize(option.text) && within(option.node, other.node)
|
||||
}.minBy { it.node.path.length }.node.path
|
||||
}.values.map { members ->
|
||||
members.first().let { first -> first.copy(
|
||||
available = members.all(VisibleSpecValue::available),
|
||||
node = first.node.copy(
|
||||
selected = members.any { it.node.selected },
|
||||
checked = members.any { it.node.checked },
|
||||
),
|
||||
) }
|
||||
}
|
||||
}
|
||||
val summaries = scoped?.finalSelectionSummaries.orEmpty().map { raw ->
|
||||
// Only the anchored recognized prefix and its optional colon are removed.
|
||||
val trimmed = raw.trim()
|
||||
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
|
||||
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
|
||||
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
|
||||
}
|
||||
normalize(value)
|
||||
}.distinct()
|
||||
val targets = linkedMapOf("color" to normalize(input.mappedColor), "size" to normalize(input.mappedSize))
|
||||
.filterValues(String::isNotBlank)
|
||||
fun reject(dimension: String, stage: String, selected: Boolean = false, conflict: Boolean = false): Nothing {
|
||||
val detail = "finalSpec;dimension=$dimension;stage=$stage;summaryPresent=${if (summaries.isNotEmpty()) 1 else 0};" +
|
||||
"selectedFound=${if (selected) 1 else 0};conflict=${if (conflict) 1 else 0}"
|
||||
diagnostic(detail)
|
||||
throw PurchaseLiveException("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
|
||||
}
|
||||
if (targets.isEmpty()) reject("none", "missing_target")
|
||||
val selectedProof = mutableSetOf<String>()
|
||||
targets.forEach { (dimension, target) ->
|
||||
val options = dimensions[dimension].orEmpty()
|
||||
val selected = options.filter { it.node.selected || it.node.checked }
|
||||
// Parsed text already follows the established UI trailing-price separation contract.
|
||||
// The final-only normalizer removes no additional content from values, input or summary.
|
||||
val exact = options.filter { normalize(it.text) == target }
|
||||
if (selected.any { normalize(it.text) != target }) reject(dimension, "selected_conflict", selected = true, conflict = true)
|
||||
if (exact.size > 1) reject(dimension, "candidate_collision", selected.isNotEmpty(), conflict = true)
|
||||
if (exact.any { !it.available }) reject(dimension, "unavailable", selected.isNotEmpty(), conflict = true)
|
||||
if (exact.any { it.node.selected || it.node.checked }) selectedProof += dimension
|
||||
}
|
||||
if (summaries.size > 1) reject(targets.keys.first(), "summary_conflict", selectedProof.isNotEmpty(), conflict = true)
|
||||
val summary = summaries.singleOrNull()
|
||||
// Evidence only supports color then size with a space. Do not guess reverse order or comma separators.
|
||||
val expected = targets.values.joinToString(" ")
|
||||
if (summary != null && targets.size == 2) {
|
||||
val colors = (dimensions["color"].orEmpty().map { normalize(it.text) } + targets.getValue("color")).distinct()
|
||||
val sizes = (dimensions["size"].orEmpty().map { normalize(it.text) } + targets.getValue("size")).distinct()
|
||||
if (colors.any { color -> sizes.any { size ->
|
||||
"$color $size" == summary && (color != targets["color"] || size != targets["size"])
|
||||
} }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true)
|
||||
}
|
||||
targets.keys.forEach { dimension ->
|
||||
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof")
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing #335 quantity-ancestor form, retaining identity as well as bounds for summary ownership. */
|
||||
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
|
||||
val visible = snapshot.nodes.filter(SnapshotNode::visible)
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenArea = screenWidth.toLong() * screenHeight
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
var current = visible.singleOrNull {
|
||||
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
|
||||
} ?: return null
|
||||
var panel: SnapshotNode? = null
|
||||
val visited = mutableSetOf(current.path)
|
||||
while (true) {
|
||||
val parent = current.parentPath?.let(byPath::get) ?: break
|
||||
if (!visited.add(parent.path)) return null
|
||||
val area = parent.bounds.width.toLong() * parent.bounds.height
|
||||
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
|
||||
panel = parent
|
||||
current = parent
|
||||
}
|
||||
return panel
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -321,10 +321,7 @@ class PurchaseLiveAutomation(
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
|
||||
if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) {
|
||||
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
|
||||
}
|
||||
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
|
||||
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
|
||||
|
||||
@@ -13,6 +13,20 @@ object SpecValueNormalizer {
|
||||
|
||||
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
|
||||
|
||||
/** Final purchase confirmation only: display equivalents, never semantic/content removal. */
|
||||
fun normalizeFinalConfirmation(value: String): String = buildString {
|
||||
value.forEach { ch ->
|
||||
append(when (ch) {
|
||||
'【', '〔', '[' -> '['
|
||||
'】', '〕', ']' -> ']'
|
||||
'(' -> '('
|
||||
')' -> ')'
|
||||
in 'A'..'Z', in 'a'..'z', in '0'..'9' -> (ch.code - 0xFEE0).toChar()
|
||||
else -> if (ch.isWhitespace()) ' ' else ch
|
||||
})
|
||||
}
|
||||
}.replace(Regex(" +"), " ").trim()
|
||||
|
||||
/** The same terminal-price grammar as normalization; never reads numbers inside a spec. */
|
||||
internal fun trailingPriceAmount(value: String): java.math.BigDecimal? =
|
||||
trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull()
|
||||
|
||||
+14
-2
@@ -1,7 +1,7 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
internal object AgentDiagnosticSchema {
|
||||
const val VERSION = 4
|
||||
const val VERSION = 5
|
||||
|
||||
val failureSnapshotStatements = listOf(
|
||||
"""CREATE TABLE IF NOT EXISTS purchase_failure_snapshot (
|
||||
@@ -38,6 +38,12 @@ internal object AgentDiagnosticSchema {
|
||||
"horizontal_swipe_count" to "INTEGER",
|
||||
)
|
||||
|
||||
val colorDiscoveryOutcomeColumns = linkedMapOf(
|
||||
"color_vertical_swipe_count" to "INTEGER",
|
||||
"color_trailing_empty_read_count" to "INTEGER",
|
||||
"color_horizontal_incomplete" to "INTEGER",
|
||||
)
|
||||
|
||||
val createTableSql =
|
||||
"""CREATE TABLE agent_diagnostic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -66,6 +72,9 @@ internal object AgentDiagnosticSchema {
|
||||
initial_selected_size_count INTEGER,
|
||||
selected_summary_present INTEGER,
|
||||
horizontal_swipe_count INTEGER,
|
||||
color_vertical_swipe_count INTEGER,
|
||||
color_trailing_empty_read_count INTEGER,
|
||||
color_horizontal_incomplete INTEGER,
|
||||
task_type TEXT,
|
||||
task_attempt_id TEXT,
|
||||
device_id INTEGER,
|
||||
@@ -87,5 +96,8 @@ internal object AgentDiagnosticSchema {
|
||||
(if (oldVersion < 3 && newVersion >= 3) purchaseDiagnosticColumns.mapNotNull { (name, definition) ->
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
} else emptyList()) +
|
||||
(if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList())
|
||||
(if (oldVersion < 4 && newVersion >= 4) failureSnapshotStatements else emptyList()) +
|
||||
(if (oldVersion < 5 && newVersion >= 5) colorDiscoveryOutcomeColumns.mapNotNull { (name, definition) ->
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
} else emptyList())
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@ data class AgentDiagnosticEvent(
|
||||
val deviceId: Long? = null,
|
||||
val phase: String? = null,
|
||||
val ruleSnapshotHash: String? = null,
|
||||
/** Successfully dispatched UP swipes during this color discovery; null means unknown. */
|
||||
val colorVerticalSwipeCount: Int? = null,
|
||||
/** Consecutive empty parsed-color discovery rounds at termination, excluding click/price reads. */
|
||||
val colorTrailingEmptyReadCount: Int? = null,
|
||||
/** Required horizontal exploration still unresolved at termination; not needed or recovered is false. */
|
||||
val colorHorizontalIncomplete: Boolean? = null,
|
||||
)
|
||||
|
||||
class SafeAgentDiagnosticRecorder(
|
||||
@@ -197,6 +203,9 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
|
||||
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
|
||||
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
|
||||
putNullableInt("color_vertical_swipe_count", event.colorVerticalSwipeCount)
|
||||
putNullableInt("color_trailing_empty_read_count", event.colorTrailingEmptyReadCount)
|
||||
putNullableBoolean("color_horizontal_incomplete", event.colorHorizontalIncomplete)
|
||||
put("agent_version", BuildConfig.VERSION_NAME)
|
||||
put("created_at", event.createdAt)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,27 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AgentDiagnosticRecorderTest {
|
||||
@Test
|
||||
fun colorDiscoveryOutcomeDefaultsToUnknownAndPreservesExplicitValues() {
|
||||
val event = AgentDiagnosticEvent(
|
||||
taskId = 370,
|
||||
stage = AgentDiagnosticStage.COLOR_DISCOVERY,
|
||||
reason = AgentDiagnosticReason.COLOR_EDGE_REACHED,
|
||||
)
|
||||
assertEquals(null, event.colorVerticalSwipeCount)
|
||||
assertEquals(null, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
val completed = event.copy(
|
||||
colorVerticalSwipeCount = 4,
|
||||
colorTrailingEmptyReadCount = 2,
|
||||
colorHorizontalIncomplete = false,
|
||||
)
|
||||
assertEquals(4, completed.colorVerticalSwipeCount)
|
||||
assertEquals(2, completed.colorTrailingEmptyReadCount)
|
||||
assertEquals(false, completed.colorHorizontalIncomplete)
|
||||
assertEquals(true, completed.copy(colorHorizontalIncomplete = true).colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() {
|
||||
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
|
||||
|
||||
+248
-3
@@ -1220,6 +1220,246 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery counts only its own successful vertical swipes`() {
|
||||
val colors = listOf("A色", "B色", "C色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
|
||||
sizePages = listOf(listOf("S"), listOf("M"), listOf("L")),
|
||||
rowSize = 1,
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(2, event.colorVerticalSwipeCount)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.UP } > requireNotNull(event.colorVerticalSwipeCount))
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery failed vertical dispatch is not counted`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(colors = listOf("A色", "B色"), rowSize = 1, verticalSwipeSucceeds = false)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(AgentDiagnosticReason.COLOR_SWIPE_FAILED, event.reason)
|
||||
assertEquals(0, event.colorVerticalSwipeCount)
|
||||
assertEquals(0, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(false, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery counts empty parsed rounds after heading scrolls away without counting price reads`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorVerticalPages = listOf(colors.take(2), colors.takeLast(2)),
|
||||
rowSize = 1,
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
priceDelayReads = colors.associateWith { 2 },
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
// This fixture retains the options while their heading disappears. The
|
||||
// existing parser/traversal still misses the second page in this stage.
|
||||
assertEquals(colors.take(2), requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(2, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(2, event.colorVerticalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery initially empty is one empty round with unknown horizontal state`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = emptyList()), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(0, event.colorVerticalSwipeCount)
|
||||
assertEquals(1, event.colorTrailingEmptyReadCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery resets trailing empty rounds after nonempty discovery`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorVerticalPages = listOf(colors.take(2), emptyList(), colors.takeLast(2)),
|
||||
rowSize = 1,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 30000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(3, event.colorVerticalSwipeCount)
|
||||
assertEquals(0, event.colorTrailingEmptyReadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery stable horizontal rows are complete`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(false, event.colorHorizontalIncomplete)
|
||||
assertEquals(0, event.colorTrailingEmptyReadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery horizontal dispatch failure before movement stays unknown`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val base = FakeCollectorDriver(colors = listOf("A色", "B色"))
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean =
|
||||
if (direction == SwipeDirection.LEFT) false else base.swipeSpec(direction, anchor)
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(0, event.horizontalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery successful horizontal dispatch alone does not prove required exploration`() {
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
val limited = config().copy(limits = config().limits + mapOf("specHorizontalSwipes" to 1, "stableEdgeReads" to 2))
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = listOf("A色", "B色")), { clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule(limited))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(1, event.horizontalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery observed horizontal movement then failure is incomplete`() {
|
||||
val colors = listOf("A色", "B色", "C色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val base = FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2)))
|
||||
var leftSwipes = 0
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
|
||||
return base.swipeSpec(direction, anchor)
|
||||
}
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(1, event.horizontalSwipeCount)
|
||||
assertEquals(true, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery uncertain revisit preserves proven incomplete horizontal row`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色", "E色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val base = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorPages = listOf(listOf("A色", "B色", "D色", "E色"), listOf("B色", "C色", "D色", "E色")),
|
||||
rowSize = 2,
|
||||
resetHorizontalPageOnVerticalSwipe = true,
|
||||
)
|
||||
var leftSwipes = 0
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
if (direction == SwipeDirection.LEFT && leftSwipes++ > 0) return false
|
||||
return base.swipeSpec(direction, anchor)
|
||||
}
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 124, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertTrue(requireNotNull(result.payload).dimensions.first { it.key == "color" }.values.contains("C色"))
|
||||
assertTrue(leftSwipes > 2)
|
||||
// The first A/B visit moves then fails; later A/B visits fail before
|
||||
// movement. Unknown new evidence must not erase that unresolved work.
|
||||
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery observed horizontal movement at budget is incomplete`() {
|
||||
val colors = listOf("A色", "B色", "C色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
val limited = config().copy(limits = config().limits + ("specHorizontalSwipes" to 1))
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
|
||||
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule(limited))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(true, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery disjoint horizontal reflow is unknown`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
|
||||
{ clock }, { clock += it }, taskId = 124, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(1, event.horizontalSwipeCount)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color discovery records only bounded aggregate evidence`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
@@ -1857,6 +2097,7 @@ class PddProductDetailCollectorTest {
|
||||
sizes: List<String> = listOf("S"),
|
||||
private val colorPages: List<List<String>> = listOf(colors),
|
||||
private val colorVerticalPages: List<List<String>>? = null,
|
||||
private val resetHorizontalPageOnVerticalSwipe: Boolean = false,
|
||||
private val sizePages: List<List<String>> = listOf(sizes),
|
||||
private val prices: Map<String, Long> = colors.associateWith { 1000L },
|
||||
private val failedClicks: Set<String> = emptySet(),
|
||||
@@ -1867,6 +2108,7 @@ class PddProductDetailCollectorTest {
|
||||
private val rowSize: Int = 4,
|
||||
private val hideSelectedColorOption: Boolean = false,
|
||||
private val hideDimensionHeadingsAfterFirstVerticalPage: Boolean = false,
|
||||
private val hideColorHeadingAfterFirstVerticalPage: Boolean = false,
|
||||
private val soldOutUntilPulls: Int = 0,
|
||||
private val startWithPanelClosed: Boolean = false,
|
||||
private val firstEntryOpensReview: Boolean = false,
|
||||
@@ -1986,7 +2228,7 @@ class PddProductDetailCollectorTest {
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage && !panelCollapsedNow) {
|
||||
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
@@ -2118,8 +2360,11 @@ class PddProductDetailCollectorTest {
|
||||
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
|
||||
when (direction) {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
SwipeDirection.UP -> {
|
||||
verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
if (resetHorizontalPageOnVerticalSwipe) horizontalPage = 0
|
||||
}
|
||||
SwipeDirection.DOWN -> {
|
||||
verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
downSwipeCount++
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Final capture only; derived from the existing #331/#332 bounded purchase-sheet fixtures. */
|
||||
class PurchaseFinalSpecConfirmationTest {
|
||||
@Test fun `bracket display variants and offscreen color are proven by full panel summary`() {
|
||||
val snapshot = sheet("已选 [香槟]RW圆领 L码[105-115斤]", colors = listOf("其他颜色"), sizes = listOf("L码[105-115斤]"))
|
||||
.selected("size/o0/t")
|
||||
accepts(snapshot, "【香槟】RW圆领", "L码【105-115斤】")
|
||||
}
|
||||
|
||||
@Test fun `summary above scroll area is accepted for scrollable and non scrollable sheets`() {
|
||||
for (scrollable in listOf(true, false)) {
|
||||
accepts(sheet("已选 黑色 XL", scrollable = scrollable))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `checked only exact option and physical parent child duplicates provide selection proof`() {
|
||||
accepts(sheet(null).selected("color/o0/img", checked = true).selected("size/o0/t", checked = true))
|
||||
}
|
||||
|
||||
@Test fun `selected other color or size overrides matching summary and positive target`() {
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色", "白色")).selected("color/o0").selected("color/o1"))
|
||||
rejects(sheet("已选 黑色 XL", sizes = listOf("XL", "L")).selected("size/o1/t"))
|
||||
}
|
||||
|
||||
@Test fun `selected bracket variants are not a conflict`() {
|
||||
accepts(sheet("已选 [白色] XL", colors = listOf("[白色]")).selected("color/o0").selected("size/o0/t"), "【白色】")
|
||||
}
|
||||
|
||||
@Test fun `distinct physical identical or normalized target options are collisions`() {
|
||||
for (colors in listOf(listOf("黑色", "黑色"), listOf("【白色】", "[白色]"))) {
|
||||
rejects(sheet("已选 ${colors[0]} XL", colors = colors).selected("color/o0"), colors[0])
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `explicit unavailable target cannot be rescued by summary`() {
|
||||
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
|
||||
}
|
||||
|
||||
@Test fun `all required selected proofs take priority over a single non proving summary`() {
|
||||
accepts(sheet("已选 其他颜色 其他尺码").selected("color/o0/img").selected("size/o0/t"))
|
||||
rejects(sheet("已选 白色 L", colors = listOf("黑色", "白色"), sizes = listOf("XL", "L"))
|
||||
.selected("color/o0/img").selected("size/o0/t")) // Known alternative composition is a conflict.
|
||||
}
|
||||
|
||||
@Test fun `selected options retain established parser separation of trailing display price`() {
|
||||
accepts(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60"), sizes = listOf("XL ¥19.60"))
|
||||
.selected("color/o0/img").selected("size/o0/t"))
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色加绒 ¥19.60")).selected("color/o0/img"))
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60", "黑色 ¥20.00")).selected("color/o0/img"))
|
||||
}
|
||||
|
||||
@Test fun `unselected visible options alone and unrelated title cannot prove selection`() {
|
||||
rejects(sheet(null).addText("r/title", "示例黑色 XL 商品"))
|
||||
}
|
||||
|
||||
@Test fun `panel external prefixed summary is ignored even at same coordinates`() {
|
||||
rejects(sheet(null).addText("r/fake", "已选 黑色 XL"))
|
||||
accepts(sheet("已选 黑色 XL").addText("r/fake", "已选 白色 L"))
|
||||
}
|
||||
|
||||
@Test fun `structural ancestry overrides textual path prefix`() {
|
||||
rejects(sheet(null).addText("r/sheet/fake", "已选 黑色 XL", parent = "r"))
|
||||
}
|
||||
|
||||
@Test fun `whole summary rejects substring sizes colors extra dimensions quantity and unsupported separators`() {
|
||||
for (summary in listOf("已选 黑色 XL", "已选 黑色 加绒 L", "已选 黑色 L 2件", "已选 黑色,L", "已选 L 黑色")) {
|
||||
rejects(sheet(summary, sizes = listOf("L")), size = "L")
|
||||
}
|
||||
rejects(sheet("已选 [白色+黑色]超值两件装 XL", colors = emptyList()), "【白色】")
|
||||
}
|
||||
|
||||
@Test fun `known candidates making another full composition reject ambiguous summary`() {
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), size = "加绒 XL")
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), color = "黑色 加绒")
|
||||
}
|
||||
|
||||
@Test fun `summary permits invisible color despite another unselected visible color`() {
|
||||
accepts(sheet("已选 黑色 XL", colors = listOf("白色")))
|
||||
}
|
||||
|
||||
@Test fun `required single dimension must equal whole summary and empty pair is never proof`() {
|
||||
accepts(sheet("已选 黑色"), size = "")
|
||||
accepts(sheet("已选 XL"), color = "")
|
||||
rejects(sheet("已选 黑色 XL"), size = "")
|
||||
rejects(sheet("已选 黑色 XL"), color = "")
|
||||
rejects(sheet("已选 黑色 XL"), color = " ", size = "")
|
||||
}
|
||||
|
||||
@Test fun `anchored selected prefix variants preserve internal colons commas and whitespace`() {
|
||||
for (prefix in listOf("已选", "已选 ", "已选:", "已选: ", " 已选: ")) {
|
||||
accepts(sheet("$prefix 黑色:加绒,款 XL", colors = emptyList()), color = "黑色:加绒,款")
|
||||
}
|
||||
accepts(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色 加绒")
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色加绒")
|
||||
rejects(sheet("已选 黑色:加绒 XL", colors = emptyList()), color = "黑色加绒")
|
||||
rejects(sheet("已选::黑色 XL"))
|
||||
accepts(sheet("已选::黑色 XL", colors = emptyList()), color = ":黑色")
|
||||
}
|
||||
|
||||
@Test fun `conflicting scoped summaries reject even both exact options selected`() {
|
||||
rejects(sheet("已选 黑色 XL").selected("color/o0").selected("size/o0/t").addText("r/sheet/second", "已选 白色 L"))
|
||||
accepts(sheet("已选 黑色 XL").addText("r/sheet/second", "已选:黑色 XL"))
|
||||
}
|
||||
|
||||
@Test fun `display normalization is narrow and content preserving`() {
|
||||
accepts(sheet("已选 [白色](加绒) RW12 XL", colors = emptyList()), color = "〔白色〕(加绒) RW12")
|
||||
rejects(sheet("已选 白色 RW12 XL", colors = emptyList()), color = "白色 rw12")
|
||||
rejects(sheet("已选 白色 1 XL", colors = emptyList()), color = "白色 ①")
|
||||
rejects(sheet("已选 白色 XL", colors = emptyList()), color = "白色 ¥19.6")
|
||||
}
|
||||
|
||||
@Test fun `existing structurally recognized unprefixed summary is usable`() {
|
||||
accepts(prefixlessSheet())
|
||||
rejects(sheet("黑色 XL")) // No quantity/header relationship in this fixture.
|
||||
rejects(sheet(null).addText("r/title", "黑色 XL"))
|
||||
rejects(prefixlessSheet().addText("r/sheet/body/info/second", "黑色 L"))
|
||||
}
|
||||
|
||||
@Test fun `same dimension groups retain earlier selected conflicts and physical candidates`() {
|
||||
val base = sheet("已选 黑色 XL").addText("r/sheet/body/list/extra/h", "颜色分类")
|
||||
.addText("r/sheet/body/list/extra/o", "白色")
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/list/extra/h" -> it.copy(bounds = NodeBounds(20, 1000, 500, 1040), parentPath = "r/sheet/body/list")
|
||||
"r/sheet/body/list/extra/o" -> it.copy(bounds = NodeBounds(20, 1045, 500, 1100), parentPath = "r/sheet/body/list", clickable = true, selected = true)
|
||||
"r/sheet/body/list" -> it.copy(bounds = NodeBounds(0, 980, 1080, 2036))
|
||||
else -> it
|
||||
} }
|
||||
rejects(base)
|
||||
rejects(base.mapNodes { if (it.path.endsWith("extra/o")) it.copy(text = "黑色") else it })
|
||||
}
|
||||
|
||||
@Test fun `collector default still collapses same raw labels and leaves summary behavior unchanged`() {
|
||||
val snapshot = sheet("已选 黑色 XL", colors = listOf("黑色", "黑色"))
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val regular = PddScreenParser.parse(snapshot, config, "374", null)
|
||||
val final = PddScreenParser.parse(snapshot, config, "374", null, forFinalConfirmation = true)
|
||||
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(regular.selectedSummary, final.selectedSummary)
|
||||
assertTrue(regular.finalSelectionSummaries.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `independently bounded selected options survive absent full panel boundary`() {
|
||||
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
|
||||
.mapNodes { if (it.path == "r/sheet") it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it }
|
||||
PurchaseFinalSpecVerifier.verify(snapshot, input()) { fail(it) }
|
||||
val noSelections = snapshot.mapNodes { it.copy(selected = false, checked = false) }
|
||||
.addText("r/sheet/fake", "已选 黑色 XL")
|
||||
try {
|
||||
PurchaseFinalSpecVerifier.verify(noSelections, input()) {}
|
||||
fail("unproven full panel must not supply summary")
|
||||
} catch (failure: PurchaseLiveException) { assertEquals("PURCHASE_SPEC_NOT_MATCHED", failure.code) }
|
||||
}
|
||||
|
||||
@Test fun `price quantity and address checks remain enforced`() {
|
||||
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_PRICE_OUT_OF_RANGE", input = input().copy(maxUnitPriceCent = 1))
|
||||
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_QUANTITY_MISMATCH", input = input().copy(quantity = 2))
|
||||
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path == "r/sheet/body/addr/a/detail") it.copy(text = "示例地址") else it }, expectedCode = "PURCHASE_ADDRESS_UPDATE_FAILED")
|
||||
}
|
||||
|
||||
private fun sheet(
|
||||
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"), scrollable: Boolean = true,
|
||||
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes, listScrollable = scrollable))
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
|
||||
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
|
||||
else -> it
|
||||
} }
|
||||
|
||||
/** Reuses the info-above-list shape from PddProductDetailCollectorTest.prefixlessPanel. */
|
||||
private fun prefixlessSheet(): UiSnapshot {
|
||||
val base = sheet("黑色 XL")
|
||||
val info = base.nodes.single { it.path == "r/sheet/body/qty" }.copy(
|
||||
path = "r/sheet/body/info", bounds = NodeBounds(0, 770, 1080, 1000), parentPath = "r/sheet/body",
|
||||
)
|
||||
return base.mapNodes { when {
|
||||
it.path == "r/sheet/body/summary" -> it.copy(path = "r/sheet/body/info/summary", parentPath = info.path)
|
||||
it.path == "r/sheet/body/qty" -> it.copy(path = "r/sheet/body/info/qty", parentPath = info.path)
|
||||
it.path.startsWith("r/sheet/body/qty/") -> it.copy(path = it.path.replace("body/qty/", "body/info/qty/"), parentPath = "r/sheet/body/info/qty")
|
||||
else -> it
|
||||
} }.let { it.copy(nodes = it.nodes + info) }
|
||||
}
|
||||
|
||||
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
|
||||
private fun UiSnapshot.selected(suffix: String, checked: Boolean = false) = mapNodes {
|
||||
if (it.path.endsWith(suffix)) it.copy(selected = !checked, checked = checked) else it
|
||||
}
|
||||
private fun UiSnapshot.addText(path: String, text: String, parent: String = path.substringBeforeLast('/')) = copy(nodes = nodes + SnapshotNode(
|
||||
path, parent, text, null, null, "android.widget.TextView", NodeBounds(384, 791, 1068, 852),
|
||||
false, false, false, false, true, true,
|
||||
))
|
||||
private fun input(color: String = "黑色", size: String = "XL") = PurchaseExecutionInput(
|
||||
taskId = 374, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=374",
|
||||
goodsId = "374", mappedColor = color, mappedSize = size, quantity = 1,
|
||||
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg374",
|
||||
)
|
||||
|
||||
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
|
||||
val driver = object : PurchaseUiDriver {
|
||||
override fun capture() = snapshot
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("final confirmation must be read only")
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
}
|
||||
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
|
||||
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg374"))
|
||||
}
|
||||
private fun accepts(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL") {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val result = try { check(snapshot, input(color, size), diagnostics) } catch (failure: PurchaseLiveException) {
|
||||
throw AssertionError("${failure.code}: ${diagnostics.lastOrNull()}", failure)
|
||||
}
|
||||
assertEquals(color, result.mappedColor)
|
||||
}
|
||||
private fun rejects(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL", expectedCode: String = "PURCHASE_SPEC_NOT_MATCHED", input: PurchaseExecutionInput = input(color, size)) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
try {
|
||||
check(snapshot, input, diagnostics)
|
||||
fail("expected $expectedCode")
|
||||
} catch (failure: PurchaseLiveException) {
|
||||
assertEquals(expectedCode, failure.code)
|
||||
if (expectedCode == "PURCHASE_SPEC_NOT_MATCHED") {
|
||||
val detail = diagnostics.last()
|
||||
assertTrue(detail, detail.matches(Regex("finalSpec;dimension=(color|size|none);stage=[a-z_]+;summaryPresent=[01];selectedFound=[01];conflict=[01]")))
|
||||
assertTrue(failure.message.orEmpty(), failure.message.orEmpty().contains(detail))
|
||||
assertFalse(failure.message.orEmpty().contains(ADDRESS))
|
||||
assertFalse(failure.message.orEmpty().contains("黑色"))
|
||||
assertFalse(failure.message.orEmpty().contains("XL"))
|
||||
}
|
||||
}
|
||||
}
|
||||
private companion object { const val ADDRESS = "示例区示例路374号_cg374" }
|
||||
}
|
||||
@@ -8,6 +8,15 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SpecValueNormalizerTest {
|
||||
@Test
|
||||
fun `final confirmation maps only display brackets alphanumerics and whitespace`() {
|
||||
assertEquals("[白][黑][灰](薄款) RW12 XL", SpecValueNormalizer.normalizeFinalConfirmation(" 【白】〔黑〕[灰](薄款) RW12\t\nXL "))
|
||||
assertEquals("白色,加绒:XL ¥19.60 ① 2件", SpecValueNormalizer.normalizeFinalConfirmation("白色,加绒:XL ¥19.60 ① 2件"))
|
||||
assertEquals("xl", SpecValueNormalizer.normalizeFinalConfirmation("xl"))
|
||||
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeColor("【白色】 XL"))
|
||||
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeSize("【白色】 XL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only trailing price is removed`() {
|
||||
assertEquals("紫色", SpecValueNormalizer.normalizeColor("紫色 ¥15.78"))
|
||||
|
||||
+177
-1
@@ -3,10 +3,112 @@ package cn.ilapage.goauto.agent.persistence
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentDiagnosticStoreMigrationTest {
|
||||
@Test
|
||||
fun freshV5DatabaseHasNullableColorDiscoveryOutcomeColumns() = withDatabase { db ->
|
||||
db.createStatement().use { it.execute(AgentDiagnosticSchema.createTableSql) }
|
||||
assertEquals(5, AgentDiagnosticSchema.VERSION)
|
||||
assertV5Columns(db)
|
||||
insertOldDiagnostic(db)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
for (incomplete in listOf(0, 1)) {
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeUpdate(
|
||||
"UPDATE agent_diagnostic SET color_vertical_swipe_count=4, " +
|
||||
"color_trailing_empty_read_count=2, color_horizontal_incomplete=$incomplete",
|
||||
)
|
||||
statement.executeQuery(
|
||||
"SELECT color_vertical_swipe_count, color_trailing_empty_read_count, " +
|
||||
"color_horizontal_incomplete FROM agent_diagnostic",
|
||||
).use { row ->
|
||||
assertTrue(row.next())
|
||||
assertEquals(4, row.getInt(1))
|
||||
assertEquals(2, row.getInt(2))
|
||||
assertEquals(incomplete, row.getInt(3))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everySupportedVersionUpgradesToV5WithoutChangingExistingRows() {
|
||||
for (oldVersion in 1..4) withDatabase { db ->
|
||||
createOldDatabase(db, oldVersion)
|
||||
insertOldDiagnostic(db)
|
||||
val existingTables = snapshotTables(db)
|
||||
|
||||
migrate(db, oldVersion, 5)
|
||||
|
||||
assertV5Columns(db)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.colorDiagnosticColumns.keys))
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseDiagnosticColumns.keys))
|
||||
assertEquals(3, snapshotTables(db).size)
|
||||
migrate(db, oldVersion, 5)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
existingTables.forEach { (name, rows) -> assertEquals(name, rows, tableRows(db, name)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v4UpgradeAddsOnlyMissingColumnsAndKeepsExistingV5Values() = withDatabase { db ->
|
||||
createOldDatabase(db, 4)
|
||||
insertOldDiagnostic(db)
|
||||
db.createStatement().use {
|
||||
it.execute("ALTER TABLE agent_diagnostic ADD COLUMN color_vertical_swipe_count INTEGER")
|
||||
it.executeUpdate("UPDATE agent_diagnostic SET color_vertical_swipe_count=3")
|
||||
}
|
||||
val statements = AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db))
|
||||
assertEquals(2, statements.size)
|
||||
assertTrue(statements.all { it.startsWith("ALTER TABLE agent_diagnostic ADD COLUMN ") })
|
||||
migrate(db, 4, 5)
|
||||
assertV5Columns(db)
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT color_vertical_swipe_count, color_trailing_empty_read_count, color_horizontal_incomplete FROM agent_diagnostic").use {
|
||||
assertTrue(it.next())
|
||||
assertEquals(3, it.getInt(1))
|
||||
assertNull(it.getObject(2))
|
||||
assertNull(it.getObject(3))
|
||||
}
|
||||
}
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(5, 5, columnNames(db)).isEmpty())
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 4, columnNames(db)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v5UpgradeSurvivesDatabaseReopen() {
|
||||
val file = java.io.File.createTempFile("goauto-diagnostic-migration-", ".db")
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC")
|
||||
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
|
||||
createOldDatabase(db, 4)
|
||||
insertOldDiagnostic(db)
|
||||
migrate(db, 4, 5)
|
||||
db.createStatement().use { it.execute("PRAGMA user_version=5") }
|
||||
}
|
||||
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { db ->
|
||||
assertV5Columns(db)
|
||||
assertOldDiagnosticPreserved(db)
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(4, 5, columnNames(db)).isEmpty())
|
||||
assertEquals(3, snapshotTables(db).size)
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("PRAGMA user_version").use {
|
||||
assertTrue(it.next())
|
||||
assertEquals(5, it.getInt(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
check(file.delete()) { "Could not remove temporary migration test database" }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v4AddsIndependentFailureSnapshotQueueWithoutChangingStructuredRows() = withDatabase { db ->
|
||||
db.createStatement().use {
|
||||
@@ -16,7 +118,6 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
AgentDiagnosticSchema.migrationStatements(3, 4, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
assertEquals(4, AgentDiagnosticSchema.VERSION)
|
||||
for (table in listOf("purchase_failure_snapshot", "purchase_failure_snapshot_queue", "purchase_failure_snapshot_context")) {
|
||||
db.prepareStatement("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?").use {
|
||||
it.setString(1, table)
|
||||
@@ -137,6 +238,80 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
assertEquals(1, rowCount(db))
|
||||
}
|
||||
|
||||
private fun assertV5Columns(db: Connection) {
|
||||
val actual = mutableSetOf<String>()
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("PRAGMA table_info(agent_diagnostic)").use { rows ->
|
||||
while (rows.next()) {
|
||||
val name = rows.getString("name")
|
||||
if (name in V5_COLUMNS) {
|
||||
actual.add(name)
|
||||
assertEquals(name, "INTEGER", rows.getString("type"))
|
||||
assertEquals(name, 0, rows.getInt("notnull"))
|
||||
assertNull(name, rows.getObject("dflt_value"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(V5_COLUMNS, actual)
|
||||
}
|
||||
|
||||
private fun createOldDatabase(db: Connection, version: Int) {
|
||||
db.createStatement().use { it.execute(CREATE_V1_TABLE_SQL) }
|
||||
migrate(db, 1, version)
|
||||
if (version >= 4) db.createStatement().use {
|
||||
it.execute("INSERT INTO purchase_failure_snapshot VALUES ('attempt','test-origin',106,7,'spec_probe',1000,2000,'test','ready','{}',X'0102','test-fingerprint',2)")
|
||||
it.execute("INSERT INTO purchase_failure_snapshot_queue VALUES ('attempt','test-fingerprint',1500,2)")
|
||||
it.execute("INSERT INTO purchase_failure_snapshot_context VALUES ('attempt','test-origin','{}',1000)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertOldDiagnostic(db: Connection) {
|
||||
db.createStatement().use {
|
||||
it.execute("INSERT INTO agent_diagnostic (task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at) VALUES (106,'COLOR_DISCOVERY','COLOR_EDGE_REACHED',2,10,'old',1000)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertOldDiagnosticPreserved(db: Connection) {
|
||||
assertEquals(1, rowCount(db))
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT id,task_id,stage,reason,attempt,elapsed_ms,agent_version,created_at,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic").use {
|
||||
assertTrue(it.next())
|
||||
assertEquals(1L, it.getLong(1))
|
||||
assertEquals(106L, it.getLong(2))
|
||||
assertEquals("COLOR_DISCOVERY", it.getString(3))
|
||||
assertEquals("COLOR_EDGE_REACHED", it.getString(4))
|
||||
assertEquals(2, it.getInt(5))
|
||||
assertEquals(10L, it.getLong(6))
|
||||
assertEquals("old", it.getString(7))
|
||||
assertEquals(1000L, it.getLong(8))
|
||||
for (index in 9..11) assertNull(it.getObject(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotTables(db: Connection): Map<String, List<List<String?>>> =
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'purchase_failure_snapshot%'").use { rows ->
|
||||
buildList { while (rows.next()) add(rows.getString(1)) }
|
||||
}
|
||||
}.associateWith { tableRows(db, it) }
|
||||
|
||||
private fun tableRows(db: Connection, table: String): List<List<String?>> =
|
||||
db.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT * FROM $table ORDER BY attempt_id").use { rows ->
|
||||
buildList {
|
||||
while (rows.next()) add((1..rows.metaData.columnCount).map { rows.getString(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrate(db: Connection, oldVersion: Int, newVersion: Int) {
|
||||
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateV1ToV2(db: Connection) {
|
||||
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
@@ -166,6 +341,7 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val V5_COLUMNS = setOf("color_vertical_swipe_count", "color_trailing_empty_read_count", "color_horizontal_incomplete")
|
||||
val CREATE_V1_TABLE_SQL =
|
||||
"""CREATE TABLE agent_diagnostic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: bf3ce6efd67fde1fdb6eacd3248f186aea880391
|
||||
synchronized_at: 2026-10-08T10:15:16Z
|
||||
wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
|
||||
synchronized_at: 2026-10-09T07:45:10Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -656,7 +656,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
## Android 重购编排(#367)
|
||||
|
||||
实现绑定 `5430dc1`,工单分支尚未合并/安装/发布;无Server/Web、数据库或接口迁移。
|
||||
实现绑定 `5430dc1`,2026-10-09 已合并 main `e26743c` 并安装集成APK;无Server/Web、数据库或接口迁移,真机批量采购仍待验收。
|
||||
|
||||
- `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。
|
||||
- `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。
|
||||
@@ -664,3 +664,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `AgentForegroundService` 独立单线程scheduled executor每2秒推进编排,历史网络不阻塞心跳或taskExecutor;发现使用独享API客户端,reset只短暂预留既有TaskExecutionMutex。原scheduler继续唯一负责claim/start/真实PDD操作及Outbox,编排不持整轮锁、不新建执行器路径。
|
||||
- 新工作查询同时检查采集next、采购next及本设备活动状态历史,防止采购next优先返回本轮current而遮蔽其他pending。停止信号为原子roundId,配置/身份冻结,仅内存保存摘要;进程重启不恢复整轮,既有单任务恢复及失败私有诊断不变。
|
||||
- `AgentApiClient` 仅补读已有采购payload的attemptNumber和标准HTTP Date响应元数据;不添加服务端字段或API。Date缺失清空样本,不沿用手机时间。
|
||||
|
||||
## Android 颜色发现诊断追加(#370 第一阶段)
|
||||
|
||||
实现绑定 `9e5f09a`,已合并 main,2026-10-09 随集成版本 `e26743c` 安装;本阶段不修改颜色遍历和采购结果判定。
|
||||
|
||||
- `AgentDiagnosticSchema` 升至 v5,在 `agent_diagnostic` 追加可空 INTEGER `color_vertical_swipe_count`、`color_trailing_empty_read_count`、`color_horizontal_incomplete`;对应事件中的两个 Int? 和一个 Boolean?,Boolean 按 0/1 写入。新库包含列,v1–v4 逐级补齐并跳过已存在列,旧行新列为 NULL;不清表,不改独立采购失败快照、队列和 context。
|
||||
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
|
||||
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
|
||||
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: c4870fd01a4569059fc0afc77179d14fbd286384
|
||||
synchronized_at: 2026-10-08T10:15:21Z
|
||||
wiki_revision: f8a5ca861965f5da9bdec2235205f23fb9a00840
|
||||
synchronized_at: 2026-10-09T07:45:19Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -375,6 +375,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
|
||||
## SYB 采购强制当次规格探测(#215)
|
||||
|
||||
以下为 #373 之前的复用行为;#373 分支的限定复用和等待规则见“采购页面一次性交接与导航稳定等待”章节,未安装新版不能按新规则判断。
|
||||
|
||||
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
|
||||
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
|
||||
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
|
||||
@@ -676,7 +678,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
|
||||
## 支付页安全返回后的核单观察(#365)
|
||||
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
|
||||
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,已合并 main 并随2026-10-09集成APK安装;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。
|
||||
|
||||
- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。
|
||||
- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。
|
||||
@@ -862,7 +864,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 图搜采集部分完成的自动关联资格(#368)
|
||||
|
||||
实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。
|
||||
实现基线:`15ccab3`(2026-10-08);2026-10-09 已合并 main `e26743c` 并部署122.228.200.167,真实图搜采集业务效果仍待验收。
|
||||
|
||||
- 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。
|
||||
- `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。
|
||||
@@ -875,7 +877,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## Android 本设备最后一批重购(#367)
|
||||
|
||||
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);未合并 main、未安装或发布,真机批量采购仍待独立授权验收。
|
||||
实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);2026-10-09 已合并 main `e26743c` 并安装集成APK,真机批量采购仍待独立授权验收。
|
||||
|
||||
- 采购页搜索栏的“回填”入口替换为“重购”。原回填弹窗、Service 动作和实现保留但不再由该按钮触发;原单条重试、继续采购不改。重购使用原任务的 `/reset`,保留 CG 编号,不使用创建替代任务的 `/retry`。
|
||||
- 读取本设备近30天全部状态、不继承页面编号/状态筛选;按创建时间倒序、同刻任务ID倒序,从最新记录逐项比较相邻时间,不超过60秒归入同一推算批次。跨50条分页去重,最多200页,结束复核首页;头部或总量变化、日期/页结构异常、查询截断无法证明完整时不启动,不拿部分列表执行。批次时间边界使用独享API客户端的 HTTP Date,不使用手机时钟,缺少有效 Date 拒绝;此轻量方案不保证任意并发删除/归属变动的数据库快照。
|
||||
@@ -885,3 +887,32 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- Service 独立串行后台调度观察,真正领取、执行、上传仍由原执行器和租约/任务锁负责。终态且执行锁、活跃记录和Outbox均收尾后才继续。检测新采集或采购工作停止剩余,本轮当前task本身不算新工作。
|
||||
- 页面和通知栏“停止重购”只停止后续,已提交/执行的任务沿用原流程;切Tab、切PDD、界面重建不中断。单条单调时钟等待上限15分钟,超时只停止编排,不取消任务、不重置或再次下单。进程死亡/服务重建不恢复整轮,原单任务恢复不改。
|
||||
- 本轮摘要与最小attempt关联仅在内存,不新增地址/树采集或数据库。接口、服务端、Web、权限及 #364 诊断契约不变。自动化回归和APK构建通过不代表真机页面/批量下单已验收。
|
||||
|
||||
|
||||
## 采购页面一次性交接与导航稳定等待(#373 v4)
|
||||
|
||||
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
|
||||
|
||||
- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。
|
||||
- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。
|
||||
- 采集、图搜、当前页采集、回填、恢复执行、导航诊断及已知主动离页操作都会使原交接不可用;服务/无障碍重建不恢复。复用仍要求新鲜PDD商品结构,读取后再次核对同一次已消费凭证的期限、实例、前台与代次,避免慢Binder读取期间过期。人工在同一PDD包内切页可能没有可观察前台变化,不能声称完全识别。
|
||||
- 不符合复用资格时打开本任务链接,沿用直达NEW_TASK|CLEAR_TASK及异常回退浏览器。等待从该次打开开始,不是给整个重购批次加固定间隔。执行器取得实际ACCEPTED路径;未知路径不当作直达。
|
||||
- 浏览器路径先观察到受支持浏览器,再允许PDD商品结构进入就绪判断,浏览器出现前的旧PDD帧不放行。直达接受后至少3秒缓冲;浏览器返回PDD后至少3秒缓冲。商品结构还需连续稳定至少1秒、至少3次读取,缓冲与稳定时间可以重叠。就绪后立即继续,不无条件等30秒。
|
||||
- 商品稳定特征只在内存比较标题和规格入口/面板结构,不以销量、价格或滚动拼单变化为依据。非PDD、空窗口、缺少商品证据或结构变化会打断稳定计时;登录、验证码、风控和无效商品保持明确失败。
|
||||
- 导航总预算为单调时钟30秒、最多300轮、轮间100ms;耗时系统读取可能超过预算,但返回后检查时限,不再继续点击。浏览器入口重试从点击调用返回后至少间隔1秒;原生点击前在新鲜节点重新校验浏览器与剩余预算。超时返回PDD_DETAIL_ENTRY_FAILED,不继续规格、地址或创建订单步骤。
|
||||
- 用户明确拒绝新增分享/复制链接操作,并接受本版仅解决跨任务无条件复用与导航抢跑。**稳定商品页不是goodsId身份核验;PDD忽略深链而稳定停在旧页、或无可观察变化的同包人工换页,仍可能无法识别。** 不得将任务携带goodsId当作页面回读,不能用本版宣称绝对排除错误商品。
|
||||
- 不改服务端/API线协议/数据库、采集器、规格选择、地址与下单逻辑、重购批次发现/停止逻辑;不涉及任何付款动作。仅增加Android对既有结果响应status/replayed的本地解析(replayed=false在服务端可省略)。普通流程不新增原始树、截图、分享URL或页面文本落盘。
|
||||
|
||||
## 下单前最终规格复核(#374 v3)
|
||||
|
||||
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(2026-10-09,工单分支 `fix/374-final-spec-confirmation`,基于 main `e26743c`);尚未合并、安装或发布,不含 #373 导航修复,真机采购未验收。
|
||||
|
||||
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
|
||||
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
|
||||
- 复用既有数量输入有界祖先的完整面板形态,但保留真实祖先关系与边界双重归属;滚动列表不等于整个弹层,摘要可位于列表上方/兄弟区域。范围内重新确认面板结构,不将全屏/root 当面板,不用仅坐标重叠的面板外文字。完整范围不可靠时禁用摘要,只使用可独立确认的有界规格滚动区域内选项。
|
||||
- 最终确认专用解析参数默认 false,普通采集及既有调用不变。仅该路径保留同名独立物理选项;沿用原 optionBlock selected/checked 聚合,并合并同值、同矩形、真实祖先链的同一选项,不吞并同名兄弟候选。同一维度多组统一检查。
|
||||
- 只检查下发非空维度,先拒绝同维度明确选中非目标、目标不可用、不同实际候选归一到同一目标或已知候选组合歧义;随后当前面板内规范化完全相等且 selected/checked 的目标可证明该维度。没有选中证明但也没有冲突时才用可信摘要,即使其他未选中候选仍可见也允许摘要补证,不使用历史 attempt 证明。
|
||||
- 摘要仅剥离开头已识别的已选前缀和紧随的一个可选冒号,保留正文标点与内容。无前缀摘要仅沿用已有 quantity/header 结构识别且须属于完整面板。当前双维证据只覆盖“颜色 空格 尺码”,逗号分隔、反序、数量后缀或未知格式不猜测支持;仅一个维度时整段须等于该值,两维均空不作为证明。
|
||||
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
|
||||
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
|
||||
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44
|
||||
synchronized_at: 2026-10-08T03:25:33Z
|
||||
wiki_revision: 6b370764320d1f5148fbc31f0ef6ce999370173d
|
||||
synchronized_at: 2026-10-09T02:06:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -76,7 +76,7 @@ Agent 0.9.13 起,颜色发现阶段在本地 `goauto_diagnostics.db` 的 `agen
|
||||
|
||||
记录只包含颜色行数、各行值数量、可点击颜色数、不可点击候选数、首次颜色点击前的颜色/尺码选中数、是否存在“已选”摘要、横向滑动次数、终止原因和耗时,不保存规格文案、价格、摘要原文、坐标、链接、goods_id、控件树、XML 或截图。
|
||||
|
||||
只对 Debug APK 使用以下读取流程:
|
||||
以下仅为 v4 之前纯结构化诊断库的历史读取方式。v4 及之后包含采购失败私有现场,不再按此导出整库;请使用下方“Android 颜色发现诊断 v5”中的定向只读方式:
|
||||
|
||||
```powershell
|
||||
adb shell run-as cn.ilapage.goauto.agent ls databases
|
||||
@@ -136,3 +136,25 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
|
||||
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
|
||||
- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。
|
||||
- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。
|
||||
|
||||
## Android 颜色发现诊断 v5(#370 第一阶段)
|
||||
|
||||
实现绑定 `9e5f09a`(工单分支),Debug 标识 `0.9.69-370-diag1`,尚未合并 main。本阶段只补诊断和测试夹具,不改变遍历、停止条件、采集完成状态或采购探测决策;不能据此宣称竖向漏采已修复。
|
||||
|
||||
`COLOR_DISCOVERY` 的结束聚合新增三个可空列:
|
||||
|
||||
- `color_vertical_swipe_count`:本次颜色循环中成功派发的 UP 次数,排除归顶及尺码阶段;不证明视口实际移动。
|
||||
- `color_trailing_empty_read_count`:终止前连续解析不到颜色的外层发现轮数;每轮只记一次,点击与采价重读不计,非空轮清零,初始空为 1。
|
||||
- `color_horizontal_incomplete`:1 表示已观察到同一行签名变化后,必要横向探索因失败或预算耗尽仍未完成;0 表示当前已观察行无需横扫或取得连续稳定证据;NULL 表示无法确认,包括重排、空解析、缺少横向容器或仅成功派发但没有移动证据。同 seed 的未知回访不能清除旧的 1,可靠完成的 0 可以清除。0 不代表整个商品已采全,签名变化也不是物理滑动成功的独立证明。
|
||||
|
||||
只保存计数、布尔/NULL 和固定原因码,不保存标签、价格、坐标、原始控件树、截图或个人信息。每行累计数仍不是逐屏轨迹;缺少日志不证明没执行,错误/超时提前退出可能没有结束聚合。现有 50 条/7 天留存不变。
|
||||
|
||||
本地 SQLite v4→v5 只补三列;旧行新值保持 NULL,不删除旧诊断或采购失败快照/队列/context。旧程序不保证能降级打开 v5,不能靠卸载清数据回退。
|
||||
|
||||
`goauto_diagnostics.db` 从 v4 起还含采购失败私有现场,不能按早期“纯结构化库”的假设导出或上传整库。已授权的 Debug 设备有 sqlite3 时可只查询所需聚合字段;无 sqlite3 时只使用已确认的内存读取方式,避免整库落盘,不放宽应用权限。示例查询(须替换设备,且实际存在 sqlite3):
|
||||
|
||||
```powershell
|
||||
adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases/goauto_diagnostics.db "SELECT task_id,reason,color_vertical_swipe_count,color_trailing_empty_read_count,color_horizontal_incomplete FROM agent_diagnostic WHERE stage='COLOR_DISCOVERY' ORDER BY id DESC LIMIT 1;"
|
||||
```
|
||||
|
||||
排查须绑定同一设备、Agent/PDD 版本、任务与规则快照,并对照同次 App 布局。网页竖排与 App 横排不是同一复现场景;当前横排只能做横向兼容性回归。真机测试结果及待验证项以 #370 工单为准。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 6ca0b35cb1e8058b2930a9a01029822af8b571d2
|
||||
synchronized_at: 2026-10-08T07:10:52Z
|
||||
wiki_revision: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
|
||||
synchronized_at: 2026-10-09T07:45:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -312,7 +312,7 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
|
||||
## 采购失败现场诊断部署与保留(#364)
|
||||
|
||||
实现绑定 `4581ee5`;本节为新版本部署要求,不表示线上已迁移/发布。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
|
||||
实现绑定 `4581ee5`;2026-10-09 已随 main `e26743c` 完成线上迁移及发布,见下方发布记录。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。
|
||||
|
||||
1. 先取得目标环境的追加迁移及发布授权,核对唯一待执行版本 `1791400000000_purchase_failure_snapshot.go`,建立独立私有诊断表,再启动配套Server/Web。专用摘要/ZIP下载注册到既有启动权限对账,只允许管理员;对账会维护完整API目录及采购员既有权限,不是只写两条接口。
|
||||
2. Server接收与权限验证就绪后再安装新Agent。旧Server不支持新诊断接口;不要把“本机保存成功”当作“已上传”。仅合并main不启动服务、不执行线上迁移、不自动安装APK。
|
||||
@@ -320,3 +320,48 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。
|
||||
5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。
|
||||
6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。
|
||||
|
||||
|
||||
## 2026-10-08 换机后本地 Supervisor 联调配置(#364 后续运行准备)
|
||||
|
||||
本节是当前电脑的配置,优先于上文历史 D:/supervisor、3307 和 main-runtime 路径;不改变线上拓扑。
|
||||
|
||||
- Supervisor 位于 `D:/supervisord`,主配置 `supervisord.conf` 通过 `[include] files=programs/goauto.conf` 引入两个实例:`goauto-admin-api` 和 `goauto-admin-ui`。两者 `autostart=false`、`autorestart=true`,添加/重读配置不等于批准启动所有服务;只对目标实例操作,不重启整个Supervisor。
|
||||
- 独立运行目录 `D:/OPC/goauto-runtime` 干净地固定在已合并main的 `e450ac28989de6827328b411e4bdc050dded9ab3`,用detached HEAD避免与开发分支混淆。根仓库 `D:/OPC/goauto` 仍保留用户旧分支和未提交资料,不在其中切main或覆盖。
|
||||
- 两实例读取 `D:/OPC/goauto/config.yaml`;本地MySQL为127.0.0.1:3308/goauto,API端口8010,Web端口9527。后端调用运行目录的 `scripts/start-server.ps1 -ConfigPath D:/OPC/goauto/config.yaml -SkipMigration`,前端调用 `scripts/start-web.ps1 -ConfigPath D:/OPC/goauto/config.yaml`。后端仍会校验/确保数据库存在并执行既有启动权限对账;SkipMigration不是只读启动。
|
||||
- 当前电脑默认go为1.24且GOTOOLCHAIN=local,继承的GOSUMDB=off又会阻止自动下载1.26.5;仅后端实例的PowerShell命令把已安装 `C:/Users/Qiu/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.5.windows-amd64/bin` 前置到进程PATH,并设置GOTOOLCHAIN=local。已用该环境构建验证;未更改全局环境、校验策略或项目go.mod。此绝对路径是本机配置,其他电脑须核实已安装工具链,不照抄。
|
||||
- 前端依赖属于运行目录自身,按锁文件安装,不链接其他worktree的node_modules。若旧残留依赖出现ERR_MODULE_NOT_FOUND且普通安装未补齐,先用 `node -e "import('vite')"` 复现,再在本目录按锁文件重新安装;不通过修改业务代码或升级依赖绕过。
|
||||
- 手动启动顺序为API就绪后Web;Web原脚本最多等待API健康60秒,并将/api同源代理到本机8010。浏览器本机入口 `http://127.0.0.1:9527/`,Agent使用电脑实际局域网IP的8010端口,不把127.0.0.1配置给手机。
|
||||
- 日志为 `D:/supervisord/goauto-admin-api.log`、`D:/supervisord/goauto-admin-ui.log`。检查依赖、端口和实际子进程环境;不能只因Supervisor显示Running就宣称HTTP就绪。
|
||||
- 新库预置SYB每小时同步与Yeeke同步status=2(启用);蝦皮规格自动匹配、异常规格AI解析status=1(关闭)。本轮只读检查,未改任务状态;用户手动启动API后既有调度会运行,真实外部访问须结合本地凭据/配置确认,不能把本地环境当作自动隔离的沙箱。
|
||||
- 配置动作未新增迁移、未部署线上、未安装APK;用户随后确认亲自点击了启动。健康/页面验证记录在工单,不将健康检查冒充登录、诊断下载或真机端到端验收。
|
||||
|
||||
|
||||
## 2026-10-09 集成发布与 Android 签名连续性(#364/#365/#366/#367/#368/#370)
|
||||
|
||||
- 集成源码 main `e26743c817275137f7dc8a3659461d552371b9e3`。122.228.200.167 当前发布目录 `/home/goauto/releases/20261009-e26743c-integrated`,旧目录 `/home/goauto/releases/20261007-0138da8-359` 保留供代码回退。用户明确要求线上有采集/采购执行时不得重启;迁移前及切换前相关运行计数均为0,没有取消、重置或发起业务。
|
||||
- 已授权追加迁移 `1791400000000_purchase_failure_snapshot.go`、启动权限对账及发布。私有备份 `/home/goauto/backups/20261009-integrated/database.sql` 目录0700/文件0600;只新增诊断表,回读LONGTEXT/LONGBLOB及attempt唯一索引通过。定时任务启停、Cron与参数摘要前后一致。
|
||||
- Server SHA256 `ae7e479e94db0485661665306d0d0306ca5c3cd4019694991c29b64d113eb637`,运行进程二进制一致;新Web入口SHA256 `242158568fb3ee9c51e2e16574abf0a1b6b5b692ae167ec78bcb957cdf5e7434`。根入口、index、login、syb-products入口、10项JS/CSS、健康与验证码通过。Nginx实际读取用户可读性切换前确认;保留旧hashed资源与config/static/temp/var。没有更改Nginx配置;未认证诊断接口401、现有非管理员凭据403。
|
||||
- 本次自动化验证为Android 274项受影响测试、Server任务/采购/商品/规格/权限/中间件/迁移相关包、Web 6项模拟API页面测试及三端构建。未执行真实采购或付款;管理员真实诊断下载及完整失败现场上传仍待业务验收。#366仅环节②,#370仅第一阶段诊断,不能视为剩余根因修复已完成。
|
||||
- Android集成APK保持 `0.9.69-370-diag1 / versionCode 82`,不能只凭版本号区分旧包;本次最终APK SHA256 `68d7caf9ae7ee5808783056f4f1591266746197b520048fa64c89365723edef7`。2026-10-09已覆盖安装到用户指定手机,安装后文件哈希一致、配置及设备身份前后摘要一致、进程启动存在,没有卸载或清数据。
|
||||
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
|
||||
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
|
||||
|
||||
|
||||
## Android 商品导航诊断(#373,诊断阶段)
|
||||
|
||||
- 分支实现 `2c58d58`,调试包 `0.9.69-373-nav-diag3`;尚未合并 main,不代表旧商品页复用或旧页排除已修复。正式采购打开路径保持原样,新增 `PRODUCT_NAVIGATION` 结构诊断至既有 SQLite `agent_diagnostic`,区分 DIRECT/BROWSER 的 ACCEPTED/REJECTED/EXCEPTION,仅记录异常类名及耗时,沿用任务、attempt、device、phase、rule hash 关联和原50条/7天保留规则,无数据库升级。ACCEPTED只表示startActivity返回,不表示目标商品已加载。
|
||||
- 仅debug包包含 `cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver`,要求发送者具有 `android.permission.DUMP`(ADB shell/system),release清单不含该入口。输入仅数字goodsId及direct/browser,不接受任意URL、脚本或购买动作。必须先获得指定设备覆盖安装及只打开商品页的授权,确认无业务任务运行;保留原签名用install -r,禁止为安装清数据或卸载。
|
||||
- Agent主界面启动前台服务且无障碍就绪后,通过ADB执行 `adb -s <设备地址> shell am broadcast -n cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver --es goodsId <数字商品ID> --es mode direct`;浏览器对照使用 `--es mode browser`。忙碌/服务未就绪时拒绝、不排队,不能用取消或重置业务任务强行取得设备。
|
||||
- 探针占用现有设备互斥锁,预算20秒/最多201轮;耗时Binder读取可能越过观察预算,但其后不再派发导航/点击,不能声称能强制中断系统读取。服务或无障碍实例失效、线程中断、登录/风控/验证码时停止。只打开商品链接,在浏览器中最多尝试3次唯一的精确“打开拼多多APP”/“打开拼多多 App”且节点自身可点击的入口;实际派发前再次校验包、窗口、节点与存活条件,无祖先/坐标/手势兜底。PDD内只读,不调用采购执行器、不进入规格选择/改地址/下单/付款,不创建/重试服务器任务。
|
||||
- 临时探针输出通过 `adb -s <设备地址> logcat -d -s GoAutoNavProbe:I '*:S'` 读取;只包含随机runId、路径/固定结果、相对毫秒、节点计数、窗口id、布尔证据及仅在内存比较所得标题same/different/unknown,不保存标题/哈希/URL/页面原文/原始树/截图,不写业务数据库。别用logcat -t的全局最后几行冒充完整诊断;若缓冲已覆盖须如实记录。禁止导出完整无过滤系统日志。
|
||||
- `legacy_would_accept`模拟现有两次商品结构命中,始终标注identityVerified=false;窗口变化、标题变化、商品结构成立都不是目标goodsId证明。shell触发探针与后台任务的系统启动许可可能不同;人工指定browser只验证浏览器路径,不证明当时自动回退的异常原因。正常/失败对照及真实后台时序仍须以任务绑定诊断和人工确认核验。
|
||||
- 自动化验证应包含debug专用探针单测与 `:app:compileReleaseUnitTestKotlin`;探针测试放 `src/testDebug`,不得在共享 `src/test` 引用debug-only类。构建使用原私有签名,安装后回读包版本和APK哈希,并比较设置/设备身份摘要。
|
||||
|
||||
## #373 v4 修复版构建与验证(2026-10-09)
|
||||
|
||||
- 源码分支 `fix/373-navigation-diagnostics`,实现提交 `dbf9a5f4606529189e57b6c266449070d5001340`。APK版本 `0.9.69-373-fix1 / code82`,与前一导航诊断版不同;未安装修复版、未合并main、未发布线上,不需要Admin迁移或服务重启。
|
||||
- APK SHA256 `171f4b12395e2940ce873b62ea394799f521d1a0997f6aaf7582a123c24795e8`;仍使用用户原签名,证书SHA256 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。后续经安装授权用install -r保留数据,不能为覆盖擅自卸载。相同versionCode不能证明文件相同,应核对完整版本和哈希。
|
||||
- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。
|
||||
- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。
|
||||
- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。
|
||||
|
||||
@@ -2,6 +2,7 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -15,8 +16,8 @@ import (
|
||||
)
|
||||
|
||||
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
|
||||
// writes an association when the task was a complete collection, the frozen
|
||||
// original association is unchanged, and the target product is active. Manual
|
||||
// writes an association when the collection is complete or missing only optional
|
||||
// metadata, the frozen original association is unchanged, and the target product is active. Manual
|
||||
// changes made while the Agent was running win by the CAS predicate.
|
||||
// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。
|
||||
// 只有确实写入时才值得触发后续的规格匹配(#287)。
|
||||
@@ -32,7 +33,7 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
|
||||
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
||||
return autoLinkedImageSearch{}, internalError(err)
|
||||
}
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || !imageSearchResultAllowsAutoLink(task) || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
return autoLinkedImageSearch{}, nil
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(task)
|
||||
@@ -94,6 +95,31 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// #368: Keep completed behavior unchanged. A partial result is eligible only
|
||||
// with explicit, nonempty evidence that every missing field is optional.
|
||||
// Unknown/malformed missing data must not silently widen this whitelist.
|
||||
func imageSearchResultAllowsAutoLink(task models.CollectionTask) bool {
|
||||
if task.Status == models.TaskStatusCompleted {
|
||||
return true
|
||||
}
|
||||
if task.Status != models.TaskStatusCompletedPartial || task.MissingJSON == nil {
|
||||
return false
|
||||
}
|
||||
var missing []string
|
||||
if err := json.Unmarshal([]byte(*task.MissingJSON), &missing); err != nil || len(missing) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, field := range missing {
|
||||
switch field {
|
||||
case "reviewCount", "salesText", "shopName":
|
||||
// Optional descriptive metadata; never colors, sizes or prices.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the
|
||||
// details the image-search task just linked (#287).
|
||||
//
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This fixture leaves the hook uncalled so tests can change the submitted
|
||||
// status, missing fields and association independently of the assertions.
|
||||
func seedPartialImageSearch(t *testing.T, db *gorm.DB, deviceID *uint64) (models.CollectionTask, models.ShopeeProduct) {
|
||||
t.Helper()
|
||||
record := createTask(t, db, deviceID)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "active").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{
|
||||
ShopeeItemID: "test-item", Title: "test", Currency: "TWD",
|
||||
SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"M","source":"import"}]}]`,
|
||||
}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syb := models.SYBProduct{
|
||||
OrderCode: "TEST-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID,
|
||||
ShopeeProductID: &shopee.ID, TargetColor: "黑色", TargetSize: "M",
|
||||
Quantity: 1, UnitPriceCent: 1000, ParseStatus: "success", RawJSON: "{}",
|
||||
}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := ImageSearchSnapshot{
|
||||
ShopeeProductID: shopee.ID, RepresentativeSYBProductID: syb.ID, SYBProductIDs: []uint64{syb.ID},
|
||||
ReferenceCurrency: "TWD", ReferencePriceCent: 1000, MaxPriceRatio: 3, PriceGuardSkipped: true,
|
||||
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
|
||||
}
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&record).Updates(map[string]any{"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": string(raw)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return record, shopee
|
||||
}
|
||||
|
||||
func TestAutoLinkImageSearchPartialMissingWhitelist(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, status, missing string
|
||||
nilMissing, want bool
|
||||
}{
|
||||
{name: "completed_empty", status: models.TaskStatusCompleted, missing: "[]", want: true},
|
||||
{name: "completed_nil", status: models.TaskStatusCompleted, nilMissing: true, want: true},
|
||||
{name: "nil", nilMissing: true},
|
||||
{name: "blank"},
|
||||
{name: "null", missing: "null"},
|
||||
{name: "empty", missing: "[]"},
|
||||
{name: "malformed", missing: `["reviewCount"`},
|
||||
{name: "object", missing: `{ "reviewCount": true }`},
|
||||
{name: "scalar", missing: `"reviewCount"`},
|
||||
{name: "number_entry", missing: `["reviewCount",1]`},
|
||||
{name: "null_entry", missing: `["reviewCount",null]`},
|
||||
{name: "blank_entry", missing: `[""]`},
|
||||
{name: "unknown", missing: `["reviewCount","futureField"]`},
|
||||
{name: "case_mismatch", missing: `["ReviewCount"]`},
|
||||
{name: "whitespace", missing: `[" reviewCount "]`},
|
||||
{name: "color", missing: `["reviewCount","color"]`},
|
||||
{name: "size", missing: `["size"]`},
|
||||
{name: "price", missing: `["price"]`},
|
||||
{name: "color_price", missing: `["reviewCount","price:黑色"]`},
|
||||
{name: "unsupported_dimension", missing: `["unsupportedDimension:款式"]`},
|
||||
{name: "invalid_dimension", missing: `["spec_dimension_invalid:size"]`},
|
||||
{name: "pending", status: models.TaskStatusPending, missing: `["reviewCount"]`},
|
||||
{name: "failed", status: models.TaskStatusFailed, missing: `["reviewCount"]`},
|
||||
{name: "duplicate_optional", missing: `["reviewCount","reviewCount"]`, want: true},
|
||||
}
|
||||
fields := []string{"reviewCount", "salesText", "shopName"}
|
||||
for mask := 1; mask < 8; mask++ {
|
||||
var missing []string
|
||||
for i, field := range fields {
|
||||
if mask&(1<<i) != 0 {
|
||||
missing = append(missing, field)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(missing)
|
||||
cases = append(cases, struct {
|
||||
name, status, missing string
|
||||
nilMissing, want bool
|
||||
}{name: fmt.Sprintf("allowed_%d", mask), missing: string(raw), want: true})
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
record, shopee := seedPartialImageSearch(t, db, nil)
|
||||
status := tc.status
|
||||
if status == "" {
|
||||
status = models.TaskStatusCompletedPartial
|
||||
}
|
||||
var missing *string
|
||||
if !tc.nilMissing {
|
||||
missing = &tc.missing
|
||||
}
|
||||
if err := record.SetStatus(status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&record).Updates(map[string]any{"status": status, "missing_json": missing, "active_slot": record.ActiveSlot, "device_run_slot": record.DeviceRunSlot}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := NewService(db).autoLinkImageSearch(context.Background(), record.ID)
|
||||
if err != nil || linked.Linked != tc.want {
|
||||
t.Fatalf("Linked=%v want=%v error=%v", linked.Linked, tc.want, err)
|
||||
}
|
||||
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shopee.ImageSearchLinked != tc.want || (shopee.PDDProductID != nil) != tc.want {
|
||||
t.Fatalf("association/marker mismatch: %+v", shopee)
|
||||
}
|
||||
if tc.want && (*shopee.PDDProductID != *record.PDDProductID || len(linked.SYBProductIDs) != 1) {
|
||||
t.Fatal("linked target or downstream SYB identifiers lost")
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Status != status || !reflect.DeepEqual(record.MissingJSON, missing) {
|
||||
t.Fatal("eligibility must not rewrite collection status or missing fields")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitImageSearchPartialLinkAndMatch(t *testing.T) {
|
||||
for _, scenario := range []string{"optional_partial", "completed", "critical_missing", "admin_source", "current_page_source", "disabled_pdd", "association_changed", "snapshot_missing", "ai_unavailable", "request_cancelled_during_match"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
requestContext, cancelRequest := context.WithCancel(context.Background())
|
||||
defer cancelRequest()
|
||||
var syncCalls atomic.Int32
|
||||
erp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
syncCalls.Add(1)
|
||||
if scenario == "request_cancelled_during_match" {
|
||||
// Cancel only after the real post-result matching hook has
|
||||
// started. Its detached context must still persist mappings.
|
||||
cancelRequest()
|
||||
}
|
||||
if scenario == "ai_unavailable" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"color":["黑色"],"size":["M"]}}`)
|
||||
}))
|
||||
defer erp.Close()
|
||||
t.Setenv("GOAUTO_ERPGO_BASE_URL", erp.URL)
|
||||
t.Setenv("GOAUTO_ERPGO_APIKEY", "synthetic-test-key")
|
||||
db := openTaskDatabase(t)
|
||||
device, token := registerTaskDevice(t, db, "partial-image-search")
|
||||
record, shopee := seedPartialImageSearch(t, db, &device.ID)
|
||||
service := newTaskService(db)
|
||||
// Execution is outside this test's scope; submit through the real
|
||||
// authenticated running-owner path with a persisted image-search snapshot.
|
||||
if err := db.Model(&record).Update("status", models.TaskStatusRunning).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
title, shop, sales, reviews := "商品", "店铺", "已拼100件", int64(12)
|
||||
request := ResultRequest{
|
||||
RequestID: uuid.NewString(), Status: models.TaskStatusCompletedPartial,
|
||||
Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot, Title: &title, ShopName: &shop, SalesText: &sales},
|
||||
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"M"}}},
|
||||
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1000}},
|
||||
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色", "size": "M"}, PriceCent: 1000, Available: true}},
|
||||
Missing: []string{"reviewCount"},
|
||||
}
|
||||
wantLinked, wantMapped := true, true
|
||||
var unchangedPDD *uint64
|
||||
switch scenario {
|
||||
case "completed":
|
||||
request.Status, request.Missing, request.Product.ReviewCount = models.TaskStatusCompleted, []string{}, &reviews
|
||||
case "critical_missing":
|
||||
request.Missing = append(request.Missing, "size")
|
||||
wantLinked, wantMapped = false, false
|
||||
case "admin_source", "current_page_source":
|
||||
source := models.CollectionTaskSourceAdmin
|
||||
if scenario == "current_page_source" {
|
||||
source = models.CollectionTaskSourceAgentCurrentPage
|
||||
}
|
||||
if err := db.Model(&record).Update("source", source).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "disabled_pdd":
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "disabled").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "association_changed":
|
||||
manual := seedAutoLinkPDD(t, db, "manual-pdd")
|
||||
unchangedPDD = &manual.ID
|
||||
if err := db.Model(&shopee).Update("pdd_product_id", manual.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "snapshot_missing":
|
||||
if err := db.Model(&record).Update("image_search_snapshot", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLinked, wantMapped = false, false
|
||||
case "ai_unavailable":
|
||||
// No exact match and no configured AI provider: the existing
|
||||
// matching path must not invent mappings or undo successful collection.
|
||||
request.Dimensions[0].Values = []string{"白色"}
|
||||
request.Dimensions[1].Values = []string{"L"}
|
||||
request.ColorPrices[0].Color = "白色"
|
||||
request.SKUs[0].Specs = map[string]string{"color": "白色", "size": "L"}
|
||||
wantMapped = false
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
ctx := context.Background()
|
||||
if attempt == 0 {
|
||||
ctx = requestContext
|
||||
}
|
||||
detail, err := service.SubmitResult(ctx, record.ID, request, token)
|
||||
if scenario == "request_cancelled_during_match" && attempt == 0 {
|
||||
// Detail still uses the original request context. Preserve that
|
||||
// existing return behavior, then inspect the committed state
|
||||
// independently; do not pretend the disconnected client got OK.
|
||||
if requestContext.Err() != context.Canceled || err == nil {
|
||||
t.Fatalf("expected cancelled response read, got %v", err)
|
||||
}
|
||||
detail, err = service.Detail(context.Background(), record.ID)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("submit attempt %d: %v", attempt, err)
|
||||
}
|
||||
if detail.Task.Status != request.Status || !reflect.DeepEqual(detail.Missing, request.Missing) || detail.Replayed != (attempt == 1) {
|
||||
t.Fatalf("submission/replay changed result: status=%s missing=%v replayed=%v", detail.Task.Status, detail.Missing, detail.Replayed)
|
||||
}
|
||||
if len(detail.SKUs) != 1 || len(detail.Dimensions) != 2 {
|
||||
t.Fatal("result children missing or duplicated")
|
||||
}
|
||||
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shopee.ImageSearchLinked != wantLinked {
|
||||
t.Fatalf("image search marker=%v", shopee.ImageSearchLinked)
|
||||
}
|
||||
wantPDD := unchangedPDD
|
||||
if wantLinked {
|
||||
wantPDD = record.PDDProductID
|
||||
}
|
||||
if !reflect.DeepEqual(shopee.PDDProductID, wantPDD) {
|
||||
t.Fatal("wrong association or concurrent change overwritten")
|
||||
}
|
||||
wantCalls := int32(0)
|
||||
if wantLinked {
|
||||
wantCalls = 1
|
||||
}
|
||||
if syncCalls.Load() != wantCalls {
|
||||
t.Fatalf("matching hook calls=%d want=%d", syncCalls.Load(), wantCalls)
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("unexpected archive dimensions: %d", len(specs))
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
for _, value := range dimension.Values {
|
||||
if wantMapped {
|
||||
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed || value.Mapping.PDDValue != value.Name {
|
||||
t.Fatalf("matching not persisted: %+v", value)
|
||||
}
|
||||
} else if value.Mapping != nil {
|
||||
t.Fatalf("unexpected mapping: %+v", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if scenario == "optional_partial" {
|
||||
// A second result for the same image carries the same original
|
||||
// association snapshot. The first committed link wins; no fallback
|
||||
// to another result or a second matching pass is introduced.
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := createTask(t, db, &device.ID)
|
||||
if err := db.Model(&second).Updates(map[string]any{
|
||||
"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": record.ImageSearchSnapshot,
|
||||
"status": models.TaskStatusRunning,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.RequestID, request.Product.PDDGoodsID = uuid.NewString(), second.GoodsIDSnapshot
|
||||
if _, err := service.SubmitResult(context.Background(), second.ID, request, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(shopee.PDDProductID, record.PDDProductID) || syncCalls.Load() != 1 {
|
||||
t.Fatal("competing image-search result replaced association or retriggered matching")
|
||||
}
|
||||
}
|
||||
var purchases int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Count(&purchases).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if purchases != 0 {
|
||||
t.Fatal("matching must not create a purchase task")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user