Compare commits

..
5 changed files with 109 additions and 164 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 86
versionName = "0.9.73"
versionCode = 85
versionName = "0.9.72"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -797,7 +797,7 @@ class PurchaseLiveAutomation(
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
return snapshot.nodes.filter { node ->
node.visible && node.enabled && node.scrollable &&
node.bounds.height * 100 >= node.bounds.width * PANEL_SCROLL_MIN_HEIGHT_TO_WIDTH_PERCENT &&
node.bounds.height >= node.bounds.width &&
node.bounds.width * 100 >= screenWidth * 60 &&
node.bounds.height * 100 >= screenHeight * 20
}.distinctBy { it.path }
@@ -1153,8 +1153,6 @@ class PurchaseLiveAutomation(
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
// #390: near-square purchase lists can be shorter than their width; exclude flat strips.
const val PANEL_SCROLL_MIN_HEIGHT_TO_WIDTH_PERCENT = 80
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
@@ -1302,113 +1302,6 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.genericSwipes)
}
@Test
fun `near square purchase panel reveals collapsed address with one bounded swipe`() {
assertClippedPanelAddressRevealed(NodeBounds(0, 400, 1080, 1478))
}
@Test
fun `purchase panel accepts exactly eighty percent height to width`() {
assertClippedPanelAddressRevealed(NodeBounds(0, 400, 1080, 1264))
}
private fun assertClippedPanelAddressRevealed(bounds: NodeBounds) {
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = bounds)
val initial = driver.capture()
val collapsedAddress = initial.nodes.single { it.path == "panel/collapsed-address" }
assertEquals(0, collapsedAddress.bounds.height)
assertFalse(collapsedAddress.visible)
assertEquals(2376, initial.nodes.maxOf { it.bounds.bottom })
val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390")
assertTrue(proof.expectedAddress.endsWith("_cg390"))
assertEquals(listOf(Triple(bounds, SwipeDirection.DOWN, 350L)), driver.scopedSwipeRequests)
assertEquals(1, driver.addressPathClicks)
assertEquals(1, driver.inputCount)
assertEquals(0, driver.genericSwipes + driver.submitClicks)
}
@Test
fun `purchase panel rejects height just below eighty percent and flat horizontal strip`() {
for (height in listOf(863, 400)) {
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = NodeBounds(0, 400, 1080, 400 + height))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
}
@Test
fun `purchase panel still requires sixty percent screen width and twenty percent screen height`() {
for (bounds in listOf(NodeBounds(0, 400, 647, 1478), NodeBounds(0, 400, 648, 999))) {
val driver = LiveDriver(addressClipped = true, clippedPanelBounds = bounds, clippedScreenHeight = 3000)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
}
@Test
fun `two near square purchase panels remain ambiguous without swiping`() {
val driver = LiveDriver(addressClipped = true, duplicatePanels = true, clippedPanelBounds = NodeBounds(0, 400, 1080, 1478))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `near square purchase panel stops after unchanged viewport without address`() {
assertClippedPanelTimeout(viewportChanges = false, expectedSwipes = 2)
}
@Test
fun `near square purchase panel stops within five rounds without address`() {
assertClippedPanelTimeout(viewportChanges = true, expectedSwipes = 4)
}
private fun assertClippedPanelTimeout(viewportChanges: Boolean, expectedSwipes: Int) {
val bounds = NodeBounds(0, 400, 1080, 1478)
val driver = LiveDriver(
addressClipped = true, clippedPanelBounds = bounds,
keepAddressClipped = true, clippedViewportChanges = viewportChanges,
)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg390") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_PANEL_TIMEOUT", error.code)
assertEquals(List(expectedSwipes) { Triple(bounds, SwipeDirection.DOWN, 350L) }, driver.scopedSwipeRequests)
assertEquals(0, driver.genericSwipes + driver.inputCount + driver.submitClicks)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `saved address evidence is restored through near square purchase panel`() {
val bounds = NodeBounds(0, 400, 1080, 1478)
val driver = LiveDriver(saveReturnsToSpecPanel = true, clippedPanelBounds = bounds)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg390")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg390"), proof)
assertEquals("_cg390", final.addressSuffix)
assertEquals(listOf(Triple(bounds, SwipeDirection.DOWN, 350L)), driver.scopedSwipeRequests)
assertEquals(0, driver.backCount + driver.genericSwipes + driver.submitClicks)
}
@Test
fun `ambiguous purchase panels fail without swiping or clicking`() {
val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
@@ -1555,10 +1448,6 @@ class PurchaseLiveAutomationTest {
private class LiveDriver(
private val addressClipped: Boolean = false,
private val duplicatePanels: Boolean = false,
private val clippedPanelBounds: NodeBounds? = null,
private val clippedScreenHeight: Int = 2376,
private val keepAddressClipped: Boolean = false,
private val clippedViewportChanges: Boolean = false,
private val chooserAfterSubmit: Boolean = false,
private val trustedChooser: Boolean = true,
private val splitConfirmationAddress: Boolean = false,
@@ -1591,7 +1480,6 @@ class PurchaseLiveAutomationTest {
val clicked = mutableListOf<String>()
var submitClicks = 0
var scopedSwipes = 0
val scopedSwipeRequests = mutableListOf<Triple<NodeBounds, SwipeDirection, Long>>()
var genericSwipes = 0
var addressTaps = 0
var addressPathClicks = 0
@@ -1612,21 +1500,7 @@ class PurchaseLiveAutomationTest {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
val snapshot = currentSnapshot().let { original ->
if (clippedPanelBounds != null && ((page == "confirmation" && !addressVisible) || page == "post-save-spec")) {
original.copy(nodes = original.nodes.map {
when (it.path) {
"root" -> it.copy(bounds = NodeBounds(0, 0, 1080, clippedScreenHeight))
"panel", "panel2" -> it.copy(bounds = clippedPanelBounds)
else -> it
}
} + node("panel/collapsed-address", "138****5678", parentPath = "panel",
bounds = NodeBounds(20, 400, 400, 400)).copy(visible = false) +
node("panel/viewport-marker", "", parentPath = "panel", bounds = NodeBounds(
20, 800 + if (clippedViewportChanges) scopedSwipes else 0, 300, 850,
)))
} else original
}
val snapshot = currentSnapshot()
if (page == "edit" && inputCount == 0) {
initialEditCaptures++
return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it }
@@ -1817,8 +1691,7 @@ class PurchaseLiveAutomationTest {
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++
scopedSwipeRequests += Triple(target.bounds, direction, durationMs)
if (target.path == "panel" && direction == SwipeDirection.DOWN && !keepAddressClipped) addressVisible = true
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
return true
}
+5 -4
View File
@@ -2,8 +2,9 @@ package sybimport
import "encoding/json"
// SYB returns numeric isCancel: 1 cancelled, 0 not cancelled. Do not infer
// anything from status/orderStatus, missing fields or undocumented values.
// SYB's numeric isCancel enum is 0 (正常), 1 (取消), 2 (已取消).
// Both 1 and 2 mean cancelled; other nonzero values remain unknown. Do not
// infer anything from status/orderStatus or missing fields.
func parseSYBIsCancelled(raw map[string]any) *bool {
var number float64
switch value := raw["isCancel"].(type) {
@@ -18,9 +19,9 @@ func parseSYBIsCancelled(raw map[string]any) *bool {
default:
return nil
}
if number != 0 && number != 1 {
if number != 0 && number != 1 && number != 2 {
return nil
}
cancelled := number == 1
cancelled := number == 1 || number == 2
return &cancelled
}
@@ -3,7 +3,11 @@ package sybimport
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
@@ -14,23 +18,42 @@ import (
func TestParseSYBIsCancelled(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{`{"isCancel":1}`, "true"}, {`{"isCancel":0}`, "false"},
{`{"isCancel":1}`, "true"}, {`{"isCancel":2}`, "true"}, {`{"isCancel":0}`, "false"},
{`{"isCancel":0.0}`, "false"}, {`{"isCancel":1.0}`, "true"}, {`{"isCancel":2.0}`, "true"},
{`{"isCancel":2e0}`, "true"},
{`{}`, "null"}, {`{"isCancel":null}`, "null"},
{`{"isCancel":2}`, "null"}, {`{"isCancel":-1}`, "null"},
{`{"isCancel":3}`, "null"}, {`{"isCancel":-1}`, "null"},
{`{"isCancel":0.5}`, "null"}, {`{"isCancel":"1"}`, "null"},
{`{"isCancel":true}`, "null"}, {`{"status":79,"orderStatus":"已取消"}`, "null"},
{`{"isCancel":1.5}`, "null"}, {`{"isCancel":2.5}`, "null"},
{`{"isCancel":"2"}`, "null"}, {`{"isCancel":"0"}`, "null"}, {`{"isCancel":""}`, "null"},
{`{"isCancel":true}`, "null"}, {`{"isCancel":false}`, "null"},
{`{"status":79,"orderStatus":"已取消","platformorderStatus":"CANCELLED"}`, "null"},
{`{"isCancel":0,"status":79,"orderStatus":"已取消","platformorderStatus":"CANCELLED"}`, "false"},
{`{"isCancel":0,"orderStatus":"申请取消"}`, "false"},
{`{"isCancel":1,"orderStatus":"已发货"}`, "true"},
{`{"isCancel":2,"orderStatus":"已发货"}`, "true"},
{`{"isCancel":2,"orderStatus":"运送中"}`, "true"},
{`{"isCancel":2,"orderStatus":"待出货"}`, "true"},
} {
var raw map[string]any
if err := json.Unmarshal([]byte(tc.input), &raw); err != nil {
t.Fatal(err)
}
got, _ := json.Marshal(parseSYBIsCancelled(raw))
if string(got) != tc.want {
t.Errorf("%s: %s, want %s", tc.input, got, tc.want)
for _, useNumber := range []bool{false, true} {
var raw map[string]any
decoder := json.NewDecoder(strings.NewReader(tc.input))
if useNumber {
decoder.UseNumber()
}
if err := decoder.Decode(&raw); err != nil {
t.Fatal(err)
}
got, _ := json.Marshal(parseSYBIsCancelled(raw))
if string(got) != tc.want {
t.Errorf("UseNumber=%t %s: %s, want %s", useNumber, tc.input, got, tc.want)
}
}
}
if got := parseSYBIsCancelled(map[string]any{"isCancel": json.Number("1")}); got == nil || !*got {
t.Fatal("json.Number")
for _, value := range []any{json.Number("invalid"), json.Number("1e999"), json.Number("NaN"), math.NaN(), math.Inf(1)} {
if got := parseSYBIsCancelled(map[string]any{"isCancel": value}); got != nil {
t.Errorf("invalid numeric value %v must remain unknown", value)
}
}
}
@@ -41,15 +64,29 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
detail := sybclient.StockDetail{ID: row.ID, Code: row.Code, Details: []sybclient.DetailItem{
{ID: 711, ProductID: 701, ProductQty: 1, ProductSpec: "黑色,XL"},
{ID: 712, ProductID: 702, ProductQty: 1, ProductSpec: "白色,L"},
{ID: 713, ProductID: 703, ProductQty: 1, ProductSpec: "蓝色,M"},
}}
untouchedTime := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
unrelated := models.SYBProduct{OrderCode: "MOCK-UNRELATED", DetailID: 721, StockID: 72,
Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`,
SYBIsCancelled: boolPointer(false), SYBStatusSyncedAt: &untouchedTime}
if err := db.Create(&unrelated).Error; err != nil {
t.Fatal(err)
}
var previous *time.Time
var existingTask models.PurchaseTask
var firstDetailID uint64
for _, tc := range []struct {
raw string
want *bool
raw string
want *bool
valid bool
}{
{`{}`, nil}, {`{"isCancel":1}`, boolPointer(true)},
{`{"isCancel":null}`, boolPointer(true)}, {`{"isCancel":7}`, boolPointer(true)},
{`{"isCancel":0}`, boolPointer(false)}, {`{}`, boolPointer(false)},
{`{}`, nil, false}, {`{"isCancel":0}`, boolPointer(false), true},
{`{"isCancel":2,"orderStatus":"已发货"}`, boolPointer(true), true},
{`{"isCancel":2}`, boolPointer(true), true},
{`{"isCancel":null}`, boolPointer(true), false}, {`{"isCancel":7}`, boolPointer(true), false},
{`{"isCancel":"2"}`, boolPointer(true), false}, {`{}`, boolPointer(true), false},
{`{"isCancel":0}`, boolPointer(false), true}, {`{}`, boolPointer(false), false},
} {
var values map[string]any
if err := json.Unmarshal([]byte(tc.raw), &values); err != nil {
@@ -59,17 +96,43 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
for key, value := range values {
row.Raw[key] = value
}
beforeSync := time.Now().UTC()
if err := applyStockDetail(ctx, db, row, detail, &SyncReport{}); err != nil {
t.Fatal(err)
}
afterSync := time.Now().UTC()
var records []models.SYBProduct
if err := db.Order("id").Find(&records).Error; err != nil {
if err := db.Where("order_code = ?", row.Code).Order("id").Find(&records).Error; err != nil {
t.Fatal(err)
}
if len(records) != 2 {
t.Fatal("must keep both details")
if len(records) != 3 {
t.Fatal("must keep all three details without duplicates")
}
firstDetailID = records[0].ID
if existingTask.ID == 0 {
product := models.PDDProduct{GoodsID: "389001", URL: "https://example.invalid/389001", SpecsJSON: `{}`}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
existingTask = models.PurchaseTask{SYBProductID: &records[0].ID, PDDProductID: product.ID,
ExecutionMode: models.PurchaseExecutionModeLive, Status: models.PurchaseTaskStatusPending,
Quantity: 1, Currency: "TWD", CreateRequestID: "mock-389-existing-task",
SpecDecisionSnapshot: `{}`, RuleSnapshot: `{}`, RequiredCapabilitiesJSON: `[]`}
if err := db.Create(&existingTask).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&existingTask, existingTask.ID).Error; err != nil {
t.Fatal(err)
}
} else {
var current models.PurchaseTask
if err := db.First(&current, existingTask.ID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(current, existingTask) {
t.Fatal("cancellation sync must leave the existing purchase task unchanged")
}
}
valid := parseSYBIsCancelled(values) != nil
for _, record := range records {
got, _ := json.Marshal(record.SYBIsCancelled)
want, _ := json.Marshal(tc.want)
@@ -82,15 +145,25 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
if tc.want != nil && record.SYBStatusSyncedAt == nil {
t.Fatal("known must have timestamp")
}
if !valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
if !tc.valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
t.Fatal("missing/invalid must preserve time")
}
if tc.valid && (record.SYBStatusSyncedAt.Before(beforeSync) || record.SYBStatusSyncedAt.After(afterSync)) {
t.Fatal("valid values, including repeats, must refresh the sync timestamp")
}
if records[0].SYBStatusSyncedAt != nil && !record.SYBStatusSyncedAt.Equal(*records[0].SYBStatusSyncedAt) {
t.Fatal("same order must use same timestamp for all details")
}
if record.PDDExcluded {
t.Fatal("display status must not exclude procurement")
}
}
if records[0].SYBStatusSyncedAt != nil && !records[0].SYBStatusSyncedAt.Equal(*records[1].SYBStatusSyncedAt) {
t.Fatal("same order must use same timestamp")
var other models.SYBProduct
if err := db.First(&other, unrelated.ID).Error; err != nil {
t.Fatal(err)
}
if other.SYBIsCancelled == nil || *other.SYBIsCancelled || other.SYBStatusSyncedAt == nil || !other.SYBStatusSyncedAt.Equal(untouchedTime) {
t.Fatal("sync must not alter another order's cancellation status or timestamp")
}
previous = records[0].SYBStatusSyncedAt
}
@@ -116,7 +189,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Data.Items) != 2 {
if len(payload.Data.Items) != 4 {
t.Fatal("list count")
}
for _, item := range payload.Data.Items {
@@ -135,7 +208,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
}
}
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("GET", "/products/1", nil))
router.ServeHTTP(response, httptest.NewRequest("GET", fmt.Sprintf("/products/%d", firstDetailID), nil))
if response.Code != 200 {
t.Fatalf("detail: %d", response.Code)
}