fix(android): keep spec dimension on normalized-name conflict (#379)

规格值规范化后同名但原文不同时,不再整维清空,而是保留一个值并在
missing 写入 spec_dimension_conflict:size|color(任务为 completed_partial);
规范化后为空或仍含货币符号仍整维拒绝(spec_dimension_invalid)。

尺码续页路径与正常路径一致做末尾价格规范化(rawText 保留原标签),
并在按 text 去重之前由 Tracker 观察每个候选,避免冲突被 groupBy 吞掉。

测试:更新 #160 的重复尺码/颜色用例为保留并标记冲突,补充跨帧、
续页、残留货币与空值整维拒绝用例。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-10-12 14:43:12 +08:00
co-authored by Claude Sonnet 5.5
parent 2ae3e8115f
commit e966d0f702
2 changed files with 174 additions and 29 deletions
@@ -1331,6 +1331,9 @@ class PddProductDetailCollector(
prices.clear()
colorImages.clear()
missing += "spec_dimension_invalid:color"
} else if (colorNormalization.conflict) {
// #379: same normalized name from different raw labels keeps one value.
missing += "spec_dimension_conflict:color"
}
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
val sizes = linkedMapOf<String, Boolean>()
@@ -1340,6 +1343,8 @@ class PddProductDetailCollector(
if (sizeNormalization.invalid) {
sizes.clear()
missing += "spec_dimension_invalid:size"
} else if (sizeNormalization.conflict) {
missing += "spec_dimension_conflict:size"
}
if (colors.isEmpty()) missing += "color"
@@ -2041,9 +2046,12 @@ class PddProductDetailCollector(
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation, config)
// #379: observe every candidate before continuation de-duplication,
// otherwise a same-name conflict is swallowed by groupBy.
val continuationCandidates = if (parsedSizes.isNotEmpty()) emptyList() else continuationCandidates(screen, continuation, config)
parsedSizes.ifEmpty { continuationCandidates }.forEach(normalization::observe)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else dedupeContinuation(continuationCandidates)
visible.forEach { value ->
normalization.observe(value)
if (SpecValueNormalizer.isSafeSize(value.text)) {
sizes[value.text] = sizes[value.text] == true || value.available
}
@@ -2088,37 +2096,32 @@ class PddProductDetailCollector(
return null
}
private class SizeNormalizationTracker {
/**
* #160/#379: an empty or currency-bearing normalized value rejects the whole
* dimension ([invalid]); a normalized name backed by several raw labels only
* marks a [conflict] and the dimension keeps a single value for that name.
*/
private open class SpecNormalizationTracker(private val isSafe: (String) -> Boolean) {
private val sources = linkedMapOf<String, MutableSet<String>>()
var invalid: Boolean = false
private set
var conflict: Boolean = false
private set
fun observe(value: VisibleSpecValue) {
if (!SpecValueNormalizer.isSafeSize(value.text)) {
if (!isSafe(value.text)) {
invalid = true
return
}
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
rawValues += value.rawText
if (rawValues.size > 1) invalid = true
if (rawValues.size > 1) conflict = true
}
}
private class ColorNormalizationTracker {
private val sources = linkedMapOf<String, MutableSet<String>>()
var invalid: Boolean = false
private set
private class SizeNormalizationTracker : SpecNormalizationTracker(SpecValueNormalizer::isSafeSize)
fun observe(value: VisibleSpecValue) {
if (!SpecValueNormalizer.isSafeColor(value.text)) {
invalid = true
return
}
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
rawValues += value.rawText
if (rawValues.size > 1) invalid = true
}
}
private class ColorNormalizationTracker : SpecNormalizationTracker(SpecValueNormalizer::isSafeColor)
private fun recordSizeDiscovery(
reason: AgentDiagnosticReason,
@@ -2198,7 +2201,8 @@ class PddProductDetailCollector(
)
}
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
/** Normalized continuation candidates before any de-duplication; rawText keeps the original label. */
private fun continuationCandidates(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
if (context == null) return emptyList()
val headingLabels = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }.toSet()
@@ -2213,13 +2217,21 @@ class PddProductDetailCollector(
.filterNot { it.label in context.colorLabels || it.label.replace(" ", "") in headingLabels }
.filterNot { PddScreenParser.isExcludedOptionLabel(it.label) || priceOnly.matches(it.label.replace(" ", "")) }
.map { node ->
VisibleSpecValue(node.label, node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains), node)
VisibleSpecValue(
SpecValueNormalizer.normalizeSize(node.label),
node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains),
node,
rawText = node.label,
)
}
.groupBy(VisibleSpecValue::text)
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
.toList()
}
private fun dedupeContinuation(candidates: List<VisibleSpecValue>): List<VisibleSpecValue> = candidates
.groupBy(VisibleSpecValue::text)
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
private fun stablePrice(
goodsId: String,
color: String,
@@ -1106,10 +1106,50 @@ class PddProductDetailCollectorTest {
}
@Test
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
fun `same-frame sizes with one normalized name keep a single value and mark conflict`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL ¥10", "XL ¥12")),
sizePages = listOf(listOf("XL ¥10", "XL ¥12", "S")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed_partial", payload.status)
assertEquals(listOf("XL", "S"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
assertFalse(payload.missing.contains("size"))
assertEquals(listOf("XL", "S"), payload.skus.map { it.specs.getValue("size") })
}
@Test
fun `size price appearing between passes keeps the dimension and marks conflict once`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed_partial", payload.status)
assertEquals(listOf("XL", "S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `residual currency in a size still rejects the whole size dimension`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL ¥10 热卖", "S")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
@@ -1121,9 +1161,79 @@ class PddProductDetailCollectorTest {
assertEquals("completed_partial", payload.status)
assertTrue(payload.dimensions.none { it.key == "size" })
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
assertFalse(payload.missing.contains("spec_dimension_conflict:size"))
assertTrue(payload.skus.all { "size" !in it.specs })
}
@Test
fun `an empty normalized size still rejects the whole size dimension`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("¥10", "S")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
val payload = requireNotNull(
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
)
assertTrue(payload.dimensions.none { it.key == "size" })
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `continuation sizes drop the trailing price like the parsed path`() {
val driver = FakeCollectorDriver(
colors = listOf("红色"),
sizePages = listOf(listOf("S", "M"), listOf("L ¥10", "XL")),
hideDimensionHeadingsAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("S", "M", "L", "XL"), payload.dimensions.first { it.key == "size" }.values)
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
}
@Test
fun `continuation conflict is seen before de-duplication and keeps one value`() {
val driver = FakeCollectorDriver(
colors = listOf("红色"),
sizePages = listOf(listOf("S", "M"), listOf("XL ¥10", "XL", "L")),
hideDimensionHeadingsAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("S", "M", "XL", "L"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `continuation size with residual currency rejects the whole size dimension`() {
val driver = FakeCollectorDriver(
colors = listOf("红色"),
sizePages = listOf(listOf("S", "M"), listOf("L ¥10 热卖", "XL")),
hideDimensionHeadingsAfterFirstVerticalPage = true,
)
var clock = 0L
val payload = requireNotNull(
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
)
assertTrue(payload.dimensions.none { it.key == "size" })
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `trailing color prices are normalized across dimension price and sku`() {
val driver = FakeCollectorDriver(
@@ -1144,10 +1254,32 @@ class PddProductDetailCollectorTest {
}
@Test
fun `duplicate colors after price cleanup reject color dependents`() {
fun `duplicate colors after price cleanup keep one value and mark conflict`() {
val driver = FakeCollectorDriver(
colors = listOf("红色 ¥10", "红色 ¥12"),
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L),
colors = listOf("红色 ¥10", "红色 ¥12", "蓝色"),
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L, "蓝色" to 1500L),
imageColorCards = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed_partial", payload.status)
assertEquals(listOf("红色", "蓝色"), payload.dimensions.first { it.key == "color" }.values)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(1000L, payload.colorPrices.first { it.color == "红色" }.priceCent)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:color" })
assertFalse(payload.missing.contains("spec_dimension_invalid:color"))
assertEquals(listOf("红色 ¥10", "蓝色"), driver.clickedLabels)
}
@Test
fun `residual currency in a color still rejects color dependents`() {
val driver = FakeCollectorDriver(
colors = listOf("红色 ¥10 热卖", "蓝色"),
prices = mapOf("红色 ¥10 热卖" to 1000L, "蓝色" to 1500L),
imageColorCards = true,
)
var clock = 0L
@@ -1161,6 +1293,7 @@ class PddProductDetailCollectorTest {
assertTrue(payload.colorPrices.isEmpty())
assertTrue(payload.skus.isEmpty())
assertTrue(payload.missing.contains("spec_dimension_invalid:color"))
assertFalse(payload.missing.contains("spec_dimension_conflict:color"))
assertTrue(result.colorImages.isEmpty())
}