Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6781b309d4 | ||
|
|
eb7cb6b5eb | ||
|
|
845853c68a | ||
|
|
955869b5f5 | ||
|
|
49de6f8045 | ||
|
|
279cb55847 | ||
|
|
35119ea261 | ||
|
|
cb00194b0f | ||
|
|
46b92dd6c5 | ||
|
|
1f5bab8192 | ||
|
|
9a4495addd |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 81
|
||||
versionName = "0.9.68"
|
||||
versionCode = 82
|
||||
versionName = "0.9.69"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+57
-3
@@ -376,6 +376,7 @@ object PddScreenParser {
|
||||
val candidates = panelVisible.asSequence()
|
||||
.filter { it.clickable && inValueRegion(it) }
|
||||
.filterNot { isExactHeadingLabel(it.label, config) }
|
||||
.filterNot { containsDistinctOptionCard(it, sourceByPath) }
|
||||
.filterNot { node ->
|
||||
isExcludedOptionLabel(node.label) ||
|
||||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
||||
@@ -586,8 +587,8 @@ object PddScreenParser {
|
||||
|
||||
/**
|
||||
* Outermost labelled clickable ancestor inside the value region whose label
|
||||
* prefixes every candidate nested in it. Rows that contain several distinct
|
||||
* options never qualify because their members do not share its label.
|
||||
* prefixes every candidate nested in it, or a single image/caption card with
|
||||
* a truncated caption. A shared ancestor alone is not card identity.
|
||||
*/
|
||||
private fun optionBlock(
|
||||
candidate: SnapshotNode,
|
||||
@@ -605,9 +606,11 @@ object PddScreenParser {
|
||||
// Only a block with its own label names the option; a blank row
|
||||
// container resolved from its first child never merges options.
|
||||
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
|
||||
if (containsDistinctOptionCard(resolved, sourceByPath)) break
|
||||
val outer = compact(resolved.label)
|
||||
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break
|
||||
val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) }
|
||||
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
@@ -615,6 +618,57 @@ object PddScreenParser {
|
||||
return block
|
||||
}
|
||||
|
||||
/** A labelled group must neither name its children nor become a synthetic selected option. */
|
||||
private fun containsDistinctOptionCard(block: SnapshotNode, sourceByPath: Map<String, SnapshotNode>): Boolean {
|
||||
val raw = sourceByPath[block.path] ?: return false
|
||||
if (raw.label.isBlank()) return false
|
||||
val name = SpecValueNormalizer.normalizeColor(raw.label).filterNot { it.isWhitespace() }
|
||||
return sourceByPath.values.any { child ->
|
||||
child.path.startsWith("${block.path}/") && child.clickable && child.label.isNotBlank() &&
|
||||
SpecValueNormalizer.normalizeColor(child.label).filterNot { it.isWhitespace() } != name &&
|
||||
sourceByPath.values.any { it.parentPath == child.path }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #362: narrowly recognize one full-labelled image above one truncated text
|
||||
* leaf. Do not reverse the old prefix rule for arbitrary nested options.
|
||||
* Non-clickable layout wrappers are fine; another clickable container is
|
||||
* ambiguous, even if its label/price also happens to be a prefix match.
|
||||
*/
|
||||
private fun isTruncatedDisplayCard(
|
||||
block: SnapshotNode,
|
||||
members: List<SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
): Boolean {
|
||||
fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() }
|
||||
val full = name(block.label)
|
||||
if (full.isEmpty()) return false
|
||||
val children = members.filter { it.path != block.path }
|
||||
val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false
|
||||
val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false
|
||||
if (children.size != 2 || name(image.label) != full) return false
|
||||
val short = name(caption.label)
|
||||
if (short.isEmpty() || short == full || !full.startsWith(short)) return false
|
||||
|
||||
val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") }
|
||||
// Image and caption must be leaves, not two independently labelled cards.
|
||||
if (children.any { child -> descendants.any { it.parentPath == child.path } }) return false
|
||||
if (descendants.any { node ->
|
||||
node.clickable && node.label.isNotBlank() &&
|
||||
descendants.any { it.parentPath == node.path }
|
||||
}) return false
|
||||
if (children.any { child ->
|
||||
child.bounds.left < block.bounds.left || child.bounds.right > block.bounds.right ||
|
||||
child.bounds.top < block.bounds.top || child.bounds.bottom > block.bounds.bottom
|
||||
}) return false
|
||||
if (image.bounds.top >= caption.bounds.top || image.bounds.bottom >= caption.bounds.bottom ||
|
||||
minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false
|
||||
|
||||
val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) }
|
||||
return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
|
||||
}
|
||||
|
||||
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
|
||||
var current: SnapshotNode? = node
|
||||
|
||||
@@ -13,6 +13,10 @@ object SpecValueNormalizer {
|
||||
|
||||
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
|
||||
|
||||
/** 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()
|
||||
|
||||
fun isSafeColor(value: String): Boolean = isSafeValue(value)
|
||||
|
||||
fun isSafeSize(value: String): Boolean = isSafeValue(value)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Synthetic cards only: no device tree, address or order data. */
|
||||
class TruncatedSpecCardTest {
|
||||
private val card = "r/sheet/body/list/color/o0"
|
||||
private val other = "r/sheet/body/list/color/o1"
|
||||
private val full = "黑色示例长裤【有抽绳】 有口袋不起球"
|
||||
private val short = "黑色示例长裤【有抽绳】 有口袋"
|
||||
private val size = "2XL建议130-150斤"
|
||||
|
||||
private fun sheet(
|
||||
outer: String = "$full ¥18.8",
|
||||
caption: String = "$short ¥18.8",
|
||||
image: String = outer,
|
||||
selected: Boolean = true,
|
||||
otherSelected: Boolean = false,
|
||||
): UiSnapshot = SpecPanelFixtures.taskOptionDedupSheet().let { snapshot ->
|
||||
snapshot.copy(nodes = snapshot.nodes.map { node ->
|
||||
when (node.path) {
|
||||
card, "$card/img" -> node.copy(text = null, contentDescription = if (node.path == card) outer else image, selected = selected)
|
||||
"$card/l/t" -> node.copy(text = caption, selected = selected)
|
||||
"r/sheet/body/selected" -> node.copy(text = "请选择: 尺码")
|
||||
else -> when {
|
||||
node.path.startsWith("$card/") -> node.copy(selected = selected)
|
||||
node.path == other || node.path.startsWith("$other/") -> node.copy(selected = otherSelected)
|
||||
else -> node
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun parse(snapshot: UiSnapshot) = PddScreenParser.parse(
|
||||
snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "123456789", null,
|
||||
)
|
||||
|
||||
private fun colors(snapshot: UiSnapshot) = parse(snapshot).dimensions.single { it.key == "color" }.values
|
||||
|
||||
@Test fun `full image and truncated clickable caption are one selected color`() {
|
||||
val result = colors(sheet())
|
||||
assertEquals(listOf(full, "白条纹"), result.map { it.text })
|
||||
assertTrue(result.first().node.selected)
|
||||
assertFalse(result.last().node.selected)
|
||||
assertEquals(5, parse(sheet()).dimensions.single { it.key == "size" }.values.size)
|
||||
}
|
||||
|
||||
@Test fun `checked display state survives grouping`() {
|
||||
val snapshot = sheet(selected = false).let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l/t") n.copy(checked = true) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).single { it.text == full }.node.checked)
|
||||
assertFalse(colors(snapshot).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `whitespace and equivalent terminal prices do not change full identity`() {
|
||||
assertEquals(listOf(full, "白条纹"), colors(sheet(caption = "黑色示例长裤【有抽绳】\n有口袋 ¥18.80")).map { it.text })
|
||||
}
|
||||
|
||||
@Test fun `one or both absent prices still require card evidence`() {
|
||||
for ((outer, caption) in listOf(full to short, "$full ¥18.8" to short, full to "$short ¥18.8")) {
|
||||
assertEquals(listOf(full, "白条纹"), colors(sheet(outer, caption)).map { it.text })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `different prices refuse truncated merge`() {
|
||||
assertTrue(colors(sheet(caption = "$short ¥28.8")).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `non prefix caption remains independent`() {
|
||||
assertTrue(colors(sheet(caption = "灰色示例 ¥18.8")).any { it.text == "灰色示例" })
|
||||
}
|
||||
|
||||
@Test fun `empty price only and missing complete image are not prefix evidence`() {
|
||||
for (caption in listOf("", "¥18.8")) {
|
||||
val snapshot = sheet(caption = caption, selected = false).let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l/t") n.copy(checked = true) else n
|
||||
}) }
|
||||
// An empty label may be omitted by the parser; a pure-price value
|
||||
// must not be promoted into the full card via empty-prefix matching.
|
||||
if (caption.isNotEmpty()) assertTrue(colors(snapshot).any { it.text.isEmpty() && it.node.checked })
|
||||
}
|
||||
assertTrue(colors(sheet(image = "$short ¥18.8")).any { it.text == short })
|
||||
assertTrue(colors(sheet(outer = "¥18.8")).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `truncated leaf outside card geometry is not merged`() {
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l/t") n.copy(bounds = NodeBounds(710, 1340, 1040, 1447)) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `clickable caption wrapper is not silently assumed to be a display leaf`() {
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l") n.copy(text = "$short ¥18.8", clickable = true) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).any { it.text == short })
|
||||
}
|
||||
|
||||
@Test fun `adjacent prefix named cards stay distinct even at same price`() {
|
||||
val snapshot = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8").let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == other || n.path == "$other/img" || n.path == "$other/l/t")
|
||||
n.copy(text = "黑色 ¥18.8", contentDescription = null) else n
|
||||
}) }
|
||||
assertEquals(listOf("黑色加绒", "黑色"), colors(snapshot).map { it.text })
|
||||
}
|
||||
|
||||
@Test fun `labelled clickable common parent cannot swallow independent cards`() {
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "r/sheet/body/list/color") n.copy(text = "$full ¥18.8", clickable = true,
|
||||
bounds = NodeBounds(0, 1044, 1080, 1447)) else n
|
||||
}) }
|
||||
assertTrue(colors(snapshot).any { it.text == "白条纹" })
|
||||
}
|
||||
|
||||
@Test fun `nested same price prefix card with own image and caption remains independent`() {
|
||||
val base = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8")
|
||||
val parent = base.nodes.single { it.path == card }
|
||||
val nested = listOf(
|
||||
parent.copy(path = "$card/nested", parentPath = card, text = "黑色 ¥18.8", contentDescription = null),
|
||||
parent.copy(path = "$card/nested/img", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.ImageView"),
|
||||
parent.copy(path = "$card/nested/text", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.TextView"),
|
||||
)
|
||||
assertTrue(colors(base.copy(nodes = base.nodes + nested)).any { it.text == "黑色" })
|
||||
}
|
||||
|
||||
@Test fun `short labelled common parent does not merge its longer independent cards`() {
|
||||
val parent = "r/sheet/body/list/color/cards"
|
||||
val base = sheet(outer = "黑色加绒", caption = "黑色加绒")
|
||||
val nodes = base.nodes.map { n ->
|
||||
val isOther = n.path == other || n.path == "$other/img" || n.path == "$other/l/t"
|
||||
val child = if (isOther) n.copy(text = "黑色", contentDescription = null) else n
|
||||
child.copy(
|
||||
path = child.path.replace(card, "$parent/o0").replace(other, "$parent/o1"),
|
||||
parentPath = child.parentPath?.replace(card, "$parent/o0")?.replace(other, "$parent/o1")
|
||||
?.let { if ((n.path == card || n.path == other)) parent else it },
|
||||
)
|
||||
}
|
||||
val container = base.nodes.single { it.path == card }.copy(
|
||||
path = parent, parentPath = parent.substringBeforeLast('/'), text = "黑色", contentDescription = null,
|
||||
bounds = NodeBounds(36, 1044, 698, 1447),
|
||||
)
|
||||
val values = colors(base.copy(nodes = nodes + container))
|
||||
assertEquals(setOf("黑色加绒", "黑色"), values.map { it.text }.toSet())
|
||||
assertTrue(values.single { it.text == "黑色加绒" }.node.selected)
|
||||
assertFalse(values.single { it.text == "黑色" }.node.selected)
|
||||
}
|
||||
|
||||
@Test fun `same named clickable caption wrapper keeps original badge merging`() {
|
||||
val snapshot = SpecPanelFixtures.taskOptionDedupSheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path == "$card/l") n.copy(text = "兰条纹", clickable = true) else n
|
||||
}) }
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors(snapshot).map { it.text })
|
||||
}
|
||||
|
||||
@Test fun `numeric and weight size values and badge regression remain intact`() {
|
||||
val baseline = SpecPanelFixtures.taskOptionDedupSheet()
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors(baseline).map { it.text })
|
||||
val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n ->
|
||||
if (n.path.endsWith("size/o0/t")) n.copy(text = "42 ¥18.8") else n
|
||||
}) }
|
||||
val sizes = parse(snapshot).dimensions.single { it.key == "size" }.values.map { it.text }
|
||||
assertTrue(sizes.containsAll(listOf("42", size, "3XL建议150-170斤")))
|
||||
}
|
||||
|
||||
@Test fun `collector used by collection and spec probe emits only full color values`() {
|
||||
var selectedColor = full
|
||||
val driver = object : PddCollectorDriver {
|
||||
override fun capture() = sheet(selected = selectedColor == full, otherSelected = selectedColor == "白条纹")
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
selectedColor = if (target.path.startsWith(card)) full else "白条纹"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back() = false
|
||||
}
|
||||
var clock = 0L
|
||||
val rule = CollectionRule(
|
||||
schemaVersion = 2, steps = emptyList(), ruleType = "pddProductDetail",
|
||||
pageEvidence = PageEvidence(SpecPanelFixtures.PDD, SpecPanelFixtures.ACTIVITY, NodeSelector(text = "颜色分类")),
|
||||
collector = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR,
|
||||
)
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect("123456789", rule)
|
||||
assertTrue(result.message, result.successful)
|
||||
assertEquals(listOf(full, "白条纹"), requireNotNull(result.payload).dimensions.single { it.key == "color" }.values)
|
||||
}
|
||||
|
||||
private fun executor(driver: PurchaseUiDriver) = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
|
||||
private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
|
||||
var opened = false
|
||||
var sizeSelected = false
|
||||
val clicks = mutableListOf<String>()
|
||||
override fun capture() = if (opened) snapshot.copy(nodes = snapshot.nodes.map { n ->
|
||||
if (n.path.contains("/size/o")) n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2")) else n
|
||||
}) else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks += target.label
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
if (target.label == "2XL建议130-150斤") sizeSelected = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase() = false
|
||||
}
|
||||
|
||||
private fun run(snapshot: UiSnapshot, target: String = full, driver: Driver = Driver(snapshot)) = executor(driver).execute(
|
||||
PurchaseExecutionInput(362, "rehearsal", "purchase", "https://mobile.yangkeduo.com/goods.html?goods_id=123456789",
|
||||
"123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000),
|
||||
PurchaseRuleParser.parse("""{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}"""),
|
||||
PurchaseAgentCapabilities.supported,
|
||||
)
|
||||
|
||||
@Test fun `dedup lets existing confirmation complete color size and rehearsal`() {
|
||||
val snapshot = sheet()
|
||||
val driver = Driver(snapshot)
|
||||
val result = run(snapshot, driver = driver)
|
||||
assertEquals(result.message, "rehearsal_completed", result.resultType)
|
||||
assertEquals(1, driver.clicks.count { it == size })
|
||||
assertFalse(driver.clicks.any { it.contains("订单") || it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test fun `other selected card still fails immediate confirmation`() {
|
||||
for (selected in listOf(false, true)) {
|
||||
val result = run(sheet(selected = selected, otherSelected = true))
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", result.errorCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `old truncated mapped target is not silently upgraded`() {
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", run(sheet(), short).errorCode)
|
||||
}
|
||||
|
||||
@Test fun `existing final confirmation target first ordering is documented not changed`() {
|
||||
val screen = parse(sheet(otherSelected = true))
|
||||
val executor = executor(Driver(sheet()))
|
||||
val immediate = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "isExactSpecSelected" }
|
||||
immediate.isAccessible = true
|
||||
assertEquals(false, immediate.invoke(executor, screen, "color", full))
|
||||
val final = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "verifyExactSpecSelection" }
|
||||
final.isAccessible = true
|
||||
val result = final.invoke(executor, screen, "color", full, null)
|
||||
val confirmed = result.javaClass.getDeclaredField("confirmed").apply { isAccessible = true }
|
||||
assertEquals(true, confirmed.get(result)) // Existing baseline; NOT a newly approved multi-selection behavior.
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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: 3165d7d419a46f41fa70a63286799deda9a8ce9f
|
||||
synchronized_at: 2026-10-06T09:17:36Z
|
||||
synchronized_at: 2026-10-06T09:50:17Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
|
||||
@@ -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: ec20bf476551c3dbc1d5aaa6301605bfe695e102
|
||||
synchronized_at: 2026-10-06T09:17:39Z
|
||||
wiki_revision: d9b2a343e790c7d90354d47e1a977f134a72a760
|
||||
synchronized_at: 2026-10-07T03:28:45Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -339,6 +339,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
|
||||
## SYB 档口入库码导入、匹配与回写
|
||||
|
||||
- 页面分页(#363,实现绑定 `955869b`,2026-10-07 已合并 main `eb7cb6b` 并发布至 122.228.200.167):仅档口入库码页首次进入或刷新默认第 1 页、每页 200 条,保留 20/50/100/200 条选项;搜索和清空筛选保持当前选定条数。其他页面、公共分页组件及后端未传 pageSize 时默认 100 的行为不变;当前页全选仍仅限当前页,匹配、删除和回写的资格与确认规则不变。
|
||||
|
||||
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
|
||||
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
|
||||
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
|
||||
@@ -810,3 +812,15 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
- PDD 点击按最终接收动作的控件或最近可点击父容器检查尺码建议类明确短语,包含简繁体;无障碍直接点击、祖先点击与中心手势共享判断。不可因面板其他位置存在建议入口而拒绝所有规格;“M 建议80-100斤”等合法选项不按“建议”泛化拦截。命中返回 SIZE_ADVICE_CLICK_BLOCKED,规格入口/规格选择不再手势兜底,颜色采集不改点其他候选。
|
||||
- 规格探测的结构化诊断复用本地诊断库,以 purchase 类型、task ID、服务端 attempt UUID、设备、phase 和规则快照哈希关联;不保存原始 trace/标签/树/截图。诊断失败不改变任务结果。
|
||||
- 真机验证不能依赖探测失败作为停止边界:live 任务探测匹配成功后可继续正式下单。必须明确演练覆盖 spec_probe 的方式,或另获正式创建订单授权;永久禁止付款。
|
||||
|
||||
## Android 同一卡片截短规格显示去重(#362)
|
||||
|
||||
解析修复绑定 9a4495a + 1f5bab8(基于 main 64f0e49);用户随后授权构建安装,工单分支于 35119ea 集成 #361,Agent 0.9.69 / 82 已于 2026-10-06 覆盖安装到指定设备。尚未合并 main,现场采购效果未验证;集成不表示 #361 的诊断疑点已解决或整体验收通过。
|
||||
|
||||
- 共享 PddScreenParser.optionBlock 保留原“成员以外层完整标签开头”的分支(含徽标/附加说明)。只新增已知图片卡片形态的保守兼容:外层有自身完整标签,候选显示成员恰为一个完整标签 ImageView 叶节点和一个截短 TextView 叶节点;允许非点击布局包装,不跨另一个带标签且可点击的容器。
|
||||
- 若带自身标签的候选容器包含另一个有不同规范化名称、可点击且有自身子节点的卡片,该容器不作为规格值,也不能成为这些卡片的合并块。这一结构边界同时保护原前缀分支,避免共同容器“黑色”吞并子卡片“黑色加绒”,以及容器聚合 selected 污染另一张卡片;同名包装和原徽标分支保留。
|
||||
- 两个显示叶节点必须位于卡片边界内,图片在字幕上方且横向相交(允许图片/字幕边缘重叠)。缺少完整图片、多个字幕或图片、嵌套独立卡片、文字归属不明确时不采用新增合并分支。相邻同价且前缀相似的不同规格不能凭文字合并。
|
||||
- 比较使用既有 SpecValueNormalizer 尾部价格语法和空白压缩,双方非空,截短名须为完整名的严格前缀;所有实际存在的尾价须数值一致,18.8 与 18.80 等价。缺失价格不构成同卡证据,必须仍满足全部结构条件。不去掉体重、尺码数字或款式文字。
|
||||
- 合并后仍使用外层完整原文按既有规则去尾价作为规格值,沿用原点击节点排序、selected/checked 聚合;普通采集与采购探测共用这一路径。精确定位、即时确认、选中证明、最终复核及下单行为不改,不允许模糊点击。
|
||||
- 历史任务若已映射到截短值,不自动升格为完整值,不修改任务快照、映射或历史数据;找不到精确目标仍明确失败。即时确认先查其他选中值,而最终确认先接受唯一目标已选中,是既有实现差异,本修复不调整或掩盖该差异。
|
||||
- #361 的诊断未写入疑点继续独立核查。解析合成测试可先行,后续用于手机安装的集成版本须包含 #361,并经明确授权安装/真机验证;不以已有单次采购成功代替完整验收,不执行付款。
|
||||
|
||||
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e
|
||||
synchronized_at: 2026-10-06T09:17:51Z
|
||||
synchronized_at: 2026-10-06T09:50:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -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: 8be38ac192b9e1930fdc3d998c23027fda17e8f3
|
||||
synchronized_at: 2026-10-06T03:27:28Z
|
||||
wiki_revision: 21e8ed0578212b84a77af537e13be6d10c8b5463
|
||||
synchronized_at: 2026-10-07T03:28:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -276,3 +276,11 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 服务端 SHA256:`a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`;远端校验一致。
|
||||
- 发布验收:公网 `/`、`/index.html`、`/syb-products/index` 与发布 dist/index.html 字节相同,10 项入口 JS/CSS 和 `/api/v1/health` 均正常;日志无 panic/fatal/缺表缺字段错误。合并后的 #358 合成解析/导入/重解析及 task/purchase 规格键定向回归通过。未将健康验证视为历史数据已纠正。
|
||||
- 回滚目标 `/home/goauto/releases/20261006-0a79c83-356` 保留,需按执行中任务检查后切回并重启;不通过恢复数据库覆盖后续业务数据。
|
||||
## #363 档口入库码默认 200 条的 Web 发布(2026-10-07)
|
||||
|
||||
- 用户授权合并 main 和线上发布;main `eb7cb6b5eb9a57041429c3dfb1d88dc8236d32f3` 已推送,发布目录 `/home/goauto/releases/20261007-eb7cb6b-363`。仅本页初始化 pageSize 改为 200,选项仍为20/50/100/200;后端缺省100不变,没有迁移、权限或业务数据修改。
|
||||
- 对比上一线上源码 `e76de6f`,Server 无差异,复用原二进制并逐字节验证;Web 从合并提交重建。沿用原 config、环境、static/temp/var 的真实目录;新 dist 保留上版不同哈希 JS/CSS,避免已打开页面的延迟加载失效。
|
||||
- 发布检查发现有采购任务执行中,未停止或重置任务。核验 Nginx root 及运行进程 GOAUTO_WEB_DIST 均指向 `/home/goauto/current/dist`,代码在请求时读取文件且没有提前解析软链接。因此本次只原子切换 current,不重启 GoAuto/Nginx;进程 PID 43738 前后一致且 active。此方式仅适用于后端二进制、配置与持久资源路径均不变的纯 Web 发布,不能推广为后端发布免重启。
|
||||
- 公网首页、index.html、login、档口入库码路由均与新 dist/index.html 字节一致;10 项入口 JS/CSS 与健康接口正常,日志检查无 panic/fatal/1146/1054。真实 Chrome 已认证只读检查:首次默认 page=1/pageSize=200、当天空态与200条/页正常;清除日期后200行渲染通过,查询至渲染约987ms。未发起匹配、删除或回写,未保存原始生产数据或截图。
|
||||
- Web包 SHA256 `a6b4429280fae37e46e81bd21a594941cfee3b5e8c48c105a09254314af32311`;Server SHA256 `a4cf6c7028a2bbbfd86a1d657ad9c3daad7b4d08445405d8ca9a778adc584760`(未变)。
|
||||
- 回滚目录 `/home/goauto/releases/20261006-e76de6f-358` 保留。对本次纯 Web 变更可将 current 原子切回,不需要恢复数据库、删除文件或中断任务;若此后已升级后端,不能复用这一免重启结论。
|
||||
|
||||
@@ -68,7 +68,7 @@ import { createRequestId } from '@/utils/request-id'
|
||||
export default {
|
||||
name: 'GoAutoSybInnerCodes',
|
||||
setup() { return { FolderOpened, RefreshLeft, Search, Upload } },
|
||||
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 200, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} },
|
||||
computed: {
|
||||
matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) },
|
||||
matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) },
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('page-local 200 default, pagination, filters, selection and other-page isolation', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }])
|
||||
const lists: URL[] = []; const shopLists: URL[] = []; const previews: number[][] = []; const writes: string[] = []
|
||||
const rows = Array.from({ length: 205 }, (_, i) => ({ id: i + 1, businessDate: '2026-10-07', orderNumber: `MOCK-${i + 1}`, status: 'ready', specRaw: '模拟规格', items: [{ ordinal: 1, code: `CODE-${i + 1}` }] }))
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request(); const url = new URL(request.url()); const path = url.pathname
|
||||
if (path.startsWith('/src/api/')) return route.continue()
|
||||
const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } })
|
||||
if (path.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] })
|
||||
if (path.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [
|
||||
{ path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' },
|
||||
{ path: '/syb-shops', component: '/goauto/syb-shops/index', menuName: 'GoAutoSybShops', title: '店铺管理', visible: '0' }
|
||||
] }])
|
||||
if (path.endsWith('/syb-inner-codes') && request.method() === 'GET') {
|
||||
lists.push(url)
|
||||
const pageNo = Number(url.searchParams.get('page')); const size = Number(url.searchParams.get('pageSize'))
|
||||
const filtered = url.searchParams.get('keyword') === 'empty' ? [] : rows
|
||||
return reply({ items: filtered.slice((pageNo - 1) * size, pageNo * size), total: filtered.length, page: pageNo, pageSize: size })
|
||||
}
|
||||
if (path.endsWith('/apply-preview')) {
|
||||
const ids = request.postDataJSON().ids; previews.push(ids)
|
||||
return reply({ records: ids.length, inboundCodes: ids.length, placeholderDetails: 0, replaceOldCodes: 0, blocked: [] })
|
||||
}
|
||||
if (path.endsWith('/syb-shops') && request.method() === 'GET') { shopLists.push(url); return reply({ items: [], total: 0 }) }
|
||||
if (request.method() !== 'GET') writes.push(path)
|
||||
return reply({ items: [], total: 0 })
|
||||
})
|
||||
const tableRows = page.locator('.el-table__body-wrapper tbody tr')
|
||||
const pagination = page.locator('.pagination-container')
|
||||
const started = Date.now()
|
||||
await page.goto('/#/syb-inner-codes')
|
||||
await expect(tableRows).toHaveCount(200)
|
||||
console.log(`mock 200-row initial navigation and render: ${Date.now() - started}ms`)
|
||||
expect(lists[0].searchParams.get('page')).toBe('1')
|
||||
expect(lists[0].searchParams.get('pageSize')).toBe('200')
|
||||
await expect(pagination.locator('.el-select')).toContainText('200')
|
||||
|
||||
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
|
||||
await page.getByRole('button', { name: '回写(200)', exact: true }).click()
|
||||
await expect.poll(() => previews.length).toBe(1)
|
||||
expect(previews[0]).toEqual(rows.slice(0, 200).map(row => row.id))
|
||||
await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(writes).toEqual([])
|
||||
|
||||
await pagination.locator('.btn-next').click()
|
||||
await expect(tableRows).toHaveCount(5)
|
||||
await expect(page.getByRole('cell', { name: 'MOCK-201', exact: true })).toBeVisible()
|
||||
expect(lists.at(-1)!.searchParams.get('page')).toBe('2')
|
||||
await expect(page.getByRole('button', { name: '回写(0)', exact: true })).toBeDisabled()
|
||||
await page.getByRole('button', { name: '搜索', exact: true }).click()
|
||||
await expect(tableRows).toHaveCount(200)
|
||||
|
||||
for (const size of [20, 50, 100, 200]) {
|
||||
await pagination.locator('.el-select').click()
|
||||
await page.getByRole('option', { name: new RegExp(`^${size}\\s*条/页$`) }).click()
|
||||
await expect(tableRows).toHaveCount(size)
|
||||
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size))
|
||||
for (const name of ['搜索', '清除']) {
|
||||
const before = lists.length
|
||||
await page.getByRole('button', { name, exact: true }).click()
|
||||
await expect.poll(() => lists.length).toBeGreaterThan(before)
|
||||
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size))
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByPlaceholder('订单号或入库码').fill('empty')
|
||||
await page.getByRole('button', { name: '搜索', exact: true }).click()
|
||||
await expect(tableRows).toHaveCount(0)
|
||||
await expect(pagination).not.toBeVisible()
|
||||
await page.reload()
|
||||
await expect(tableRows).toHaveCount(200)
|
||||
expect(lists.at(-1)!.searchParams.get('pageSize')).toBe('200')
|
||||
|
||||
await page.goto('/#/syb-shops')
|
||||
await expect.poll(() => shopLists.length).toBeGreaterThan(0)
|
||||
expect(shopLists[0].searchParams.get('pageSize')).toBe('20')
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const page = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8')
|
||||
const source = page.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
|
||||
const options = new Function(source)()
|
||||
|
||||
test('inner code page defaults to 200 and keeps a manual size through search and clear', async () => {
|
||||
const vm = { ...options.data(), ...options.methods }
|
||||
const requests = []
|
||||
vm.load = async () => { requests.push({ ...vm.query }) }
|
||||
|
||||
const now = new Date()
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
assert.equal(vm.query.page, 1)
|
||||
assert.equal(vm.query.pageSize, 200)
|
||||
assert.deepEqual(vm.dateRange, [today, today])
|
||||
assert.match(page, /:page-sizes="\[20, 50, 100, 200\]"/)
|
||||
|
||||
vm.query.pageSize = 50
|
||||
vm.query.page = 4
|
||||
vm.query.keyword = 'code'
|
||||
vm.dateRange = ['2026-10-01', '2026-10-02']
|
||||
await vm.search()
|
||||
assert.equal(requests[0].page, 1)
|
||||
assert.equal(requests[0].pageSize, 50)
|
||||
|
||||
vm.query.page = 3
|
||||
await vm.clearFilters()
|
||||
assert.equal(requests[1].page, 1)
|
||||
assert.equal(requests[1].pageSize, 50)
|
||||
assert.equal(requests[1].keyword, '')
|
||||
assert.equal(requests[1].dateFrom, '')
|
||||
assert.equal(requests[1].dateTo, '')
|
||||
|
||||
const freshVm = { ...options.data() }
|
||||
assert.equal(freshVm.query.pageSize, 200)
|
||||
})
|
||||
Reference in New Issue
Block a user