基本完成,测试功能,等待修改

Co-authored-by: Cursor <cursoragent@cursor.com>
master
你的GitHub用户名 1 month ago
commit 360c2c2d71

@ -0,0 +1,48 @@
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse Road Farmers Branch TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204915}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"delivery","label":"5678 Distribution Drive Fort Worth TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204925}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse Road Farmers Branch TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204928}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"delivery","label":"5678 Distribution Drive Fort Worth TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204929}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse Road Farmers Branch TX","placeId":"ChIJ_test_pickup","description":"1234 Warehouse Road, Farmers Branch, TX, USA","candidateCount":1,"allPlaceIds":["ChIJ_test_pickup"]},"timestamp":1782201204932}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201536196}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["EisxMjM0IFdhcmVob3Vz"]},"timestamp":1782201537665}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse StreetLos Angeles, CA","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateCount":1,"allPlaceIds":["EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol"]},"timestamp":1782201538143}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateIndex":0},"timestamp":1782201538477}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201541494}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201545036}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201557222}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol commit 失败原因widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler可能仅更新了 input 文本路径keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782201557223}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201589622}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["EisxMjM0IFdhcmVob3Vz"]},"timestamp":1782201597002}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":2,"placeIds":["EjMxMjM0IFdhcmVob3Vz"]},"timestamp":1782201609716}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"1234 Warehouse StreetLos Angeles, CA","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateCount":2,"allPlaceIds":["EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","EjMxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDYWxpZm9ybmlhLCBVU0EiMRIvChQKEgnLANPoJcbCgBG6jPkcgl7bSBDSCSoUChIJIRokdSbGwoARjZhcWiHJmiU"]},"timestamp":1782201610080}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateIndex":0},"timestamp":1782201610427}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2}},"timestamp":1782201613437}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2}},"timestamp":1782201616967}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2}},"timestamp":1782201629145}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":2,"searchIngestCount":2},"reason":"place/EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol commit 失败原因widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler可能仅更新了 input 文本路径keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782201629146}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201942106}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["ChIJT3S4eOm20YgRUeQO"]},"timestamp":1782201943875}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"La Concha Key West, Autograph Collection Duval Street, FL","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateCount":1,"allPlaceIds":["ChIJT3S4eOm20YgRUeQOBiQSjPc"]},"timestamp":1782201944190}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateIndex":0},"timestamp":1782201944527}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201947532}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201951074}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201963262}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/ChIJT3S4eOm20YgRUeQOBiQSjPc commit 失败原因widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler可能仅更新了 input 文本路径keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782201963263}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782201993208}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["ChIJT3S4eOm20YgRUeQO"]},"timestamp":1782201994545}
{"sessionId":"efb842","hypothesisId":"H3","location":"address-step.ts:selectAddressSuggestion","message":"binding-resolved","data":{"side":"pickup","label":"La Concha Key West, Autograph Collection Duval Street, FL","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateCount":1,"allPlaceIds":["ChIJT3S4eOm20YgRUeQOBiQSjPc"]},"timestamp":1782201994911}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-click","data":{"clickPath":"keyboard-bound-0","placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","description":"La Concha Key West, Autograph Collection, Duval Street, Key West, FL, USA","candidateIndex":0},"timestamp":1782201995251}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782201998255}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":3000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782202001788}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782202013999}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"keyboard-bound-0 → keyboard-full-0 → description-click","diag":{"expectedPlaceId":"ChIJT3S4eOm20YgRUeQOBiQSjPc","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/ChIJT3S4eOm20YgRUeQOBiQSjPc commit 失败原因widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler可能仅更新了 input 文本路径keyboard-bound-0 → keyboard-full-0 → description-click"},"timestamp":1782202014000}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:attach","message":"bridge-attached","data":{"pageUrl":"https://www.mothership.com/"},"timestamp":1782209499595}
{"sessionId":"efb842","hypothesisId":"H1","location":"axel-selection-bridge.ts:handleResponse","message":"search-ingested","data":{"added":1,"totalCandidates":1,"placeIds":["EisxMjM0IFdhcmVob3Vz"]},"timestamp":1782209500402}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209506528}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209511713}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209517158}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"after-keyboard","data":{"clickPath":"description-pointer → description-click → keyboard-bound-0","placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","description":"1234 Warehouse Street, Los Angeles, CA, USA","candidateIndex":0},"timestamp":1782209517159}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209522700}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":5000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209528490}
{"sessionId":"efb842","hypothesisId":"H2","location":"axel-selection-bridge.ts:waitForPlaceCommit","message":"wait-finished","data":{"placeId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committed":false,"timeoutMs":12000,"diagnostics":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1}},"timestamp":1782209540492}
{"sessionId":"efb842","hypothesisId":"H2","location":"address-suggestions.ts:selectAddressWithPlaceBinding","message":"commit-failed","data":{"clickPath":"description-pointer → description-click → keyboard-bound-0 → keyboard-full-0 → description-pointer-retry","diag":{"expectedPlaceId":"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol","committedPlaceIds":[],"placeNetworkLog":[],"searchCandidateCount":1,"searchIngestCount":1},"reason":"place/EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol commit 失败原因widget 未发起 GET place 请求;说明:点选未触发 MotherShip selection handler可能仅更新了 input 文本路径description-pointer → description-click → keyboard-bound-0 → keyboard-full-0 → description-pointer-retry"},"timestamp":1782209540493}

@ -0,0 +1,70 @@
---
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
alwaysApply: true
---
# Karpathy behavioral guidelines
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

@ -0,0 +1,35 @@
---
description: 项目统一规则入口
alwaysApply: true
---
你在本项目执行任何编码任务前,必须先读取并遵循 `cursor_project_rules` 目录中的规则文件。
强制约束:
## 不得脱离 `cursor_project_rules` 的上下文进行实现。
## 新功能必须按 `cursor_project_rules/implementation-plan.mdc` 的步骤顺序执行。
## 每完成一个步骤,必须在 `implementation-plan.mdc` 中写入:
- `Done`
- 两行本次执行记录
## 修改代码时保持最小变更,原则:执行修改必须遵循零风险,最小代码修改量,不能因为本次的修改而影响了其他功能正常使用,禁止修改与计划无关的代码。安全第一:不得引入新 bug不破坏现有逻辑
## 对新增或变更逻辑补充对应测试(如项目存在测试框架)。
## 要求:
### 必须100%精准定位错误根源:具体到哪个文件、第几行(或哪几行)、哪个函数/组件/查询/中间件。
### 逐层深度分析错误成因数据库层SQL/ORM/索引/事务、后端逻辑API、控制器、服务、验证、中间件、前端React/Vue/Svelte组件、状态管理、hooks、请求封装、组件库、构建工具、环境变量、部署配置全部覆盖。
### 给出100%能彻底解决的修改方案:直接贴出修改后的完整代码块(只改动必要部分,用```标注语言),并用注释标明「为何这样改」。
### 方案必须只能改最少文件、最少行数,杜绝过度重构。
### 整场对话诊断+方案总行数控制在100行以内精炼到底。所有输出的计划或总报告不得超过100行。现在开始请先复述你理解的错误现象然后直接报出「根源在xxx文件 第xx行」
## 所有前端界面文本必须使用中文,在设计前端时,应考虑用户为中文环境下使用,系统应该设计为中文系统,能被看到的都应该翻译为中文,除非是用户特殊说明。
## 界面文案必须干净,不得包含需求文档引用标记
### 并发场景必须考虑:重复提交、资源竞争、原子性、锁机制
### 外部调用必须处理:超时、失败、重试、熔断、降级
### 所有业务逻辑必须覆盖:异常流程、错误流程、边界场景、反向场景
### 禁止只处理理想流程,必须覆盖所有可能出现的异常与风险点
### 代码必须稳定、安全、可直接上线,不允许脆弱逻辑请直接输出**完整、健壮、包含所有异常处理与安全机制**的最终代码。
## 请在系统开发与初始化流程中,自动完成以下动作:
### 从需求文档中提取所有示例、实例、案例、样例数据、演示值、测试用例、状态示例。
### 核心响应协议:深度思考优先
## If a fix is attempted 3 times without structural improvement in logs or system state, stop all local fixes and perform full root cause re-analysis across all layers (UI, RPA, backend, queue, pricing). Local patching is prohibited until root cause layer is correctly identified.

@ -0,0 +1,11 @@
node_modules
.next
.git
.env
.env.*
.mypy_cache
*.md
docs
.cursor
cursor_project_rules
scripts

@ -0,0 +1,98 @@
# 宿主机 MySQL 端口docker-compose 映射,避开本机已占用的 3306
MYSQL_HOST_PORT=3307
MYSQL_ROOT_PASSWORD=changeme
JWT_SECRET=your-256-bit-secret-change-in-production
# 宿主 Service Token 映射JSONtoken → { customerId, permissions[] }
HOST_SERVICE_TOKENS={"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}
# 管理端可见客户列表(逗号分隔,与 HOST_SERVICE_TOKENS 合并)
CUSTOMER_REGISTRY=CUST_001,CUST_002,CUST_003
# ── RPAPRD v0.6 GD-15headed 录制后填入)────────────────────────
# 逗号分隔、按序探测;禁止 dashboard.mothership.com / www.mothership.com/quote
# 英文 locale 避免中文精简 widget无 cargo editor首页 + en-US 为推荐组合
MOTHERSHIP_QUOTE_URLS=https://www.mothership.com/
# 留空则 Playwright 默认en-US headless 仅显示 zip 精简条,与地址 RPA 不兼容
# RPA_BROWSER_LOCALE=en-US
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
RPA_MOCK_MODE=false
# 地址 commit 隔离real=真实 MotherShipmock=仅替代 commitprove-rpa-chain 下游验证用)
RPA_ADDRESS_MODE=real
# 报价模式direct=直连 axel API默认embed-demo 推荐)| widget=Playwright 页面交互
RPA_QUOTE_MODE=direct
# 方案 Awidget 双地址 vault 注入widget 模式默认 true显式 false 可关闭)
# RPA_DUAL_ADDRESS_PATCH=true
# place commit 修复模式event=Google事件触发(默认) | state=React注入 | legacy=原逻辑
RPA_PLACE_FIX_MODE=event
# true 时注入 P0 实例拓扑/监听器指纹 + P2 fetch 劫持fix-place 各阶段落盘 .rpa/place-diagnostic/
RPA_PLACE_DIAG=false
# 有头调试RPA_HEADED=true 等价 RPA_HEADLESS=false
RPA_HEADLESS=true
# RPA_HEADED=true
# 货物 drawer 填写完成后 page.pause()(仅人工验收时开启)
# RPA_CARGO_DRAWER_PAUSE=true
RPA_BROWSER_LOCALE=en-US
RPA_USE_PATCHRIGHT=false
# 反爬playwright-extra stealth与 patchright 二选一;部分页面会破坏 widget默认 false
RPA_USE_STEALTH=false
# 使用本机 Chrome/Edge降低 HeadlessChrome 指纹(推荐 chrome
# RPA_BROWSER_CHANNEL=chrome
# 真人入口流:先打开首页再点 Get Quote携带 Referer/Cookie
# RPA_HUMAN_ENTRY_FLOW=true
# RPA_QUOTE_WARMUP_URL=https://www.mothership.com/
# headed 调试慢动作毫秒RPA_NETWORK_DEBUG=true 输出全部请求
# RPA_SLOW_MO_MS=100
# RPA_NETWORK_DEBUG=false
RPA_WORKER_ID=
RPA_QUEUE_ENABLED=true
# 驻留页单次会话加速实验性默认关true 时需 worker + confirm 全链路)
RPA_PARKED_SESSION=false
# SelectorPlaywright codegen / record-mothership 产出,填表 + submit
# 录制 115541 / 115059 — 中英双语,逗号分隔按序尝试
RPA_SELECTOR_PICKUP_SECTION=#react-quoter-landing-plugin >> text=Pick up from,#react-quoter-landing-plugin >> text=Where to pick up,#react-quoter-landing-plugin >> text=从何处取货
RPA_SELECTOR_DELIVERY_SECTION=#react-quoter-landing-plugin >> text=Deliver to,#react-quoter-landing-plugin >> text=Delivery to,#react-quoter-landing-plugin >> text=送货至
RPA_SELECTOR_QUOTE_WIDGET=#react-quoter-landing-plugin
RPA_SELECTOR_PICKUP_STREET=#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"]
RPA_SELECTOR_DELIVERY_STREET=#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"]
RPA_SELECTOR_ADDRESS_SUGGESTION=#react-quoter-landing-plugin >> [role="listbox"] [role="option"],#react-quoter-landing-plugin >> li,.pac-item,[role="option"]
RPA_SELECTOR_CARGO_SECTION=#react-quoter-landing-plugin >> filter:text=Shipment details>>nth=4,#react-quoter-landing-plugin >> filter:text=Freight details>>nth=4,#react-quoter-landing-plugin >> filter:text=货运详情>>nth=4
RPA_SELECTOR_SUBMIT=role=button[name="GET QUOTE"],role=button[name="Get freight quote"],role=button[name="Get a freight quote"],role=button[name="获取货运报价"]
# 报价 tab/price selector 已废弃Network-First可留空
# RPA_SELECTOR_STANDARD_TAB=
# RPA_SELECTOR_GUARANTEED_TAB=
# RPA_SELECTOR_LOWEST_OPTION=
# RPA_SELECTOR_FASTEST_OPTION=
# RPA_SELECTOR_PRICE=
# 报价 API endpointHAR 校准辅助,仅 telemetry不参与 runtime 匹配)
# 阶段0 录证白名单services.mothership.com/axel/quote
MOTHERSHIP_QUOTE_API_URL_PATTERNS=services.mothership.com/axel/quote
# 设为 true 时输出全部 REQ/RESP定位 json=0
RPA_QUOTE_NETWORK_VISIBILITY=false
# 设为 true 时契约失败日志输出 raw JSON body
RPA_QUOTE_DEBUG_RAW=false
# 设为 true 时日志输出 JSON 候选 URL校准 HAR pinned 用)
RPA_QUOTE_NETWORK_SNIFF=false
RPA_SELECTOR_TRANSIT=text=business days
# 货物字段已改为 fillCargoInDrawer 动态 placeholder 定位,下列 env 仅兼容旧脚本runtime 不再读取
# RPA_SELECTOR_PALLET_QTY=...
# RPA_SELECTOR_WEIGHT=...
# RPA_SELECTOR_LENGTH=...
# RPA_SELECTOR_WIDTH=...
# RPA_SELECTOR_HEIGHT=...
# 可选:仅当入口跳转 login 时使用
MOTHERSHIP_EMAIL=
MOTHERSHIP_PASSWORD=
# 已废弃:请改用 MOTHERSHIP_QUOTE_URLS
# MOTHERSHIP_QUOTE_URL=
# 开发基础设施模式: auto | docker | native
DEV_INFRA_MODE=native
# 本地 prisma migrate/seed 使用宿主机端口;容器内仍用 mysql:3306
DATABASE_URL=mysql://root:changeme@localhost:3307/chajia
REDIS_URL=redis://localhost:6379

@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [main, develop, "feature/**"]
pull_request:
branches: [main, develop]
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Validate Prisma schema
run: npm run db:validate
- name: Lint (tsc)
run: npm run lint
- name: Unit tests
run: npm run test:unit
- name: Integration tests
run: npm run test:integration
env:
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'
- name: Build
run: npm run build
env:
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'

@ -0,0 +1,144 @@
name: E2E Nightly
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: E2E tests
run: npm run test:e2e
env:
CI: true
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
go-no-go:
runs-on: ubuntu-latest
needs: e2e
if: always()
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: Go-No-Go check (mock)
run: npm run go-no-go -- --mock
env:
RPA_MOCK_MODE: "true"
probe:
runs-on: ubuntu-latest
needs: e2e
if: always()
timeout-minutes: 45
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers (probe)
run: npx playwright install chromium --with-deps
- name: RPA probe 3/3 (optional)
run: bash scripts/ci/run-probe-nightly.sh
env:
MOTHERSHIP_USERNAME: ${{ secrets.MOTHERSHIP_USERNAME }}
MOTHERSHIP_PASSWORD: ${{ secrets.MOTHERSHIP_PASSWORD }}
MOTHERSHIP_QUOTE_URLS: ${{ secrets.MOTHERSHIP_QUOTE_URLS }}
PROBE_NIGHTLY_LOG: probe-nightly.log
- uses: actions/upload-artifact@v4
if: always()
with:
name: probe-nightly-log
path: probe-nightly.log
retention-days: 7
if-no-files-found: ignore
load:
runs-on: ubuntu-latest
needs: e2e
if: always()
steps:
- uses: actions/checkout@v4
- name: Install k6
run: |
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: Start app (mock mode)
run: |
npm run build
npm run start &
sleep 15
env:
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'
RPA_MOCK_MODE: "true"
DATABASE_URL: mysql://root:root@127.0.0.1:3306/chajia
REDIS_URL: redis://127.0.0.1:6379
- name: k6 rate-limit (smoke)
continue-on-error: true
run: k6 run --vus 1 --iterations 5 load-tests/rate-limit.js
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: always()
with:
name: k6-report
path: load-tests/
retention-days: 7

37
.gitignore vendored

@ -0,0 +1,37 @@
# dependencies
node_modules/
# env
.env
.env.local
.env.*.local
# next
.next/
out/
# prisma
prisma/*.db
# misc
.DS_Store
*.pem
npm-debug.log*
.mypy_cache/
# RPA 持久化storageStatetask-120
.rpa/
# typescript
*.tsbuildinfo
next-env.d.ts
# ide
.idea/
.vscode/
# local dev runtime
.dev/
# test artifacts
playwright-report/
test-results/

@ -0,0 +1,31 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npx prisma generate
ENV NODE_ENV=production
CMD ["npm", "run", "worker:rpa"]

@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/admin/markup-configs/route";
import { PUT } from "@/app/api/admin/markup-configs/[customer_id]/route";
import { resetServiceTokenCache } from "@/modules/auth/service-token";
vi.mock("@/lib/prisma", () => ({
prisma: {
markupConfig: {
findMany: vi.fn(),
upsert: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
const ADMIN_HEADERS = {
authorization: "Bearer admin-jwt",
"x-auth-type": "admin",
"x-user-id": "admin_demo",
"x-user-role": "admin",
};
describe("admin markup-configs API", () => {
beforeEach(() => {
vi.clearAllMocks();
resetServiceTokenCache();
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
process.env.CUSTOMER_REGISTRY = "CUST_002,CUST_003";
});
it("GET 返回注册客户合并配置", async () => {
vi.mocked(prisma.markupConfig.findMany).mockResolvedValue([
{
customerId: "CUST_002",
markupType: "percent",
markupPercent: 10,
markupFixedAmount: null,
operatorId: "admin_demo",
remark: null,
updatedAt: new Date("2026-06-15T10:00:00Z"),
},
] as never);
const res = await GET(
new Request("http://localhost/api/admin/markup-configs?page=1&size=10", {
headers: ADMIN_HEADERS,
}),
);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.code).toBe(0);
expect(body.data.total).toBe(3);
expect(body.data.list[1].customer_id).toBe("CUST_002");
expect(body.data.list[1].markup_percent).toBe(10);
});
it("PUT 固定金额加价成功", async () => {
vi.mocked(prisma.markupConfig.upsert).mockResolvedValue({
customerId: "CUST_001",
markupType: "fixed",
markupPercent: 0,
markupFixedAmount: 25,
operatorId: "admin_demo",
remark: "测试",
updatedAt: new Date("2026-06-24T10:00:00Z"),
} as never);
const res = await PUT(
new Request("http://localhost/api/admin/markup-configs/CUST_001", {
method: "PUT",
headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
markup_type: "fixed",
markup_fixed_amount: 25,
remark: "测试",
}),
}),
{ params: Promise.resolve({ customer_id: "CUST_001" }) },
);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.markup_type).toBe("fixed");
expect(body.data.markup_fixed_amount).toBe(25);
});
it("PUT 未知客户 400", async () => {
const res = await PUT(
new Request("http://localhost/api/admin/markup-configs/CUST_999", {
method: "PUT",
headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ markup_percent: 5 }),
}),
{ params: Promise.resolve({ customer_id: "CUST_999" }) },
);
const body = await res.json();
expect(res.status).toBe(400);
expect(body.message).toBe("客户不存在");
});
});

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET as getQueueStats } from "@/app/api/admin/queues/stats/route";
import { GET as getBullBoard } from "@/app/api/admin/queues/[[...slug]]/route";
vi.mock("@/workers/rpa/queue", () => ({
getQuoteRpaQueue: vi.fn(),
getQuoteRpaQueueCounts: vi.fn().mockResolvedValue({
waiting: 2,
active: 1,
delayed: 3,
failed: 4,
completed: 100,
}),
}));
vi.mock("@/lib/bull-board/setup", () => ({
getBullBoardHandler: vi.fn(() => async () =>
new Response("<html>Bull Board</html>", {
status: 200,
headers: { "Content-Type": "text/html" },
}),
),
}));
const ADMIN_HEADERS = {
"x-auth-type": "admin",
"x-user-id": "U_admin_demo",
"x-user-role": "admin",
};
const SERVICE_HEADERS = {
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("admin queues APItask-132", () => {
it("GET /api/admin/queues/stats → 200 + counts", async () => {
const response = await getQueueStats(
new Request("http://localhost/api/admin/queues/stats", {
headers: ADMIN_HEADERS,
}),
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.code).toBe(0);
expect(json.data.queue_name).toBe("quote-rpa");
expect(json.data.depth).toBe(6);
expect(json.data.failed).toBe(4);
expect(json.data.delayed).toBe(3);
});
it("GET /api/admin/queues/stats 非 admin → 403", async () => {
const response = await getQueueStats(
new Request("http://localhost/api/admin/queues/stats", {
headers: SERVICE_HEADERS,
}),
);
const json = await response.json();
expect(response.status).toBe(403);
expect(json.code).toBe("FORBIDDEN");
});
it("GET /api/admin/queues Bull Board admin → 200 HTML", async () => {
const response = await getBullBoard(
new Request("http://localhost/api/admin/queues", {
headers: ADMIN_HEADERS,
}),
{ params: Promise.resolve({}) },
);
expect(response.status).toBe(200);
expect(await response.text()).toContain("Bull Board");
});
it("GET /api/admin/queues 非 admin → 403", async () => {
const response = await getBullBoard(
new Request("http://localhost/api/admin/queues", {
headers: SERVICE_HEADERS,
}),
{ params: Promise.resolve({}) },
);
const json = await response.json();
expect(response.status).toBe(403);
expect(json.code).toBe("FORBIDDEN");
});
});

@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET as listAlerts } from "@/app/api/alerts/route";
import { POST as resolveAlert } from "@/app/api/alerts/[id]/resolve/route";
vi.mock("@/lib/prisma", () => ({
prisma: {
alertLog: {
count: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
const ADMIN_HEADERS = {
"x-auth-type": "admin",
"x-user-id": "U_admin_demo",
"x-user-role": "admin",
};
const SERVICE_HEADERS = {
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "",
};
const SAMPLE_ALERT = {
id: BigInt(1),
alertType: "RPA_FAILED",
quoteId: "QTE_001",
cargoHash: "abc123",
detailJson: { reason: "timeout" },
status: "open",
resolverId: null,
resolvedAt: null,
createdAt: new Date("2026-06-16T08:00:00Z"),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/alerts", () => {
it("admin + type 筛选 → 200", async () => {
vi.mocked(prisma.alertLog.count).mockResolvedValue(1);
vi.mocked(prisma.alertLog.findMany).mockResolvedValue([SAMPLE_ALERT as never]);
const response = await listAlerts(
new Request(
"http://localhost/api/alerts?page=1&size=20&type=RPA_FAILED&status=open",
{ headers: ADMIN_HEADERS },
),
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.code).toBe(0);
expect(json.data.list).toHaveLength(1);
expect(json.data.list[0].alert_type).toBe("RPA_FAILED");
expect(prisma.alertLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { alertType: "RPA_FAILED", status: "open" },
}),
);
});
it("缺分页 → 400", async () => {
const response = await listAlerts(
new Request("http://localhost/api/alerts", { headers: ADMIN_HEADERS }),
);
expect(response.status).toBe(400);
});
it("宿主 token → 403", async () => {
const response = await listAlerts(
new Request("http://localhost/api/alerts?page=1&size=20", {
headers: SERVICE_HEADERS,
}),
);
expect(response.status).toBe(403);
});
});
describe("POST /api/alerts/{id}/resolve", () => {
it("admin 处理 → 200 + resolved", async () => {
vi.mocked(prisma.alertLog.findUnique).mockResolvedValue(
SAMPLE_ALERT as never,
);
vi.mocked(prisma.alertLog.update).mockResolvedValue({
...SAMPLE_ALERT,
status: "resolved",
resolverId: "U_admin_demo",
resolvedAt: new Date("2026-06-16T09:00:00Z"),
} as never);
const response = await resolveAlert(
new Request("http://localhost/api/alerts/1/resolve", {
method: "POST",
headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ resolver_id: "U_admin_demo" }),
}),
{ params: Promise.resolve({ id: "1" }) },
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.data.status).toBe("resolved");
expect(json.data.resolver_id).toBe("U_admin_demo");
});
it("不存在 id → 404", async () => {
vi.mocked(prisma.alertLog.findUnique).mockResolvedValue(null);
const response = await resolveAlert(
new Request("http://localhost/api/alerts/999/resolve", {
method: "POST",
headers: ADMIN_HEADERS,
}),
{ params: Promise.resolve({ id: "999" }) },
);
expect(response.status).toBe(404);
});
it("非 admin → 403", async () => {
const response = await resolveAlert(
new Request("http://localhost/api/alerts/1/resolve", {
method: "POST",
headers: SERVICE_HEADERS,
}),
{ params: Promise.resolve({ id: "1" }) },
);
expect(response.status).toBe(403);
});
});

@ -0,0 +1,101 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { detectDeviation } from "@/modules/alert/deviation-detector";
const prismaMock = vi.hoisted(() => ({
quoteCacheMeta: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
alertLog: {
create: vi.fn(),
},
}));
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
const FOUR_TIERS = [
{
service_level: "standard" as const,
rate_option: "lowest" as const,
carrier: "M",
transit_days: "5",
transit_description: "x",
raw_freight: 320,
surcharges: 0,
raw_total: 320,
},
{
service_level: "standard" as const,
rate_option: "fastest" as const,
carrier: "M",
transit_days: "3",
transit_description: "x",
raw_freight: 365,
surcharges: 0,
raw_total: 365,
},
{
service_level: "guaranteed" as const,
rate_option: "lowest" as const,
carrier: "M",
transit_days: "4",
transit_description: "x",
raw_freight: 380,
surcharges: 0,
raw_total: 380,
},
{
service_level: "guaranteed" as const,
rate_option: "fastest" as const,
carrier: "M",
transit_days: "3",
transit_description: "x",
raw_freight: 410,
surcharges: 0,
raw_total: 410,
},
];
describe("TC-401 cache vs RPA 一致性task-113", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("L2 raw_total 与 RPA 写入一致偏差≥5% 触发 PRICE_DEVIATION", async () => {
const l2RawTotal = 320;
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({
cargoHash: "hash_cache",
rawTotalStandard: l2RawTotal,
});
prismaMock.quoteCacheMeta.update.mockResolvedValue({});
prismaMock.alertLog.create.mockResolvedValue({});
const newRpaQuotes = FOUR_TIERS.map((t) => ({ ...t }));
newRpaQuotes[0].raw_total = 340;
newRpaQuotes[0].raw_freight = 340;
await detectDeviation("hash_cache", newRpaQuotes, "QTE_CACHE");
expect(prismaMock.quoteCacheMeta.findUnique).toHaveBeenCalledWith({
where: { cargoHash: "hash_cache" },
});
expect(prismaMock.alertLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ alertType: "PRICE_DEVIATION" }),
}),
);
});
it("L2 与 RPA 一致(偏差<5%)→ 不触发预警", async () => {
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({
cargoHash: "hash_ok",
rawTotalStandard: 320,
});
prismaMock.quoteCacheMeta.update.mockResolvedValue({});
await detectDeviation("hash_ok", FOUR_TIERS, "QTE_OK");
expect(prismaMock.alertLog.create).not.toHaveBeenCalled();
});
});

@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { submitQuote } from "@/modules/quote/orchestrator";
vi.mock("@/modules/cache/redis-cache", () => ({
getL2: vi.fn(),
}));
vi.mock("@/modules/quote/idempotency", () => ({
checkIdempotency: vi.fn(),
saveIdempotency: vi.fn(),
}));
vi.mock("@/modules/quote/quote-id", () => ({
generateQuoteId: vi.fn(),
handleQuoteIdConflict: vi.fn(),
isQuoteIdUniqueConflict: vi.fn(),
}));
vi.mock("@/modules/quote/rpa-queue", () => ({
enqueueQuoteJob: vi.fn(),
}));
vi.mock("@/modules/pricing/engine", () => ({
getMarkupPercent: vi.fn().mockResolvedValue(0),
applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes),
}));
vi.mock("@/modules/metrics/collector", () => ({
safeRecord: vi.fn(),
recordPostTotal: vi.fn().mockResolvedValue(undefined),
recordCacheHit: vi.fn().mockResolvedValue(undefined),
recordPostDone: vi.fn().mockResolvedValue(undefined),
recordRpaTriggered: vi.fn().mockResolvedValue(undefined),
markQuoteStarted: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: { create: vi.fn() },
},
}));
import { getL2 } from "@/modules/cache/redis-cache";
import { checkIdempotency } from "@/modules/quote/idempotency";
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
const VALID_BODY = {
request_id: "550e8400-e29b-41d4-a716-446655440000",
customer_id: "CUST_001",
pickup_address: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
delivery_address: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weight: { value: 500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
pallet_count: 2,
cargo_type: "general_freight",
};
describe("TC-108 L2 过期触发 RPAtask-112", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkIdempotency).mockResolvedValue({ hit: false });
vi.mocked(getL2).mockResolvedValue(null);
vi.mocked(enqueueQuoteJob).mockResolvedValue(undefined);
});
it("L2 不存在(已过期/DEL→ 触发 RPA 入队", async () => {
const result = await submitQuote(VALID_BODY);
expect(result.status).toBe("processing");
expect(enqueueQuoteJob).toHaveBeenCalledTimes(1);
expect(result.source_type).toBeUndefined();
});
it("L2 存在 → 不走 RPA", async () => {
vi.mocked(getL2).mockResolvedValue({
quotes: [
{
service_level: "standard",
rate_option: "lowest",
raw_freight: 320,
surcharges: 0,
raw_total: 320,
carrier: "M",
transit_days: "5",
transit_description: "x",
},
{
service_level: "standard",
rate_option: "fastest",
raw_freight: 365,
surcharges: 0,
raw_total: 365,
carrier: "M",
transit_days: "3",
transit_description: "x",
},
{
service_level: "guaranteed",
rate_option: "lowest",
raw_freight: 380,
surcharges: 0,
raw_total: 380,
carrier: "M",
transit_days: "4",
transit_description: "x",
},
{
service_level: "guaranteed",
rate_option: "fastest",
raw_freight: 410,
surcharges: 0,
raw_total: 410,
carrier: "M",
transit_days: "3",
transit_description: "x",
},
],
});
const result = await submitQuote(VALID_BODY);
expect(result.status).toBe("done");
expect(result.source_type).toBe("cache");
expect(enqueueQuoteJob).not.toHaveBeenCalled();
});
});

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET as getDashboard } from "@/app/api/metrics/dashboard/route";
vi.mock("@/modules/metrics/collector", () => ({
getMetricsSnapshot: vi.fn(),
}));
vi.mock("@/workers/rpa/queue", () => ({
getQueueDepth: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
alertLog: {
count: vi.fn(),
},
},
}));
import { getMetricsSnapshot } from "@/modules/metrics/collector";
import { prisma } from "@/lib/prisma";
import { getQueueDepth } from "@/workers/rpa/queue";
import { METRIC_COUNTER } from "@/lib/constants/metrics";
const ADMIN_HEADERS = {
"x-auth-type": "admin",
"x-user-id": "U_admin_demo",
"x-user-role": "admin",
};
const SERVICE_HEADERS = {
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "",
};
const SAMPLE_SNAPSHOT = {
api_success_rate: 99.5,
rpa_success_rate: 85,
realtime_rate: 90,
cache_hit_rate: 92,
p95_latency_ms: 12_000,
latency_sample_count: 100,
counters: {
[METRIC_COUNTER.POST_TOTAL]: 1000,
[METRIC_COUNTER.POST_DONE]: 995,
[METRIC_COUNTER.CACHE_HIT_L1]: 200,
[METRIC_COUNTER.CACHE_HIT_L2]: 720,
[METRIC_COUNTER.RPA_TRIGGERED]: 80,
[METRIC_COUNTER.RPA_DONE]: 68,
[METRIC_COUNTER.DONE_TOTAL]: 995,
[METRIC_COUNTER.DONE_REALTIME]: 896,
},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/metrics/dashboard", () => {
it("admin → 200 + GD-4 四指标", async () => {
vi.mocked(getMetricsSnapshot).mockResolvedValue(SAMPLE_SNAPSHOT);
vi.mocked(getQueueDepth).mockResolvedValue(3);
vi.mocked(prisma.alertLog.count).mockResolvedValue(5);
const response = await getDashboard(
new Request("http://localhost/api/metrics/dashboard", {
headers: ADMIN_HEADERS,
}),
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.code).toBe(0);
expect(json.data.api_success_rate).toBe(99.5);
expect(json.data.rpa_success_rate).toBe(85);
expect(json.data.realtime_rate).toBe(90);
expect(json.data.cache_hit_rate).toBe(92);
expect(json.data.p95_latency_ms).toBe(12_000);
expect(json.data.queue_depth).toBe(3);
expect(json.data.open_alerts_count).toBe(5);
});
it("非 admin → 403", async () => {
const response = await getDashboard(
new Request("http://localhost/api/metrics/dashboard", {
headers: SERVICE_HEADERS,
}),
);
expect(response.status).toBe(403);
expect(getMetricsSnapshot).not.toHaveBeenCalled();
});
});

@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { POST } from "@/app/api/quotes/route";
import { GET as getQuote } from "@/app/api/quotes/[quote_id]/route";
import { GET as getHistory } from "@/app/api/quotes/history/route";
import { PUT as putMarkup } from "@/app/api/markup-configs/[customer_id]/route";
vi.mock("@/modules/quote/orchestrator", () => ({
submitQuote: vi.fn(),
}));
vi.mock("@/lib/api/rate-limit", () => ({
enforceRateLimits: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
findFirst: vi.fn(),
count: vi.fn(),
findMany: vi.fn(),
},
markupConfig: { upsert: vi.fn() },
auditLog: { create: vi.fn().mockResolvedValue({}) },
alertLog: { create: vi.fn().mockResolvedValue({}) },
},
}));
import { submitQuote } from "@/modules/quote/orchestrator";
import { prisma } from "@/lib/prisma";
import { resetServiceTokenCache } from "@/modules/auth/service-token";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
const SERVICE_HEADERS = {
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "pricing:markup:write",
};
const VALID_BODY = {
request_id: "550e8400-e29b-41d4-a716-446655440000",
customer_id: "CUST_001",
pickup_address: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
delivery_address: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weight: { value: 500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
pallet_count: 2,
cargo_type: "general_freight",
};
beforeEach(() => {
vi.clearAllMocks();
resetServiceTokenCache();
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
});
describe("POST /api/quotes", () => {
it("正常询价 → 200 + quote_id", async () => {
vi.mocked(submitQuote).mockResolvedValue({
quote_id: "QTE_001",
status: "done",
source_type: "cache",
});
const response = await POST(
new Request("http://localhost/api/quotes", {
method: "POST",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify(VALID_BODY),
}),
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.code).toBe(0);
expect(json.data.quote_id).toBe("QTE_001");
expect(json.data.source_type).toBe("cache");
});
it("customer_id 不匹配 → 403", async () => {
const response = await POST(
new Request("http://localhost/api/quotes", {
method: "POST",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify({ ...VALID_BODY, customer_id: "CUST_999" }),
}),
);
expect(response.status).toBe(403);
});
});
describe("GET /api/quotes/{quote_id}", () => {
it("他人 quote_id → 403TC-501", async () => {
vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue({
quoteId: "QTE_B",
customerId: "CUST_B",
requestId: "req",
status: "done",
currency: "USD",
isRealtime: true,
sourceType: "cache",
confidenceScore: null,
validUntil: new Date(Date.now() + 60_000),
quotesJson: [],
errorCode: null,
createdAt: new Date(),
updatedAt: new Date(),
} as never);
const response = await getQuote(
new Request("http://localhost/api/quotes/QTE_B", {
headers: SERVICE_HEADERS,
}),
{ params: Promise.resolve({ quote_id: "QTE_B" }) },
);
expect(response.status).toBe(403);
});
it("不存在 → 404", async () => {
vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue(null);
const response = await getQuote(
new Request("http://localhost/api/quotes/QTE_X", {
headers: SERVICE_HEADERS,
}),
{ params: Promise.resolve({ quote_id: "QTE_X" }) },
);
expect(response.status).toBe(404);
});
});
describe("GET /api/quotes/history", () => {
it("缺 page/size → 400TC-506", async () => {
const response = await getHistory(
new Request("http://localhost/api/quotes/history", {
headers: SERVICE_HEADERS,
}),
);
expect(response.status).toBe(400);
});
});
describe("PUT /api/markup-configs/{customer_id}", () => {
it("markup=30.1 → 400TC-505", async () => {
const response = await putMarkup(
new Request("http://localhost/api/markup-configs/CUST_001", {
method: "PUT",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify({ markup_percent: 30.1, operator_id: "op1" }),
}),
{ params: Promise.resolve({ customer_id: "CUST_001" }) },
);
expect(response.status).toBe(400);
});
it("无 pricing:markup:write → 403TC-502", async () => {
const response = await putMarkup(
new Request("http://localhost/api/markup-configs/CUST_001", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "",
},
body: JSON.stringify({ markup_percent: 10, operator_id: "op1" }),
}),
{ params: Promise.resolve({ customer_id: "CUST_001" }) },
);
expect(response.status).toBe(403);
});
});

@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { POST } from "@/app/api/quotes/route";
vi.mock("@/modules/cache/redis-cache", () => ({
getL2: vi.fn(),
setL2: vi.fn(),
setL3: vi.fn(),
}));
vi.mock("@/modules/quote/idempotency", () => ({
checkIdempotency: vi.fn(),
saveIdempotency: vi.fn(),
}));
vi.mock("@/modules/quote/quote-id", () => ({
generateQuoteId: vi.fn(),
handleQuoteIdConflict: vi.fn(),
isQuoteIdUniqueConflict: vi.fn(),
}));
vi.mock("@/modules/quote/rpa-queue", () => ({
enqueueQuoteJob: vi.fn(),
}));
vi.mock("@/modules/metrics/collector", () => ({
safeRecord: vi.fn(),
recordPostTotal: vi.fn().mockResolvedValue(undefined),
recordCacheHit: vi.fn().mockResolvedValue(undefined),
recordPostDone: vi.fn().mockResolvedValue(undefined),
recordRpaTriggered: vi.fn().mockResolvedValue(undefined),
markQuoteStarted: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/api/rate-limit", () => ({
enforceRateLimits: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: { create: vi.fn() },
markupConfig: { findFirst: vi.fn().mockResolvedValue(null) },
},
}));
import { getL2 } from "@/modules/cache/redis-cache";
import { checkIdempotency } from "@/modules/quote/idempotency";
import { generateQuoteId } from "@/modules/quote/quote-id";
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
import { prisma } from "@/lib/prisma";
import { resetServiceTokenCache } from "@/modules/auth/service-token";
import { buildCargoHash, serializeAddress } from "@/modules/quote/cargo-hash";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
const SERVICE_HEADERS = {
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "pricing:markup:write",
};
const VALID_BODY = {
request_id: "550e8400-e29b-41d4-a716-446655440000",
customer_id: "CUST_001",
pickup_address: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
delivery_address: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weight: { value: 500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
pallet_count: 2,
cargo_type: "general_freight",
};
const FOUR_TIERS = [
{
service_level: "standard",
rate_option: "lowest",
raw_freight: 320,
surcharges: 0,
raw_total: 320,
carrier: "M",
transit_days: "5",
transit_description: "x",
},
{
service_level: "standard",
rate_option: "fastest",
raw_freight: 365,
surcharges: 0,
raw_total: 365,
carrier: "M",
transit_days: "3",
transit_description: "x",
},
{
service_level: "guaranteed",
rate_option: "lowest",
raw_freight: 380,
surcharges: 0,
raw_total: 380,
carrier: "M",
transit_days: "4",
transit_description: "x",
},
{
service_level: "guaranteed",
rate_option: "fastest",
raw_freight: 410,
surcharges: 0,
raw_total: 410,
carrier: "M",
transit_days: "3",
transit_description: "x",
},
];
beforeEach(() => {
vi.clearAllMocks();
resetServiceTokenCache();
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
vi.mocked(checkIdempotency).mockResolvedValue({ hit: false });
vi.mocked(getL2).mockResolvedValue(null);
vi.mocked(generateQuoteId).mockResolvedValue("QTE_INT_0001");
vi.mocked(prisma.quoteRecord.create).mockResolvedValue({} as never);
vi.mocked(enqueueQuoteJob).mockResolvedValue(undefined);
});
describe("POST /api/quotes 集成task-085", () => {
it("TC-101首次询价 → processing + 入队 RPA", async () => {
const response = await POST(
new Request("http://localhost/api/quotes", {
method: "POST",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify(VALID_BODY),
}),
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.data.status).toBe("processing");
expect(enqueueQuoteJob).toHaveBeenCalled();
});
it("TC-104L2 命中 → done + source=cache", async () => {
vi.mocked(getL2).mockResolvedValue({ quotes: FOUR_TIERS });
const response = await POST(
new Request("http://localhost/api/quotes", {
method: "POST",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify(VALID_BODY),
}),
);
const json = await response.json();
expect(json.data.status).toBe("done");
expect(json.data.source_type).toBe("cache");
expect(enqueueQuoteJob).not.toHaveBeenCalled();
});
it("TC-105L1 幂等 → 返回原 quote_id", async () => {
vi.mocked(checkIdempotency).mockResolvedValue({
hit: true,
quoteId: "QTE_EXISTING",
response: {
quote_id: "QTE_EXISTING",
status: "done",
source_type: "rpa",
},
});
const response = await POST(
new Request("http://localhost/api/quotes", {
method: "POST",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify(VALID_BODY),
}),
);
const json = await response.json();
expect(json.data.quote_id).toBe("QTE_EXISTING");
expect(prisma.quoteRecord.create).not.toHaveBeenCalled();
});
it("TC-106/107相同货物 hash 一致service_level 不影响 hash", () => {
const pickup = serializeAddress({
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
});
const delivery = serializeAddress({
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
});
const base = {
pickupSerialized: pickup,
deliverySerialized: delivery,
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
};
const h1 = buildCargoHash(base);
const h2 = buildCargoHash({ ...base, cargoType: "general_freight" });
expect(h1).toBe(h2);
expect(h1).toHaveLength(32);
});
});

@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET as getRpaStatus } from "@/app/api/rpa/status/route";
import { POST as pauseWorkerRoute } from "@/app/api/rpa/worker/pause/route";
vi.mock("@/modules/cache/circuit-breaker", () => ({
isCircuitOpen: vi.fn().mockResolvedValue(false),
}));
vi.mock("@/workers/rpa/queue", () => ({
getQueueDepth: vi.fn().mockResolvedValue(3),
}));
vi.mock("@/workers/rpa/worker-state", () => ({
listWorkerHeartbeats: vi.fn().mockResolvedValue([
{ workerId: "worker-1", lastSeenMs: Date.now() },
]),
getWorkerPauseRemaining: vi.fn().mockResolvedValue(0),
pauseWorker: vi.fn().mockResolvedValue(undefined),
isWorkerPaused: vi.fn().mockResolvedValue(false),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
count: vi.fn().mockResolvedValue(10),
},
},
}));
import { pauseWorker } from "@/workers/rpa/worker-state";
const ADMIN_HEADERS = {
"x-auth-type": "admin",
"x-user-id": "U_admin_demo",
"x-user-role": "admin",
};
const SERVICE_HEADERS = {
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("RPA 管理 API 集成task-116", () => {
it("GET /api/rpa/status → 200 + workers", async () => {
const response = await getRpaStatus(
new Request("http://localhost/api/rpa/status", {
headers: ADMIN_HEADERS,
}),
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.code).toBe(0);
expect(json.data.workers).toHaveLength(1);
expect(json.data.queue_depth).toBe(3);
});
it("POST pause → Worker 暂停", async () => {
const response = await pauseWorkerRoute(
new Request("http://localhost/api/rpa/worker/pause", {
method: "POST",
headers: {
...ADMIN_HEADERS,
"Content-Type": "application/json",
},
body: JSON.stringify({
worker_id: "worker-1",
duration_minutes: 5,
}),
}),
);
const json = await response.json();
expect(response.status).toBe(200);
expect(json.code).toBe(0);
expect(pauseWorker).toHaveBeenCalledWith("worker-1", 5);
});
it("非 admin → 403", async () => {
const response = await getRpaStatus(
new Request("http://localhost/api/rpa/status", {
headers: SERVICE_HEADERS,
}),
);
expect(response.status).toBe(403);
});
});

@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { POST } from "@/app/api/quotes/route";
import { GET as getQuote } from "@/app/api/quotes/[quote_id]/route";
import { PUT as putMarkup } from "@/app/api/markup-configs/[customer_id]/route";
vi.mock("@/modules/quote/orchestrator", () => ({
submitQuote: vi.fn(),
}));
vi.mock("@/lib/api/rate-limit", () => ({
enforceRateLimits: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: { findFirst: vi.fn() },
auditLog: { create: vi.fn() },
markupConfig: { upsert: vi.fn() },
},
}));
vi.mock("@/modules/alert/service", () => ({
writeAlert: vi.fn(),
}));
import { submitQuote } from "@/modules/quote/orchestrator";
import { enforceRateLimits } from "@/lib/api/rate-limit";
import { prisma } from "@/lib/prisma";
import { writeAlert } from "@/modules/alert/service";
import { resetServiceTokenCache } from "@/modules/auth/service-token";
import { fail } from "@/lib/response";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
const SERVICE_HEADERS = {
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "pricing:markup:write",
};
const VALID_BODY = {
request_id: "550e8400-e29b-41d4-a716-446655440000",
customer_id: "CUST_001",
pickup_address: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
delivery_address: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weight: { value: 500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
pallet_count: 2,
cargo_type: "general_freight",
};
beforeEach(() => {
vi.clearAllMocks();
resetServiceTokenCache();
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
vi.mocked(enforceRateLimits).mockResolvedValue(null);
vi.mocked(submitQuote).mockResolvedValue({
quote_id: "QTE_001",
status: "done",
source_type: "cache",
});
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.markupConfig.upsert).mockResolvedValue({} as never);
});
describe("安全与权限集成task-086", () => {
it("TC-501越权查询他人 quote → 403 + audit", async () => {
vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue({
quoteId: "QTE_B",
customerId: "CUST_B",
requestId: "req",
status: "done",
currency: "USD",
isRealtime: true,
sourceType: "cache",
confidenceScore: null,
validUntil: new Date(Date.now() + 60_000),
quotesJson: [],
errorCode: null,
createdAt: new Date(),
updatedAt: new Date(),
} as never);
const response = await getQuote(
new Request("http://localhost/api/quotes/QTE_B", {
headers: SERVICE_HEADERS,
}),
{ params: Promise.resolve({ quote_id: "QTE_B" }) },
);
expect(response.status).toBe(403);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ action: "FORBIDDEN_ACCESS" }),
}),
);
});
it("TC-502无 pricing:markup:write → 403", async () => {
const response = await putMarkup(
new Request("http://localhost/api/markup-configs/CUST_001", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-auth-type": "service",
"x-customer-id": "CUST_001",
"x-permissions": "",
},
body: JSON.stringify({ markup_percent: 10, operator_id: "op1" }),
}),
{ params: Promise.resolve({ customer_id: "CUST_001" }) },
);
expect(response.status).toBe(403);
});
it("TC-503customer 限流 → 429", async () => {
vi.mocked(enforceRateLimits).mockResolvedValue(
fail("RATE_LIMITED", "请求过于频繁,请稍后再试", 429),
);
const response = await POST(
new Request("http://localhost/api/quotes", {
method: "POST",
headers: {
"Content-Type": "application/json",
...SERVICE_HEADERS,
"x-forwarded-for": "10.0.0.1",
},
body: JSON.stringify(VALID_BODY),
}),
);
expect(response.status).toBe(429);
});
it("TC-504SQL 注入 street → 400 + SECURITY 预警", async () => {
const { SecurityValidationError } = await import("@/modules/quote/types");
vi.mocked(submitQuote).mockImplementation(() => {
throw new SecurityValidationError("提货地址无效,请检查");
});
const response = await POST(
new Request("http://localhost/api/quotes", {
method: "POST",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify({
...VALID_BODY,
pickup_address: {
...VALID_BODY.pickup_address,
street: "'; DROP TABLE quote_record; --",
},
}),
}),
);
expect(response.status).toBe(400);
await vi.waitFor(() => {
expect(writeAlert).toHaveBeenCalledWith(
"SECURITY",
expect.any(Object),
);
});
});
it("TC-505markup=30.1 → 400", async () => {
const response = await putMarkup(
new Request("http://localhost/api/markup-configs/CUST_001", {
method: "PUT",
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
body: JSON.stringify({ markup_percent: 30.1, operator_id: "op1" }),
}),
{ params: Promise.resolve({ customer_id: "CUST_001" }) },
);
expect(response.status).toBe(400);
});
});

@ -0,0 +1,46 @@
/** 阶段0 录证 axel/quote 响应精简 fixture */
export const AXEL_QUOTE_BODY = {
data: {
rates: {
standard: {
lowest: {
price: 423.49,
finalPrice: 428.48,
days: 8,
serviceLevel: "standard",
serviceType: "lowest",
serviceCommitmentDescription: "Standard",
},
fastest: {
price: 662.08,
finalPrice: 667.07,
days: 3,
serviceLevel: "standard",
serviceType: "fastest",
serviceCommitmentDescription: "LTL Standard",
},
bestValue: {
price: 576.24,
days: 6,
serviceLevel: "standard",
serviceType: "bestValue",
},
},
guaranteed: {
bestValue: {
price: 809.16,
finalPrice: 814.15,
days: 3,
serviceLevel: "guaranteed",
serviceType: "bestValue",
serviceCommitmentDescription: "Urgent Care (Guaranteed by 5pm)",
},
},
},
availableRates: {},
},
key: "quote_test",
metadata: {},
};
export const AXEL_QUOTE_URL = "https://services.mothership.com/axel/quote";

@ -0,0 +1,12 @@
/** 测试用:用户已确认的 MotherShip 精确地址字段 */
export const MOTHERSHIP_PICKUP_CONFIRMED = {
mothership_option_id: "ms_test_pickup",
mothership_display_label: "1234 Warehouse Blvd, Los Angeles, CA 90001",
selected_from_mothership: true as const,
};
export const MOTHERSHIP_DELIVERY_CONFIRMED = {
mothership_option_id: "ms_test_delivery",
mothership_display_label: "5678 Distribution Dr, Dallas, TX 75201",
selected_from_mothership: true as const,
};

@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
import {
POLL_INTERVAL_MS,
POLL_TIMEOUT_MS,
} from "@/lib/frontend/constants";
import type { QuoteDetail } from "@/lib/frontend/types";
function processingQuote(): QuoteDetail {
return {
quote_id: "QTE_test",
request_id: "req-1",
status: "processing",
currency: "USD",
};
}
function doneQuote(): QuoteDetail {
return {
quote_id: "QTE_test",
request_id: "req-1",
status: "done",
currency: "USD",
is_realtime: true,
quotes: [],
};
}
describe("pollQuoteUntilDone", () => {
it("前 3 次网络失败第 4 次成功 → done", async () => {
let calls = 0;
const result = await pollQuoteUntilDone(async () => {
calls += 1;
if (calls < 4) {
return { ok: false };
}
return { ok: true, data: doneQuote() };
});
expect(result.type).toBe("done");
if (result.type === "done") {
expect(result.quote.status).toBe("done");
}
expect(calls).toBe(4);
});
it("超过 POLL_TIMEOUT_MS 仍 processing → timeout", async () => {
vi.useFakeTimers();
const promise = pollQuoteUntilDone(async () => ({
ok: true,
data: processingQuote(),
}));
await vi.advanceTimersByTimeAsync(
POLL_TIMEOUT_MS + POLL_INTERVAL_MS,
);
const result = await promise;
expect(result.type).toBe("timeout");
vi.useRealTimers();
});
it("failed 状态 → error 含可读文案", async () => {
const result = await pollQuoteUntilDone(async () => ({
ok: true,
data: {
...doneQuote(),
status: "failed",
error_code: "ADDRESS_SUGGESTION_NOT_FOUND",
error_message: "未找到用户确认的 MotherShip 地址",
},
}));
expect(result.type).toBe("error");
if (result.type === "error") {
expect(result.message).toBe("未找到用户确认的 MotherShip 地址");
}
});
it("processing 超过 10s 后使用更短轮询间隔", async () => {
vi.useFakeTimers();
let calls = 0;
const intervals: number[] = [];
let last = Date.now();
const promise = pollQuoteUntilDone(async () => {
const now = Date.now();
if (calls > 0) {
intervals.push(now - last);
}
last = now;
calls += 1;
if (calls < 7) {
return { ok: true, data: processingQuote() };
}
return { ok: true, data: doneQuote() };
});
for (let i = 0; i < 6; i += 1) {
await vi.advanceTimersByTimeAsync(2_000);
}
await vi.advanceTimersByTimeAsync(800);
const result = await promise;
expect(result.type).toBe("done");
expect(intervals.some((gap) => gap <= 800)).toBe(true);
vi.useRealTimers();
});
});

@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it } from "vitest";
import { validateProbeItems } from "@/lib/rpa/probe-validation";
import { executeProbeQuote } from "@/lib/rpa/run-probe";
import type { QuoteItem } from "@/modules/providers/quote-provider";
function fourTiers(): QuoteItem[] {
return [
{
serviceLevel: "standard",
rateOption: "lowest",
carrier: "M",
transitDays: "5",
transitDescription: "最低",
rawFreight: 320,
surcharges: 0,
rawTotal: 320,
},
{
serviceLevel: "standard",
rateOption: "fastest",
carrier: "M",
transitDays: "3",
transitDescription: "最快",
rawFreight: 365,
surcharges: 0,
rawTotal: 365,
},
{
serviceLevel: "guaranteed",
rateOption: "lowest",
carrier: "M",
transitDays: "4",
transitDescription: "保价最低",
rawFreight: 380,
surcharges: 0,
rawTotal: 380,
},
{
serviceLevel: "guaranteed",
rateOption: "fastest",
carrier: "M",
transitDays: "2",
transitDescription: "保价最快",
rawFreight: 410,
surcharges: 0,
rawTotal: 410,
},
];
}
describe("TC-601 probe 校验task-133", () => {
it("validateProbeItems4 档通过", () => {
expect(validateProbeItems(fourTiers())).toEqual({ ok: true });
});
it("validateProbeItems仅 2 档 lowest → 通过fastest 复用)", () => {
const twoLowest = [
fourTiers()[0],
fourTiers()[2],
];
expect(validateProbeItems(twoLowest)).toEqual({ ok: true });
});
it("validateProbeItems仅 1 档 → 失败", () => {
const result = validateProbeItems([fourTiers()[0]]);
expect(result.ok).toBe(false);
});
it("mock 模式 executeProbeQuote → items.length===4", async () => {
process.env.RPA_MOCK_MODE = "true";
const result = await executeProbeQuote();
expect(result.items).toHaveLength(4);
expect(validateProbeItems(result.items)).toEqual({ ok: true });
result.items.forEach((item) => {
expect(item.rawFreight).toBeGreaterThan(0);
expect(item.transitDescription).toBeTruthy();
});
});
});
describe("probe 3/3 模拟task-133", () => {
beforeEach(() => {
process.env.RPA_MOCK_MODE = "true";
});
it("连续 3 次 mock probe 均四档齐全", async () => {
for (let i = 0; i < 3; i++) {
const result = await executeProbeQuote();
expect(result.items.length).toBe(4);
expect(validateProbeItems(result.items).ok).toBe(true);
}
});
});

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { axelSearchRowToCandidate } from "@/lib/axel/candidates";
describe("axelSearchRowToCandidate", () => {
it("display_label 与 axel description 一致option_id 保留原始 placeId", () => {
const row = {
placeId: "ChIJ4cLf10jGwoARBQWdZHer2OQ",
description: "123 Main Street, Los Angeles, CA, USA",
mainText: "123 Main Street",
secondaryText: "Los Angeles, CA, USA",
};
const candidate = axelSearchRowToCandidate(row, {
street: "123 Main St",
city: "Los Angeles",
state: "CA",
zip: "90001",
});
expect(candidate?.option_id).toBe("ChIJ4cLf10jGwoARBQWdZHer2OQ");
expect(candidate?.display_label).toBe(
"123 Main Street, Los Angeles, CA, USA",
);
expect(candidate?.street).toContain("123");
});
});

@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { pickBestSearchCandidate } from "@/lib/axel/pick-candidate";
import { buildAxelShipmentPayload } from "@/lib/axel/shipment";
import { parseQuoteCliArgs } from "@/lib/axel/parse-cli-args";
describe("parseQuoteCliArgs", () => {
it("解析必填参数", () => {
const args = parseQuoteCliArgs([
"--origin",
"3131 Western Ave, Seattle, WA",
"--dest",
"123 Main St, Los Angeles, CA",
"--pallets",
"2",
"--weight",
"500",
]);
expect(args.origin).toContain("Seattle");
expect(args.pallets).toBe(2);
expect(args.weightLb).toBe(500);
});
it("缺少 origin 时抛错", () => {
expect(() =>
parseQuoteCliArgs(["--dest", "x", "--pallets", "1", "--weight", "100"]),
).toThrow(/--origin/);
});
});
describe("pickBestSearchCandidate", () => {
it("优先匹配门牌号", () => {
const picked = pickBestSearchCandidate("3131 Western Ave, Seattle, WA", [
{
placeId: "ChIJ-other",
description: "Western Avenue, Seattle, WA, USA",
mainText: "Western Avenue",
},
{
placeId: "ChIJ-good",
description: "3131 Western Avenue, Seattle, WA, USA",
mainText: "3131 Western Avenue",
},
]);
expect(picked?.placeId).toBe("ChIJ-good");
});
it("门牌号查询不选泛化 route", () => {
const picked = pickBestSearchCandidate(
"123 Main St, Los Angeles, CA 90001",
[
{
placeId: "ChIJ-good",
description: "123 Main Street, Los Angeles, CA, USA",
mainText: "123 Main Street",
},
{
placeId: "Eh1-route",
description: "Main St, Los Angeles, CA, USA",
mainText: "Main St",
types: ["geocode", "route"],
},
],
);
expect(picked?.placeId).toBe("ChIJ-good");
});
});
describe("buildAxelShipmentPayload", () => {
it("生成 shipment 含 cargo 与 autocompleteResult", () => {
const shipment = buildAxelShipmentPayload({
pickup: { placeId: "ChIJ-pickup", street: "1 Main" },
delivery: { placeId: "ChIJ-delivery", street: "2 Oak" },
pickupDescription: "1 Main St, Seattle, WA",
deliveryDescription: "2 Oak Ave, Miami, FL",
cargo: {
palletCount: 2,
weightLb: 500,
lengthIn: 48,
widthIn: 40,
heightIn: 45,
},
});
expect(shipment.pickupDate).toMatch(/T19:00:00\.000Z$/);
const pickup = shipment.pickupLocation as Record<string, unknown>;
expect(pickup.autocompleteResult).toBeTruthy();
expect(Object.keys(shipment.cargo as object)).toHaveLength(1);
});
});

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatAddressDisplayLabel } from "@/lib/frontend/format-address";
describe("formatAddressDisplayLabel", () => {
it("优先使用 street/city/state 组合,不含邮编", () => {
expect(
formatAddressDisplayLabel({
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001",
}),
).toBe("1234 Warehouse Blvd, Los Angeles, CA");
});
it("从 formatted_address 剥离邮编与 USA", () => {
expect(
formatAddressDisplayLabel({
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
}),
).toBe("5678 Distribution Dr, Dallas, TX");
});
it("MotherShip 确认后展示与弹窗一致的 display_label", () => {
expect(
formatAddressDisplayLabel({
street: "3131 Western Ave",
city: "Seattle",
state: "WA",
selected_from_mothership: true,
mothership_display_label:
"3131 Western Avenue, Seattle, WA, USA",
}),
).toBe("3131 Western Avenue, Seattle, WA");
});
});

@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { executeWithGatewayRetry } from "@/lib/gateway-retry";
describe("lib/gateway-retry", () => {
it("首次 503 第二次 200 返回 200", async () => {
const handler = vi
.fn()
.mockResolvedValueOnce({ status: 503, body: { error: "down" } })
.mockResolvedValueOnce({ status: 200, body: { ok: true } });
const result = await executeWithGatewayRetry(handler);
expect(result.status).toBe(200);
expect(result.body).toEqual({ ok: true });
expect(handler).toHaveBeenCalledTimes(2);
});
it("连续 503 返回 INTERNAL_ERROR", async () => {
const handler = vi.fn().mockResolvedValue({ status: 503, body: null });
const result = await executeWithGatewayRetry(handler);
expect(result.status).toBe(503);
expect(result.body).toEqual({
code: "INTERNAL_ERROR",
message: "服务暂时不可用,请稍后重试",
data: null,
});
expect(handler).toHaveBeenCalledTimes(2);
});
it("首次 200 不重试", async () => {
const handler = vi.fn().mockResolvedValue({ status: 200, body: { ok: true } });
const result = await executeWithGatewayRetry(handler);
expect(result.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
});

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { roundHalfUp } from "@/lib/math";
describe("roundHalfUp", () => {
it("0.105×2 → 0.21(不是 0.20", () => {
expect(roundHalfUp(0.105 * 2, 2)).toBe(0.21);
});
it("32.005 → 32.01", () => {
expect(roundHalfUp(32.005, 2)).toBe(32.01);
});
it("负数边界", () => {
expect(roundHalfUp(-1.005, 2)).toBe(-1.0);
expect(roundHalfUp(-1.006, 2)).toBe(-1.01);
});
it("整数精度", () => {
expect(roundHalfUp(100, 2)).toBe(100);
expect(roundHalfUp(100.004, 2)).toBe(100);
});
});

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { fail, ok } from "@/lib/response";
describe("lib/response", () => {
it("ok 返回 code=0、message=ok、data", async () => {
const res = ok({ quote_id: "Q001" });
const body = await res.json();
expect(res.status).toBe(200);
expect(body).toEqual({
code: 0,
message: "ok",
data: { quote_id: "Q001" },
});
expect(res.headers.get("Cache-Control")).toBe("no-store");
});
it("fail 返回 error code、message、data=null 及 HTTP 状态码", async () => {
const res = fail("VALIDATION_FAILED", "请填写重量", 400);
const body = await res.json();
expect(res.status).toBe(400);
expect(body).toEqual({
code: "VALIDATION_FAILED",
message: "请填写重量",
data: null,
});
expect(res.headers.get("Cache-Control")).toBe("no-store");
});
});

@ -0,0 +1,101 @@
import { describe, expect, it, vi } from "vitest";
import {
assertRpaWorkerEnv,
isForbiddenQuoteUrl,
parseMothershipQuoteUrls,
shouldKeepBrowserOpen,
shouldKeepBrowserWarm,
isParkedSessionEnabled,
getRpaPlaceFixMode,
validateMothershipQuoteUrls,
isWidgetQuoteFallbackEnabled,
} from "@/lib/rpa/env";
describe("lib/rpa/env", () => {
it("parseMothershipQuoteUrls 解析逗号分隔列表", () => {
expect(
parseMothershipQuoteUrls(
"https://example.com/a , https://example.com/b",
),
).toEqual(["https://example.com/a", "https://example.com/b"]);
});
it("isForbiddenQuoteUrl 拦截 dashboard 与 /quote", () => {
expect(isForbiddenQuoteUrl("https://dashboard.mothership.com/")).toBe(true);
expect(isForbiddenQuoteUrl("https://www.mothership.com/quote")).toBe(true);
expect(isForbiddenQuoteUrl("https://ship.mothership.com/rate")).toBe(false);
});
it("validateMothershipQuoteUrls 空列表报错", () => {
expect(validateMothershipQuoteUrls([])).toContain(
"MOTHERSHIP_QUOTE_URLS 未配置或为空",
);
});
it("assertRpaWorkerEnv mock 模式跳过校验", () => {
vi.stubEnv("RPA_MOCK_MODE", "true");
vi.stubEnv("MOTHERSHIP_QUOTE_URLS", "");
expect(() => assertRpaWorkerEnv()).not.toThrow();
vi.unstubAllEnvs();
});
it("assertRpaWorkerEnv 缺 URL 时 fail-fast", () => {
vi.stubEnv("RPA_MOCK_MODE", "false");
vi.stubEnv("MOTHERSHIP_QUOTE_URLS", "");
expect(() => assertRpaWorkerEnv()).toThrow(/MOTHERSHIP_QUOTE_URLS/);
vi.unstubAllEnvs();
});
it("assertRpaWorkerEnv 禁止 dashboard URL", () => {
vi.stubEnv("RPA_MOCK_MODE", "false");
vi.stubEnv(
"MOTHERSHIP_QUOTE_URLS",
"https://dashboard.mothership.com/",
);
expect(() => assertRpaWorkerEnv()).toThrow(/禁止的报价入口 URL/);
vi.unstubAllEnvs();
});
it("shouldKeepBrowserOpen 唯一 config 来源", () => {
vi.stubEnv("RPA_KEEP_BROWSER_OPEN", "true");
expect(shouldKeepBrowserOpen()).toBe(true);
vi.stubEnv("RPA_KEEP_BROWSER_OPEN", "false");
expect(shouldKeepBrowserOpen()).toBe(false);
vi.unstubAllEnvs();
});
it("shouldKeepBrowserWarm headless 默认保活", () => {
vi.stubEnv("RPA_KEEP_BROWSER_WARM", "");
vi.stubEnv("RPA_HEADLESS", "true");
expect(shouldKeepBrowserWarm()).toBe(true);
vi.stubEnv("RPA_KEEP_BROWSER_WARM", "false");
expect(shouldKeepBrowserWarm()).toBe(false);
vi.unstubAllEnvs();
});
it("isParkedSessionEnabled 默认关,显式 true 才开", () => {
vi.stubEnv("RPA_PARKED_SESSION", "");
expect(isParkedSessionEnabled()).toBe(false);
vi.stubEnv("RPA_PARKED_SESSION", "true");
expect(isParkedSessionEnabled()).toBe(true);
vi.unstubAllEnvs();
});
it("getRpaPlaceFixMode 默认 event支持 state/legacy", () => {
vi.stubEnv("RPA_PLACE_FIX_MODE", "");
expect(getRpaPlaceFixMode()).toBe("event");
vi.stubEnv("RPA_PLACE_FIX_MODE", "state");
expect(getRpaPlaceFixMode()).toBe("state");
vi.stubEnv("RPA_PLACE_FIX_MODE", "legacy");
expect(getRpaPlaceFixMode()).toBe("legacy");
vi.unstubAllEnvs();
});
it("isWidgetQuoteFallbackEnabled 默认开RPA_DISABLE_WIDGET_QUOTE_FALLBACK=true 关闭", () => {
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "");
expect(isWidgetQuoteFallbackEnabled()).toBe(true);
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "true");
expect(isWidgetQuoteFallbackEnabled()).toBe(false);
vi.unstubAllEnvs();
});
});

@ -0,0 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import {
humanPacingDelayMs,
RPA_HUMAN_PACING_MAX_MS,
RPA_HUMAN_PACING_MIN_MS,
} from "@/lib/rpa/human-pacing";
describe("human-pacing", () => {
it("默认延迟在 100~300ms", () => {
vi.stubEnv("RPA_HUMAN_PACING", "");
for (let i = 0; i < 20; i += 1) {
const ms = humanPacingDelayMs();
expect(ms).toBeGreaterThanOrEqual(RPA_HUMAN_PACING_MIN_MS);
expect(ms).toBeLessThanOrEqual(RPA_HUMAN_PACING_MAX_MS);
}
vi.unstubAllEnvs();
});
it("RPA_HUMAN_PACING=false 时返回 0", () => {
vi.stubEnv("RPA_HUMAN_PACING", "false");
expect(humanPacingDelayMs()).toBe(0);
vi.unstubAllEnvs();
});
});

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { pageLocator } from "@/lib/rpa/locator";
describe("pageLocator", () => {
it("解析 role= 规格", () => {
const calls: unknown[] = [];
const page = {
getByRole: (role: string, opts?: { name?: string }) => {
calls.push([role, opts]);
return { _type: "locator" };
},
locator: () => ({ _type: "css" }),
getByText: () => ({ _type: "text" }),
getByPlaceholder: () => ({ _type: "ph" }),
} as unknown as Parameters<typeof pageLocator>[0];
pageLocator(page, 'role=button[name="获取货运报价"]');
expect(calls[0]).toEqual(["button", { name: "获取货运报价" }]);
});
});

@ -0,0 +1,95 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const connect = vi.fn().mockResolvedValue(undefined);
const quit = vi.fn().mockResolvedValue(undefined);
const setex = vi.fn().mockResolvedValue("OK");
const get = vi.fn().mockResolvedValue(null);
vi.mock("ioredis", () => ({
default: vi.fn().mockImplementation(() => ({
connect,
quit,
setex,
get,
})),
}));
import {
resolveQuoteSessionStoragePath,
saveQuoteSessionFromContext,
} from "@/lib/rpa/quote-session-store";
describe("quote-session-store", () => {
const prevCwd = process.cwd();
const prevRedisUrl = process.env.REDIS_URL;
let tmpDir = "";
afterEach(() => {
process.chdir(prevCwd);
if (prevRedisUrl === undefined) {
delete process.env.REDIS_URL;
} else {
process.env.REDIS_URL = prevRedisUrl;
}
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = "";
}
vi.clearAllMocks();
});
it("saveQuoteSessionFromContext 写入本地文件并返回 sessionId", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quote-session-"));
process.chdir(tmpDir);
process.env.REDIS_URL = "redis://127.0.0.1:6379";
const storageState = vi.fn().mockImplementation(async ({ path: outPath }: { path: string }) => {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, "{}", "utf8");
});
const sessionId = await saveQuoteSessionFromContext(
{ storageState } as never,
{
pickup: {
street: "123 Main",
city: "LA",
state: "CA",
zip: "90001",
},
delivery: {
street: "456 Oak",
city: "Dallas",
state: "TX",
zip: "75201",
},
},
);
expect(sessionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
expect(
fs.existsSync(path.join(tmpDir, ".rpa", "sessions", `${sessionId}.json`)),
).toBe(true);
expect(setex).toHaveBeenCalled();
});
it("resolveQuoteSessionStoragePath Redis 失败时回退本地文件", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quote-session-"));
process.chdir(tmpDir);
process.env.REDIS_URL = "redis://127.0.0.1:6379";
connect.mockRejectedValueOnce(new Error("redis down"));
const sessionId = "11111111-1111-4111-8111-111111111111";
const sessionsDir = path.join(tmpDir, ".rpa", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
const file = path.join(sessionsDir, `${sessionId}.json`);
fs.writeFileSync(file, "{}", "utf8");
const resolved = await resolveQuoteSessionStoragePath(sessionId);
expect(resolved).toBe(file);
});
});

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
getSelectorSpecs,
parseSelectorSpecs,
} from "@/lib/rpa/selector-specs";
describe("selector-specs", () => {
it("parseSelectorSpecs 支持逗号分隔双语", () => {
expect(parseSelectorSpecs("text=Pick up from,text=从何处取货")).toEqual([
"text=Pick up from",
"text=从何处取货",
]);
});
it("getSelectorSpecs 合并 env 与内置回退", () => {
process.env.RPA_SELECTOR_PICKUP_SECTION = "text=Custom pickup";
const specs = getSelectorSpecs("RPA_SELECTOR_PICKUP_SECTION");
expect(specs[0]).toBe("text=Custom pickup");
expect(specs.some((s) => s.includes("Pick up from"))).toBe(true);
delete process.env.RPA_SELECTOR_PICKUP_SECTION;
});
it("英文 street selector 在内置回退中", () => {
const specs = getSelectorSpecs("RPA_SELECTOR_PICKUP_STREET");
expect(specs.some((s) => s.includes("Search address"))).toBe(true);
});
});

@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { assertRpaSelectorsEnv, getSelector } from "@/lib/rpa/selectors";
const REQUIRED = {
RPA_SELECTOR_PICKUP_STREET: "role=textbox",
RPA_SELECTOR_DELIVERY_STREET: "role=textbox",
RPA_SELECTOR_ADDRESS_SUGGESTION: ".pac-item",
RPA_SELECTOR_SUBMIT: "role=button",
RPA_SELECTOR_QUOTE_WIDGET: "#react-quoter-landing-plugin",
} as const;
describe("assertRpaSelectorsEnv", () => {
it("FASTEST 留空时仍可通过启动校验", () => {
vi.stubEnv("RPA_MOCK_MODE", "false");
for (const [key, value] of Object.entries(REQUIRED)) {
vi.stubEnv(key, value);
}
vi.stubEnv("RPA_SELECTOR_FASTEST_OPTION", "");
expect(() => assertRpaSelectorsEnv()).not.toThrow();
vi.unstubAllEnvs();
});
it("env 为空时使用内置双语回退", () => {
vi.stubEnv("RPA_MOCK_MODE", "false");
for (const key of Object.keys(REQUIRED)) {
vi.stubEnv(key, "");
}
vi.stubEnv("RPA_SELECTOR_FASTEST_OPTION", "");
expect(() => assertRpaSelectorsEnv()).not.toThrow();
vi.unstubAllEnvs();
});
it("RPA_SELECTOR_QUOTE_WIDGET 显式空串时 getSelector 走内置回退,禁止空 selector", () => {
vi.stubEnv("RPA_MOCK_MODE", "false");
for (const [key, value] of Object.entries(REQUIRED)) {
vi.stubEnv(key, value);
}
vi.stubEnv("RPA_SELECTOR_QUOTE_WIDGET", "");
expect(getSelector("RPA_SELECTOR_QUOTE_WIDGET")).toBe(
"#react-quoter-landing-plugin",
);
vi.unstubAllEnvs();
});
});

@ -0,0 +1,41 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("storage-state", () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
vi.resetModules();
});
it("invalidateStorageState 删除 state 文件", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-state-"));
const stateFile = path.join(tmpDir, "state.json");
fs.writeFileSync(stateFile, "{}");
process.env.RPA_STORAGE_STATE_PATH = stateFile;
const { invalidateStorageState, storageStateExists } = await import(
"@/lib/rpa/storage-state"
);
expect(storageStateExists()).toBe(true);
invalidateStorageState();
expect(storageStateExists()).toBe(false);
});
it("getStorageStateForContext 存在时返回 storageState", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-state-"));
const stateFile = path.join(tmpDir, "state.json");
fs.writeFileSync(stateFile, '{"cookies":[]}');
process.env.RPA_STORAGE_STATE_PATH = stateFile;
const { getStorageStateForContext } = await import(
"@/lib/rpa/storage-state"
);
expect(getStorageStateForContext()).toEqual({ storageState: stateFile });
});
});

@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/workers/rpa/worker-state", () => ({
listWorkerHeartbeats: vi.fn(),
}));
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
import { listWorkerHeartbeats } from "@/workers/rpa/worker-state";
describe("isAnyRpaWorkerHealthy", () => {
it("存在近期心跳时返回 true", async () => {
vi.mocked(listWorkerHeartbeats).mockResolvedValue([
{ workerId: "w1", lastSeenMs: Date.now() - 5_000 },
]);
await expect(isAnyRpaWorkerHealthy()).resolves.toBe(true);
});
it("无心跳或 Redis 异常时返回 false", async () => {
vi.mocked(listWorkerHeartbeats).mockResolvedValue([]);
await expect(isAnyRpaWorkerHealthy()).resolves.toBe(false);
vi.mocked(listWorkerHeartbeats).mockRejectedValue(new Error("redis down"));
await expect(isAnyRpaWorkerHealthy()).resolves.toBe(false);
});
});

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { listMockMothershipCandidates } from "@/modules/address/candidate-catalog";
describe("listMockMothershipCandidates", () => {
it("Dallas 75201 返回 4 个 MotherShip 精确坐标", () => {
const list = listMockMothershipCandidates({
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
});
expect(list).toHaveLength(4);
expect(list.map((c) => c.display_label)).toContain(
"5678 Distribution Dr, Dallas, TX 75201",
);
});
it("唯一候选时长度为 1", () => {
const list = listMockMothershipCandidates({
street: "99 Unknown Rd",
city: "Austin",
state: "TX",
zip: "78701",
});
expect(list).toHaveLength(1);
});
});

@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/rpa/worker-health", () => ({
isAnyRpaWorkerHealthy: vi.fn(),
}));
vi.mock("@/lib/rpa/spawn-address-candidates", () => ({
spawnFetchMothershipAddressCandidates: vi.fn(),
}));
vi.mock("@/workers/rpa/queue", () => ({
addAddressCandidatesJob: vi.fn(),
}));
const redisGet = vi.fn();
vi.mock("@/lib/redis", () => ({
ensureRedisReady: vi.fn().mockResolvedValue(undefined),
getRedis: vi.fn(() => ({
setex: vi.fn().mockResolvedValue("OK"),
get: redisGet,
})),
resetRedisClient: vi.fn(),
}));
import { spawnFetchMothershipAddressCandidates } from "@/lib/rpa/spawn-address-candidates";
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
import { requestMothershipCandidatesViaWorker } from "@/modules/address/candidates-queue";
import { addAddressCandidatesJob } from "@/workers/rpa/queue";
const input = {
pickup: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
delivery: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
};
const mockResult = {
pickup_candidates: [{ display_label: "A", option_id: "1" }],
delivery_candidates: [{ display_label: "B", option_id: "2" }],
requires_selection: false,
};
describe("requestMothershipCandidatesViaWorker", () => {
afterEach(() => {
vi.clearAllMocks();
redisGet.mockReset();
delete process.env.RPA_QUEUE_ENABLED;
});
it("Worker 无心跳时默认走 spawn", async () => {
delete process.env.RPA_PARKED_SESSION;
vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(false);
vi.mocked(spawnFetchMothershipAddressCandidates).mockResolvedValue(
mockResult,
);
const result = await requestMothershipCandidatesViaWorker(input);
expect(result).toEqual(mockResult);
expect(spawnFetchMothershipAddressCandidates).toHaveBeenCalledWith(input);
});
it("Worker 无心跳且 RPA_PARKED_SESSION=true 时抛错", async () => {
process.env.RPA_QUEUE_ENABLED = "true";
process.env.RPA_PARKED_SESSION = "true";
vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(false);
await expect(requestMothershipCandidatesViaWorker(input)).rejects.toMatchObject(
{ code: "PAGE_LOAD_TIMEOUT" },
);
expect(spawnFetchMothershipAddressCandidates).not.toHaveBeenCalled();
});
it("Worker 有心跳时只走队列等待结果,不 spawn", async () => {
process.env.RPA_QUEUE_ENABLED = "true";
vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(true);
vi.mocked(addAddressCandidatesJob).mockResolvedValue(undefined);
redisGet
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
JSON.stringify({ ok: true, data: mockResult }),
);
const result = await requestMothershipCandidatesViaWorker(input);
expect(result).toEqual(mockResult);
expect(addAddressCandidatesJob).toHaveBeenCalled();
expect(spawnFetchMothershipAddressCandidates).not.toHaveBeenCalled();
});
});

@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/axel/candidates", () => ({
resolveAxelMothershipCandidates: vi.fn(),
}));
import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates";
import { resolveAxelMothershipCandidates } from "@/lib/axel/candidates";
describe("resolveMothershipCandidates", () => {
const input = {
pickup: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
delivery: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
};
it("非 mock 模式统一走 axel search", async () => {
process.env.RPA_MOCK_MODE = "false";
vi.mocked(resolveAxelMothershipCandidates).mockResolvedValue({
pickup_candidates: [
{
option_id: "ChIJpickup",
display_label: "5678 Distribution Dr, Dallas, TX 75201",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201",
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
],
delivery_candidates: [
{
option_id: "ChIJdelivery",
display_label: "1234 Warehouse Blvd, Los Angeles, CA 90001",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001",
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
],
});
const result = await resolveMothershipCandidates(input);
expect(resolveAxelMothershipCandidates).toHaveBeenCalledWith(input);
expect(result.pickup_candidates[0].option_id).toBe("ChIJpickup");
});
});

@ -0,0 +1,114 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const prismaMock = vi.hoisted(() => ({
quoteCacheMeta: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
alertLog: {
create: vi.fn(),
},
}));
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
import { detectDeviation } from "@/modules/alert/deviation-detector";
import type { RawQuoteTier } from "@/modules/pricing/engine";
const fourTiers = (): RawQuoteTier[] => [
{
service_level: "standard",
rate_option: "lowest",
carrier: "A",
transit_days: "3",
transit_description: "3d",
raw_freight: 320,
surcharges: 0,
raw_total: 320,
},
{
service_level: "standard",
rate_option: "fastest",
carrier: "A",
transit_days: "2",
transit_description: "2d",
raw_freight: 380,
surcharges: 0,
raw_total: 380,
},
{
service_level: "guaranteed",
rate_option: "lowest",
carrier: "B",
transit_days: "3",
transit_description: "3d",
raw_freight: 420,
surcharges: 0,
raw_total: 420,
},
{
service_level: "guaranteed",
rate_option: "fastest",
carrier: "B",
transit_days: "2",
transit_description: "2d",
raw_freight: 480,
surcharges: 0,
raw_total: 480,
},
];
describe("detectDeviation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("TC-212 首次询价不触发偏差预警", async () => {
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue(null);
prismaMock.quoteCacheMeta.create.mockResolvedValue({});
await detectDeviation("hash1", fourTiers(), "QTE001");
expect(prismaMock.alertLog.create).not.toHaveBeenCalled();
expect(prismaMock.quoteCacheMeta.create).toHaveBeenCalled();
});
it("TC-210 偏差≥5% 写 PRICE_DEVIATION", async () => {
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({
cargoHash: "hash1",
rawTotalStandard: 352,
});
prismaMock.quoteCacheMeta.update.mockResolvedValue({});
prismaMock.alertLog.create.mockResolvedValue({});
const quotes = fourTiers();
quotes[0].raw_total = 370;
quotes[0].raw_freight = 370;
await detectDeviation("hash1", quotes, "QTE002");
expect(prismaMock.alertLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ alertType: "PRICE_DEVIATION" }),
}),
);
});
it("TC-211 偏差4.9% 不触发", async () => {
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({
cargoHash: "hash1",
rawTotalStandard: 352,
});
prismaMock.quoteCacheMeta.update.mockResolvedValue({});
const quotes = fourTiers();
quotes[0].raw_total = 369.25;
quotes[0].raw_freight = 369.25;
await detectDeviation("hash1", quotes, "QTE003");
expect(prismaMock.alertLog.create).not.toHaveBeenCalled();
expect(prismaMock.quoteCacheMeta.update).toHaveBeenCalled();
});
});

@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const prismaMock = vi.hoisted(() => ({
alertLog: {
create: vi.fn(),
},
}));
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
import { writeAlert } from "@/modules/alert/service";
describe("writeAlert", () => {
beforeEach(() => {
vi.clearAllMocks();
prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) });
});
it("写入 alert_log 成功", async () => {
await writeAlert("RPA_FAILED", {
quoteId: "QTE_001",
cargoHash: "abc123",
detail: { reason: "timeout" },
});
expect(prismaMock.alertLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
alertType: "RPA_FAILED",
quoteId: "QTE_001",
cargoHash: "abc123",
status: "open",
}),
});
});
it("DB 写失败不抛异常", async () => {
prismaMock.alertLog.create.mockRejectedValue(new Error("db down"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
writeAlert("SECURITY", { detail: { reason: "test" } }),
).resolves.toBeUndefined();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});

@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const prismaMock = vi.hoisted(() => ({
auditLog: { create: vi.fn() },
alertLog: { create: vi.fn() },
}));
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
import { recordSecurityEvent, writeAudit } from "@/modules/audit/service";
describe("writeAudit", () => {
beforeEach(() => {
vi.clearAllMocks();
prismaMock.auditLog.create.mockResolvedValue({ id: BigInt(1) });
prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) });
});
it("写入 audit_log 成功", async () => {
await writeAudit("FORBIDDEN_ACCESS", "CUST_001", "QTE_B", {
owner: "CUST_B",
});
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "FORBIDDEN_ACCESS",
actorId: "CUST_001",
resource: "QTE_B",
}),
});
});
it("DB 写失败不抛异常", async () => {
prismaMock.auditLog.create.mockRejectedValue(new Error("db down"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
writeAudit("TEST", "actor", "resource"),
).resolves.toBeUndefined();
consoleSpy.mockRestore();
});
});
describe("recordSecurityEvent", () => {
beforeEach(() => {
vi.clearAllMocks();
prismaMock.auditLog.create.mockResolvedValue({ id: BigInt(1) });
prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) });
});
it("同时写 audit + SECURITY 预警", async () => {
await recordSecurityEvent(
"SECURITY_VALIDATION_BLOCKED",
"CUST_001",
"POST /api/quotes",
{ message: "injection" },
);
expect(prismaMock.auditLog.create).toHaveBeenCalled();
expect(prismaMock.alertLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ alertType: "SECURITY" }),
});
});
});

@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
decodeToken,
signToken,
verifyToken,
} from "@/modules/auth/jwt";
describe("modules/auth/jwt", () => {
beforeEach(() => {
process.env.JWT_SECRET = "test-jwt-secret-for-unit-tests";
});
it("签发后校验通过", async () => {
const token = await signToken({ sub: "ADMIN_001", role: "admin" });
const payload = await verifyToken(token);
expect(payload).toEqual({ sub: "ADMIN_001", role: "admin" });
});
it("篡改 token 后校验失败", async () => {
const token = await signToken({ sub: "ADMIN_001", role: "admin" });
const tampered = `${token.slice(0, -4)}xxxx`;
await expect(verifyToken(tampered)).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("过期 token 校验失败", async () => {
const { SignJWT } = await import("jose");
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const expired = await new SignJWT({ role: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("ADMIN_001")
.setIssuedAt(Math.floor(Date.now() / 1000) - 7200)
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600)
.sign(secret);
await expect(verifyToken(expired)).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
it("decodeToken 可解析有效载荷", async () => {
const token = await signToken({ sub: "ADMIN_001", role: "admin" });
expect(decodeToken(token)).toEqual({ sub: "ADMIN_001", role: "admin" });
});
});

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { requirePermission } from "@/modules/auth/rbac";
describe("modules/auth/rbac", () => {
const adminAuth = {
type: "admin" as const,
userId: "ADMIN_001",
role: "admin" as const,
};
const hostAuth = {
type: "service" as const,
customerId: "CUST_001",
permissions: ["pricing:markup:write" as const],
};
it("admin + alert:read 通过", () => {
expect(() => requirePermission(adminAuth, "alert:read")).not.toThrow();
});
it("admin + rpa:operate 通过", () => {
expect(() => requirePermission(adminAuth, "rpa:operate")).not.toThrow();
});
it("宿主 token + alert:read 拒绝", () => {
expect(() => requirePermission(hostAuth, "alert:read")).toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
it("宿主 token + pricing:markup:write 通过", () => {
expect(() =>
requirePermission(hostAuth, "pricing:markup:write"),
).not.toThrow();
});
});

@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
resetServiceTokenCache,
verifyServiceToken,
} from "@/modules/auth/service-token";
const DEMO_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
describe("modules/auth/service-token", () => {
beforeEach(() => {
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
resetServiceTokenCache();
});
afterEach(() => {
delete process.env.HOST_SERVICE_TOKENS;
resetServiceTokenCache();
});
it("正确 token + 匹配 customer_id 通过", () => {
const result = verifyServiceToken("demo-host-token", "CUST_001");
expect(result.customerId).toBe("CUST_001");
expect(result.permissions).toContain("pricing:markup:write");
});
it("customer_id 不匹配返回 403", () => {
expect(() => verifyServiceToken("demo-host-token", "CUST_002")).toThrow(
expect.objectContaining({ code: "FORBIDDEN" }),
);
});
it("无效 token 返回 401", () => {
expect(() => verifyServiceToken("invalid-token", "CUST_001")).toThrow(
expect.objectContaining({ code: "UNAUTHORIZED" }),
);
});
});

@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
L1_TTL_SECONDS,
L2_TTL_BASE_SECONDS,
L2_TTL_JITTER_MAX_SECONDS,
L3_TTL_SECONDS,
LOCK_TTL_SECONDS,
} from "@/lib/constants/cache";
type RedisMock = {
store: Map<string, { value: string; expireAt?: number }>;
get: ReturnType<typeof vi.fn>;
set: ReturnType<typeof vi.fn>;
del: ReturnType<typeof vi.fn>;
exists: ReturnType<typeof vi.fn>;
incr: ReturnType<typeof vi.fn>;
expire: ReturnType<typeof vi.fn>;
};
function createRedisMock(): RedisMock {
const store = new Map<string, { value: string; expireAt?: number }>();
const isExpired = (key: string): boolean => {
const entry = store.get(key);
if (!entry?.expireAt) {
return false;
}
return Date.now() > entry.expireAt;
};
return {
store,
get: vi.fn(async (key: string) => {
if (isExpired(key)) {
store.delete(key);
return null;
}
return store.get(key)?.value ?? null;
}),
set: vi.fn(
async (
key: string,
value: string,
...args: (string | number)[]
): Promise<string | null> => {
const nx = args.includes("NX");
if (nx && store.has(key) && !isExpired(key)) {
return null;
}
let ttlSeconds: number | undefined;
const exIndex = args.indexOf("EX");
if (exIndex >= 0) {
ttlSeconds = Number(args[exIndex + 1]);
}
store.set(key, {
value,
expireAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined,
});
return "OK";
},
),
del: vi.fn(async (key: string) => {
store.delete(key);
return 1;
}),
exists: vi.fn(async (key: string) => {
if (isExpired(key)) {
store.delete(key);
return 0;
}
return store.has(key) ? 1 : 0;
}),
incr: vi.fn(async (key: string) => {
const current = store.get(key);
const next = current ? Number(current.value) + 1 : 1;
store.set(key, { value: String(next) });
return next;
}),
expire: vi.fn(async (key: string, seconds: number) => {
const entry = store.get(key);
if (!entry) {
return 0;
}
entry.expireAt = Date.now() + seconds * 1000;
return 1;
}),
};
}
let redisMock: RedisMock;
vi.mock("@/lib/redis", () => ({
getRedis: () => redisMock,
}));
describe("modules/cache/redis-cache", () => {
beforeEach(async () => {
redisMock = createRedisMock();
vi.resetModules();
});
async function loadCache() {
return import("@/modules/cache/redis-cache");
}
it("L1 setL1 SETNX 不覆盖 + TTL 86400", async () => {
const cache = await loadCache();
const payload = { quote_id: "Q1", status: "done" };
expect(await cache.setL1("req-1", payload)).toBe(true);
expect(await cache.setL1("req-1", { quote_id: "Q2" })).toBe(false);
expect(await cache.getL1("req-1")).toEqual(payload);
const [, , , ttl] = redisMock.set.mock.calls[0];
expect(ttl).toBe(L1_TTL_SECONDS);
});
it("L2 TTL 在 180~210s 之间", async () => {
const cache = await loadCache();
const ttls = new Set<number>();
for (let i = 0; i < 20; i++) {
redisMock.store.clear();
const ttl = await cache.setL2("hash-1", { quotes: [] });
ttls.add(ttl);
}
for (const ttl of ttls) {
expect(ttl).toBeGreaterThanOrEqual(L2_TTL_BASE_SECONDS);
expect(ttl).toBeLessThanOrEqual(
L2_TTL_BASE_SECONDS + L2_TTL_JITTER_MAX_SECONDS,
);
}
});
it("L3 TTL 1800s", async () => {
const cache = await loadCache();
await cache.setL3("hash-1", { quotes: [] });
const [, , , ttl] = redisMock.set.mock.calls[0];
expect(ttl).toBe(L3_TTL_SECONDS);
});
it("acquireLock SETNX TTL 5s", async () => {
const cache = await loadCache();
expect(await cache.acquireLock("hash-1")).toBe(true);
expect(await cache.acquireLock("hash-1")).toBe(false);
const [, , , ttl] = redisMock.set.mock.calls[0];
expect(ttl).toBe(LOCK_TTL_SECONDS);
});
});
describe("modules/cache/rate-limiter", () => {
beforeEach(async () => {
redisMock = createRedisMock();
vi.resetModules();
});
it("60 次通过,第 61 次 RATE_LIMITED", async () => {
const { checkRateLimit } = await import("@/modules/cache/rate-limiter");
for (let i = 0; i < 60; i++) {
expect(await checkRateLimit("CUST_001")).toEqual({ allowed: true });
}
expect(await checkRateLimit("CUST_001")).toEqual({
allowed: false,
errorCode: "RATE_LIMITED",
});
});
});
describe("modules/cache/ip-rate-limiter", () => {
beforeEach(async () => {
redisMock = createRedisMock();
vi.resetModules();
});
it("超 1000 次封禁 IP", async () => {
const { checkIpRateLimit } = await import(
"@/modules/cache/ip-rate-limiter"
);
for (let i = 0; i < 1000; i++) {
expect(await checkIpRateLimit("1.2.3.4")).toEqual({ allowed: true });
}
const blocked = await checkIpRateLimit("1.2.3.4");
expect(blocked).toEqual({
allowed: false,
errorCode: "RATE_LIMITED",
banned: true,
});
expect(await checkIpRateLimit("1.2.3.4")).toEqual({
allowed: false,
errorCode: "RATE_LIMITED",
banned: true,
});
});
});
describe("modules/cache/circuit-breaker", () => {
beforeEach(async () => {
redisMock = createRedisMock();
vi.resetModules();
});
it("连续 3 次失败熔断", async () => {
const breaker = await import("@/modules/cache/circuit-breaker");
expect(await breaker.isCircuitOpen()).toBe(false);
await breaker.recordRpaFailure();
await breaker.recordRpaFailure();
expect(await breaker.isCircuitOpen()).toBe(false);
await breaker.recordRpaFailure();
expect(await breaker.isCircuitOpen()).toBe(true);
});
it("closeCircuit 清除熔断状态", async () => {
const breaker = await import("@/modules/cache/circuit-breaker");
await breaker.openCircuit();
expect(await breaker.isCircuitOpen()).toBe(true);
await breaker.closeCircuit();
expect(await breaker.isCircuitOpen()).toBe(false);
});
});

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { METRIC_COUNTER } from "@/lib/constants/metrics";
import {
computeP95,
computeRate,
computeRates,
type MetricsCounters,
} from "@/modules/metrics/collector";
function emptyCounters(): MetricsCounters {
return {
[METRIC_COUNTER.POST_TOTAL]: 0,
[METRIC_COUNTER.POST_DONE]: 0,
[METRIC_COUNTER.CACHE_HIT_L1]: 0,
[METRIC_COUNTER.CACHE_HIT_L2]: 0,
[METRIC_COUNTER.RPA_TRIGGERED]: 0,
[METRIC_COUNTER.RPA_DONE]: 0,
[METRIC_COUNTER.DONE_TOTAL]: 0,
[METRIC_COUNTER.DONE_REALTIME]: 0,
};
}
describe("computeRates (GD-4)", () => {
it("100 次 mock80 done / 100 post → api 80%", () => {
const counters: MetricsCounters = {
...emptyCounters(),
[METRIC_COUNTER.POST_TOTAL]: 100,
[METRIC_COUNTER.POST_DONE]: 80,
};
expect(computeRates(counters).api_success_rate).toBe(80);
});
it("RPA 85/100 → rpa_success_rate 85%", () => {
const counters: MetricsCounters = {
...emptyCounters(),
[METRIC_COUNTER.RPA_TRIGGERED]: 100,
[METRIC_COUNTER.RPA_DONE]: 85,
};
expect(computeRates(counters).rpa_success_rate).toBe(85);
});
it("realtime 90/100 done → realtime_rate 90%", () => {
const counters: MetricsCounters = {
...emptyCounters(),
[METRIC_COUNTER.DONE_TOTAL]: 100,
[METRIC_COUNTER.DONE_REALTIME]: 90,
};
expect(computeRates(counters).realtime_rate).toBe(90);
});
it("cache (L1+L2) 90/100 post → cache_hit_rate 90%", () => {
const counters: MetricsCounters = {
...emptyCounters(),
[METRIC_COUNTER.POST_TOTAL]: 100,
[METRIC_COUNTER.CACHE_HIT_L1]: 40,
[METRIC_COUNTER.CACHE_HIT_L2]: 50,
};
expect(computeRates(counters).cache_hit_rate).toBe(90);
});
it("分母为 0 → 返回 0", () => {
expect(computeRate(10, 0)).toBe(0);
expect(computeRates(emptyCounters()).api_success_rate).toBe(0);
});
});
describe("computeP95", () => {
it("100 样本 P95 取第 95 百分位", () => {
const samples = Array.from({ length: 100 }, (_, index) => index + 1);
expect(computeP95(samples)).toBe(95);
});
it("空样本 → 0", () => {
expect(computeP95([])).toBe(0);
});
});

@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
applyMarkup,
applyMarkupToTier,
getMarkupPercent,
} from "@/modules/pricing/engine";
vi.mock("@/lib/prisma", () => ({
prisma: {
markupConfig: {
findFirst: vi.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
const mockedFindFirst = vi.mocked(prisma.markupConfig.findFirst);
describe("applyMarkup", () => {
it("TC-103raw_freight=320surcharges=15markup=10% → 32.00/367.00", () => {
const rawTotal = 320 + 15;
const result = applyMarkup(320, rawTotal, 10);
expect(result.markupAmount).toBe(32.0);
expect(result.finalTotal).toBe(367.0);
});
it("raw_freight=380markup=10% → 38.00/418.00", () => {
const result = applyMarkup(380, 380, 10);
expect(result.markupAmount).toBe(38.0);
expect(result.finalTotal).toBe(418.0);
});
it("surcharge=0 仅对 raw_freight 加价", () => {
const result = applyMarkup(320, 320, 10);
expect(result.markupAmount).toBe(32.0);
expect(result.finalTotal).toBe(352.0);
});
it("0% 加价", () => {
const result = applyMarkup(320, 335, 0);
expect(result.markupAmount).toBe(0);
expect(result.finalTotal).toBe(335);
});
it("30% 边界", () => {
const result = applyMarkup(100, 100, 30);
expect(result.markupAmount).toBe(30);
expect(result.finalTotal).toBe(130);
});
it("固定金额加价", () => {
const result = applyMarkup(320, 335, {
type: "fixed",
percent: 0,
fixedAmount: 50,
});
expect(result.markupAmount).toBe(50);
expect(result.finalTotal).toBe(385);
});
});
describe("applyMarkupToTier", () => {
it("生成 breakdown 三项", () => {
const tier = applyMarkupToTier(
{
service_level: "standard",
rate_option: "lowest",
carrier: "Mothership",
transit_days: "5-7",
transit_description: "最低价格",
raw_freight: 320,
surcharges: 15,
raw_total: 335,
},
10,
);
expect(tier.breakdown).toHaveLength(3);
expect(tier.final_total).toBe(367);
});
});
describe("getMarkupPercent", () => {
beforeEach(() => {
mockedFindFirst.mockReset();
});
it("未配置 customer_id → 0.0", async () => {
mockedFindFirst.mockResolvedValue(null);
expect(await getMarkupPercent("CUST_UNKNOWN")).toBe(0);
});
it("已配置 10.0% → 10.0", async () => {
mockedFindFirst.mockResolvedValue({
markupType: "percent",
markupPercent: 10.0,
markupFixedAmount: null,
} as never);
expect(await getMarkupPercent("CUST_002")).toBe(10);
});
});

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseMarkupInput } from "@/modules/pricing/markup-validation";
describe("parseMarkupInput", () => {
it("默认百分比模式", () => {
const result = parseMarkupInput({ markup_percent: 10 });
expect(result).toEqual({
markupType: "percent",
markupPercent: 10,
markupFixedAmount: null,
});
});
it("固定金额模式", () => {
const result = parseMarkupInput({
markup_type: "fixed",
markup_fixed_amount: 50.5,
});
expect(result).toEqual({
markupType: "fixed",
markupPercent: 0,
markupFixedAmount: 50.5,
});
});
it("比例超过 30% 拒绝", () => {
const result = parseMarkupInput({ markup_percent: 30.1 });
expect(result).toMatchObject({
code: "PERCENT_TOO_HIGH",
message: "加价比例不能超过 30%",
});
});
});

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
buildCargoHash,
serializeAddress,
} from "@/modules/quote/cargo-hash";
const baseInput = {
pickupSerialized: serializeAddress({
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
}),
deliverySerialized: serializeAddress({
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
}),
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
};
describe("buildCargoHash", () => {
it("相同入参 → 相同 hash", () => {
const h1 = buildCargoHash(baseInput);
const h2 = buildCargoHash(baseInput);
expect(h1).toBe(h2);
expect(h1).toHaveLength(32);
});
it("place_id 不同 → hash 不同", () => {
const h1 = buildCargoHash(baseInput);
const h2 = buildCargoHash({
...baseInput,
pickupSerialized: serializeAddress({
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_other",
}),
});
expect(h1).not.toBe(h2);
});
it("service_level 不在 hash 中cargo_type 相同则 hash 相同)", () => {
const h1 = buildCargoHash(baseInput);
const h2 = buildCargoHash({ ...baseInput, cargoType: "general_freight" });
expect(h1).toBe(h2);
});
});

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getConfidenceScore } from "@/modules/quote/confidence";
describe("getConfidenceScore", () => {
it("source=rpa → 0.95", () => {
expect(getConfidenceScore("rpa")).toBe(0.95);
});
it("source=cache → 0.95", () => {
expect(getConfidenceScore("cache")).toBe(0.95);
});
it("source=stale → 0.70", () => {
expect(getConfidenceScore("stale")).toBe(0.7);
});
});

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
shouldConsumeRpaRetry,
} from "@/modules/quote/fallback-orchestrator";
import { RpaError } from "@/modules/rpa/errors";
describe("fallback entry errors (task-129)", () => {
it("QUOTE_ENTRY_UNAVAILABLE 不消耗重试配额", () => {
expect(
shouldConsumeRpaRetry(
new RpaError("QUOTE_ENTRY_UNAVAILABLE", "入口不可用", {
retryable: false,
}),
),
).toBe(false);
});
it("ADDRESS_SUGGESTION_NOT_FOUND 不消耗重试配额", () => {
expect(
shouldConsumeRpaRetry(
new RpaError("ADDRESS_SUGGESTION_NOT_FOUND", "联想失败", {
retryable: false,
}),
),
).toBe(false);
});
it("PAGE_LOAD_TIMEOUT 仍可重试", () => {
expect(
shouldConsumeRpaRetry(
new RpaError("PAGE_LOAD_TIMEOUT", "超时"),
),
).toBe(true);
});
});

@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleRpaFailure } from "@/modules/quote/fallback-orchestrator";
vi.mock("@/modules/cache/redis-cache", () => ({
getL3: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: { update: vi.fn() },
alertLog: { create: vi.fn() },
},
}));
vi.mock("@/modules/pricing/engine", () => ({
getMarkupPercent: vi.fn().mockResolvedValue(10),
applyMarkupToQuotes: vi.fn((quotes: unknown[]) =>
quotes.map((q) => ({ ...(q as object), markup_percent: 10 })),
),
}));
import { getL3 } from "@/modules/cache/redis-cache";
import { prisma } from "@/lib/prisma";
const mockedGetL3 = vi.mocked(getL3);
const mockedUpdate = vi.mocked(prisma.quoteRecord.update);
const mockedAlertCreate = vi.mocked(prisma.alertLog.create);
const FOUR_TIERS = [
{ service_level: "standard", rate_option: "lowest", raw_freight: 320, surcharges: 0, raw_total: 320, carrier: "M", transit_days: "5", transit_description: "x" },
{ service_level: "standard", rate_option: "fastest", raw_freight: 365, surcharges: 0, raw_total: 365, carrier: "M", transit_days: "3", transit_description: "x" },
{ service_level: "guaranteed", rate_option: "lowest", raw_freight: 380, surcharges: 0, raw_total: 380, carrier: "M", transit_days: "4", transit_description: "x" },
{ service_level: "guaranteed", rate_option: "fastest", raw_freight: 410, surcharges: 0, raw_total: 410, carrier: "M", transit_days: "3", transit_description: "x" },
];
describe("handleRpaFailure", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUpdate.mockResolvedValue({} as never);
mockedAlertCreate.mockResolvedValue({} as never);
});
it("TC-205L3 命中 → done stale + STALE_FALLBACK", async () => {
mockedGetL3.mockResolvedValue({ quotes: FOUR_TIERS });
const result = await handleRpaFailure(
"QTE_20260616_0001",
"abc123",
"CUST_001",
new Error("captcha"),
);
expect(result.status).toBe("done");
if (result.status === "done") {
expect(result.sourceType).toBe("stale");
expect(result.isRealtime).toBe(false);
}
expect(mockedAlertCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ alertType: "STALE_FALLBACK" }),
}),
);
});
it("TC-206无 L3 → failed QUOTE_UNAVAILABLE + RPA_FAILED", async () => {
mockedGetL3.mockResolvedValue(null);
const result = await handleRpaFailure(
"QTE_20260616_0001",
"abc123",
"CUST_001",
new Error("timeout"),
);
expect(result).toEqual({
status: "failed",
errorCode: "QUOTE_UNAVAILABLE",
errorMessage: "timeout",
});
expect(mockedAlertCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ alertType: "RPA_FAILED" }),
}),
);
});
});

@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
checkIdempotency,
saveIdempotency,
} from "@/modules/quote/idempotency";
vi.mock("@/modules/cache/redis-cache", () => ({
getL1: vi.fn(),
setL1: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
idempotencyRecord: {
findUnique: vi.fn(),
create: vi.fn(),
},
quoteRecord: {
findUnique: vi.fn(),
},
},
}));
import { getL1, setL1 } from "@/modules/cache/redis-cache";
import { prisma } from "@/lib/prisma";
const mockedGetL1 = vi.mocked(getL1);
const mockedSetL1 = vi.mocked(setL1);
const mockedFindUnique = vi.mocked(prisma.idempotencyRecord.findUnique);
const mockedCreate = vi.mocked(prisma.idempotencyRecord.create);
const mockedQuoteFind = vi.mocked(prisma.quoteRecord.findUnique);
describe("checkIdempotency", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("L1 命中 → 返回 quote_id", async () => {
mockedGetL1.mockResolvedValue({
quoteId: "QTE_20260616_0001",
response: { quote_id: "QTE_20260616_0001", status: "done" },
});
const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000");
expect(result.hit).toBe(true);
if (result.hit) {
expect(result.quoteId).toBe("QTE_20260616_0001");
}
});
it("L1 未命中、DB 命中 → 回填 L1", async () => {
mockedGetL1.mockResolvedValue(null);
mockedFindUnique.mockResolvedValue({
quoteId: "QTE_20260616_0002",
} as never);
mockedQuoteFind.mockResolvedValue({
quoteId: "QTE_20260616_0002",
status: "done",
} as never);
const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000");
expect(result.hit).toBe(true);
expect(mockedSetL1).toHaveBeenCalled();
});
it("均未命中 → hit=false", async () => {
mockedGetL1.mockResolvedValue(null);
mockedFindUnique.mockResolvedValue(null);
const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000");
expect(result.hit).toBe(false);
});
});
describe("saveIdempotency", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedCreate.mockResolvedValue({} as never);
mockedSetL1.mockResolvedValue(true);
});
it("写入 DB + Redis", async () => {
await saveIdempotency(
"550e8400-e29b-41d4-a716-446655440000",
"CUST_001",
"QTE_20260616_0001",
{ quote_id: "QTE_20260616_0001", status: "done" },
);
expect(mockedCreate).toHaveBeenCalled();
expect(mockedSetL1).toHaveBeenCalled();
});
it("P2002 冲突 → 静默返回", async () => {
mockedCreate.mockRejectedValue({ code: "P2002" });
mockedFindUnique.mockResolvedValue({
quoteId: "QTE_20260616_0001",
} as never);
await expect(
saveIdempotency(
"550e8400-e29b-41d4-a716-446655440000",
"CUST_001",
"QTE_20260616_0001",
{},
),
).resolves.toBeUndefined();
});
});

@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { submitQuote } from "@/modules/quote/orchestrator";
vi.mock("@/modules/cache/redis-cache", () => ({
getL2: vi.fn(),
}));
vi.mock("@/modules/quote/idempotency", () => ({
checkIdempotency: vi.fn(),
saveIdempotency: vi.fn(),
}));
vi.mock("@/modules/quote/quote-id", () => ({
generateQuoteId: vi.fn(),
handleQuoteIdConflict: vi.fn(),
isQuoteIdUniqueConflict: vi.fn(),
}));
vi.mock("@/modules/quote/rpa-queue", () => ({
enqueueQuoteJob: vi.fn(),
}));
vi.mock("@/modules/pricing/engine", () => ({
getMarkupPercent: vi.fn().mockResolvedValue(0),
applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes),
}));
vi.mock("@/modules/metrics/collector", () => ({
safeRecord: vi.fn(),
recordPostTotal: vi.fn().mockResolvedValue(undefined),
recordCacheHit: vi.fn().mockResolvedValue(undefined),
recordPostDone: vi.fn().mockResolvedValue(undefined),
recordRpaTriggered: vi.fn().mockResolvedValue(undefined),
markQuoteStarted: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: { create: vi.fn() },
},
}));
import { getL2 } from "@/modules/cache/redis-cache";
import { checkIdempotency, saveIdempotency } from "@/modules/quote/idempotency";
import { generateQuoteId } from "@/modules/quote/quote-id";
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
import { prisma } from "@/lib/prisma";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
const VALID_BODY = {
request_id: "550e8400-e29b-41d4-a716-446655440000",
customer_id: "CUST_001",
pickup_address: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
delivery_address: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weight: { value: 500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
pallet_count: 2,
cargo_type: "general_freight",
};
const FOUR_TIERS = [
{ service_level: "standard", rate_option: "lowest", raw_freight: 320, surcharges: 0, raw_total: 320, carrier: "M", transit_days: "5", transit_description: "x" },
{ service_level: "standard", rate_option: "fastest", raw_freight: 365, surcharges: 0, raw_total: 365, carrier: "M", transit_days: "3", transit_description: "x" },
{ service_level: "guaranteed", rate_option: "lowest", raw_freight: 380, surcharges: 0, raw_total: 380, carrier: "M", transit_days: "4", transit_description: "x" },
{ service_level: "guaranteed", rate_option: "fastest", raw_freight: 410, surcharges: 0, raw_total: 410, carrier: "M", transit_days: "3", transit_description: "x" },
];
const mockedCheckIdem = vi.mocked(checkIdempotency);
const mockedGetL2 = vi.mocked(getL2);
const mockedGenerateId = vi.mocked(generateQuoteId);
const mockedCreate = vi.mocked(prisma.quoteRecord.create);
const mockedSaveIdem = vi.mocked(saveIdempotency);
const mockedEnqueue = vi.mocked(enqueueQuoteJob);
describe("submitQuote", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedCheckIdem.mockResolvedValue({ hit: false });
mockedGetL2.mockResolvedValue(null);
mockedGenerateId.mockResolvedValue("QTE_20260616_0001");
mockedCreate.mockResolvedValue({} as never);
mockedSaveIdem.mockResolvedValue(undefined);
mockedEnqueue.mockResolvedValue(undefined);
});
it("TC-105L1 命中 → 返回原 quote_id", async () => {
mockedCheckIdem.mockResolvedValue({
hit: true,
quoteId: "QTE_20260616_0001",
response: { quote_id: "QTE_20260616_0001", status: "done" },
});
const result = await submitQuote(VALID_BODY);
expect(result.quote_id).toBe("QTE_20260616_0001");
expect(result.status).toBe("done");
expect(mockedCreate).not.toHaveBeenCalled();
});
it("TC-104L2 命中 → done + source=cache", async () => {
mockedGetL2.mockResolvedValue({ quotes: FOUR_TIERS });
const result = await submitQuote(VALID_BODY);
expect(result.status).toBe("done");
expect(result.source_type).toBe("cache");
expect(mockedEnqueue).not.toHaveBeenCalled();
});
it("首次询价 → processing + 入队", async () => {
const result = await submitQuote(VALID_BODY);
expect(result.status).toBe("processing");
expect(mockedEnqueue).toHaveBeenCalled();
});
});

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
assertFourTiers,
normalizeToFourTiers,
} from "@/modules/quote/quote-completeness";
const FOUR_TIERS = [
{ service_level: "standard", rate_option: "lowest" },
{ service_level: "standard", rate_option: "fastest" },
{ service_level: "guaranteed", rate_option: "lowest" },
{ service_level: "guaranteed", rate_option: "fastest" },
];
describe("assertFourTiers", () => {
it("4 档 → 通过", () => {
expect(() => assertFourTiers(FOUR_TIERS)).not.toThrow();
});
it("仅 2 档 lowest → normalize 后通过", () => {
expect(() =>
assertFourTiers([
{ service_level: "standard", rate_option: "lowest" },
{ service_level: "guaranteed", rate_option: "lowest" },
]),
).not.toThrow();
});
it("缺 guaranteed 整档 → 仅含 standard/lowest 仍通过", () => {
expect(() =>
assertFourTiers(
FOUR_TIERS.filter((t) => t.service_level !== "guaranteed"),
),
).not.toThrow();
});
it("有 guaranteed/lowest 无 fastest → 通过", () => {
const incomplete = FOUR_TIERS.filter(
(t) => !(t.service_level === "guaranteed" && t.rate_option === "fastest"),
);
expect(() => assertFourTiers(incomplete)).not.toThrow();
});
it("含 bestValue 扩展档 → 保留 API 返回的全部档位", () => {
const six = [
...FOUR_TIERS,
{ service_level: "standard", rate_option: "bestValue" },
{ service_level: "guaranteed", rate_option: "bestValue" },
];
const normalized = normalizeToFourTiers(six);
expect(normalized).toHaveLength(6);
expect(() => assertFourTiers(six)).not.toThrow();
});
it("2+1 动态档位全部保留", () => {
const tiers = [
{ service_level: "standard", rate_option: "lowest" },
{ service_level: "standard", rate_option: "fastest" },
{ service_level: "guaranteed", rate_option: "lowest" },
];
expect(normalizeToFourTiers(tiers)).toHaveLength(3);
});
it("3+3 动态档位全部保留", () => {
const tiers = [
{ service_level: "standard", rate_option: "lowest" },
{ service_level: "standard", rate_option: "fastest" },
{ service_level: "standard", rate_option: "bestValue" },
{ service_level: "guaranteed", rate_option: "lowest" },
{ service_level: "guaranteed", rate_option: "fastest" },
{ service_level: "guaranteed", rate_option: "bestValue" },
];
expect(normalizeToFourTiers(tiers)).toHaveLength(6);
});
});

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { RpaError } from "@/modules/rpa/errors";
import {
formatQuoteErrorMessage,
resolveQuoteFailureCode,
resolveQuoteFailureMessage,
} from "@/modules/quote/quote-error-messages";
describe("quote-error-messages", () => {
it("RpaError 保留细分 error_code", () => {
expect(
resolveQuoteFailureCode(
new RpaError("ADDRESS_SUGGESTION_NOT_FOUND", "未找到地址"),
),
).toBe("ADDRESS_SUGGESTION_NOT_FOUND");
});
it("RpaError 使用原始 message 作为用户文案", () => {
const msg = resolveQuoteFailureMessage(
new RpaError("STRUCT_CHANGE", "login 页且无 Mothership 凭据"),
"QUOTE_ENTRY_UNAVAILABLE",
);
expect(msg).toBe("login 页且无 Mothership 凭据");
});
it("formatQuoteErrorMessage 优先 storedMessage", () => {
expect(
formatQuoteErrorMessage("QUOTE_UNAVAILABLE", "自定义原因"),
).toBe("自定义原因");
});
it("formatQuoteErrorMessage 按 code 回退", () => {
expect(formatQuoteErrorMessage("QUOTE_TIMEOUT")).toContain("420 秒");
});
});

@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
generateQuoteId,
handleQuoteIdConflict,
isQuoteIdUniqueConflict,
} from "@/modules/quote/quote-id";
import { QuoteIdConflictError } from "@/modules/quote/types";
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
findFirst: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
const mockedFindFirst = vi.mocked(prisma.quoteRecord.findFirst);
const mockedAuditCreate = vi.mocked(prisma.auditLog.create);
describe("generateQuoteId", () => {
beforeEach(() => {
mockedFindFirst.mockReset();
});
it("首日生成 _0001", async () => {
mockedFindFirst.mockResolvedValue(null);
const id = await generateQuoteId();
expect(id).toMatch(/^QTE_\d{8}_0001$/);
});
it("递增序号", async () => {
mockedFindFirst.mockResolvedValue({
quoteId: "QTE_20260616_0005",
} as never);
const id = await generateQuoteId();
expect(id).toMatch(/_0006$/);
});
});
describe("isQuoteIdUniqueConflict", () => {
it("P2002 quote_id → true", () => {
expect(
isQuoteIdUniqueConflict({
code: "P2002",
meta: { target: ["quote_id"] },
}),
).toBe(true);
});
it("其他错误 → false", () => {
expect(isQuoteIdUniqueConflict({ code: "P2002", meta: { target: ["email"] } })).toBe(false);
});
});
describe("handleQuoteIdConflict", () => {
it("写 audit 并抛 QuoteIdConflictError", async () => {
mockedAuditCreate.mockResolvedValue({} as never);
await expect(
handleQuoteIdConflict("QTE_20260616_0001", "CUST_001"),
).rejects.toThrow(QuoteIdConflictError);
expect(mockedAuditCreate).toHaveBeenCalled();
});
});

@ -0,0 +1,109 @@
import { Prisma } from "@prisma/client";
import type { QuoteRecord } from "@prisma/client";
import { describe, expect, it } from "vitest";
import {
serializeQuoteDetail,
serializeQuoteSummary,
} from "@/modules/quote/quote-serializer";
const BASE_RECORD: QuoteRecord = {
id: BigInt(1),
quoteId: "QTE_20260616_0001",
requestId: "550e8400-e29b-41d4-a716-446655440000",
customerId: "CUST_001",
cargoHash: "abc123",
status: "done",
sourceType: "cache",
isRealtime: true,
confidenceScore: null,
currency: "USD",
pickupJson: {},
deliveryJson: {},
weightLb: new Prisma.Decimal(500),
dimLIn: new Prisma.Decimal(48),
dimWIn: new Prisma.Decimal(40),
dimHIn: new Prisma.Decimal(48),
palletCount: 2,
cargoType: "general_freight",
quotesJson: [{ service_level: "standard" }],
markupPercent: new Prisma.Decimal(0),
validUntil: new Date(Date.now() + 60_000),
errorCode: null,
errorMessage: null,
ccCustomerId: null,
forecastId: null,
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
};
describe("serializeQuoteDetail", () => {
it("done + cache → confidence_score=0.95 + quotes", () => {
const result = serializeQuoteDetail(BASE_RECORD);
expect(result.status).toBe("done");
expect(result.confidence_score).toBe(0.95);
expect(result.source_type).toBe("cache");
expect(result.quotes).toHaveLength(1);
});
it("stale → confidence_score=0.70", () => {
const result = serializeQuoteDetail({
...BASE_RECORD,
sourceType: "stale",
isRealtime: false,
});
expect(result.confidence_score).toBe(0.7);
expect(result.is_realtime).toBe(false);
});
it("valid_until 过期 → status=expired不含 quotes", () => {
const result = serializeQuoteDetail({
...BASE_RECORD,
validUntil: new Date(Date.now() - 1_000),
});
expect(result.status).toBe("expired");
expect(result.quotes).toBeUndefined();
});
it("processing → 仅基础字段", () => {
const result = serializeQuoteDetail({
...BASE_RECORD,
status: "processing",
quotesJson: null,
validUntil: null,
});
expect(result.status).toBe("processing");
expect(result.quotes).toBeUndefined();
});
it("failed → error_code + error_message", () => {
const result = serializeQuoteDetail({
...BASE_RECORD,
status: "failed",
errorCode: "QUOTE_TIMEOUT",
errorMessage: null,
});
expect(result.status).toBe("failed");
expect(result.error_code).toBe("QUOTE_TIMEOUT");
expect(result.error_message).toContain("420 秒");
});
it("failed → 优先返回库内 error_message", () => {
const result = serializeQuoteDetail({
...BASE_RECORD,
status: "failed",
errorCode: "QUOTE_UNAVAILABLE",
errorMessage: "MotherShip 页面加载失败",
});
expect(result.error_message).toBe("MotherShip 页面加载失败");
});
});
describe("serializeQuoteSummary", () => {
it("历史摘要含关键字段", () => {
const result = serializeQuoteSummary(BASE_RECORD);
expect(result.quote_id).toBe("QTE_20260616_0001");
expect(result.pallet_count).toBe(2);
expect(result.created_at).toBeTruthy();
});
});

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { cmToIn, kgToLb } from "@/modules/quote/unit-converter";
describe("unit-converter", () => {
it("227kg → 500.45lbROUND_HALF_UPJS 浮点 227×2.20462", () => {
expect(kgToLb(227)).toBe(500.45);
});
it("122cm → 48.03in", () => {
expect(cmToIn(122)).toBe(48.03);
});
it("500lb 不变", () => {
expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0);
});
});

@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import {
MOTHERSHIP_LIMITS,
validateQuoteInput,
rejectQuantityField,
} from "@/modules/quote/validation";
import { ValidationError } from "@/modules/quote/types";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
const VALID_BODY = {
request_id: "550e8400-e29b-41d4-a716-446655440000",
customer_id: "CUST_001",
pickup_address: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
delivery_address: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weight: { value: 500, unit: "lb" },
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
pallet_count: 2,
cargo_type: "general_freight",
};
describe("validateQuoteInput", () => {
it("正常入参通过", () => {
const result = validateQuoteInput(VALID_BODY);
expect(result.cargoHash).toHaveLength(32);
expect(result.weightLb).toBe(500);
});
it("TC-201weight 缺失 → VALIDATION_FAILED", () => {
const { weight: _w, ...rest } = VALID_BODY;
expect(() => validateQuoteInput(rest)).toThrow(ValidationError);
});
it("TC-202weight=0 → 超范围", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
weight: { value: 0, unit: "lb" },
}),
).toThrow(/超出 Mothership 允许范围/);
});
it("空邮编通过MotherShip 不要求填写邮编)", () => {
const result = validateQuoteInput({
...VALID_BODY,
pickup_address: { ...VALID_BODY.pickup_address, zip: "" },
delivery_address: { ...VALID_BODY.delivery_address, zip: "" },
});
expect(result.cargoHash).toHaveLength(32);
});
it("TC-203zip 4 位 → 无效", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
pickup_address: {
...VALID_BODY.pickup_address,
zip: "9001",
},
}),
).toThrow(/邮编/);
});
it("TC-204request_id 非 UUID → 无效", () => {
expect(() =>
validateQuoteInput({ ...VALID_BODY, request_id: "not-uuid" }),
).toThrow("请求标识无效");
});
it("E4.3selected_from_suggestions=false", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
pickup_address: {
...VALID_BODY.pickup_address,
selected_from_suggestions: false,
},
}),
).toThrow(ValidationError);
});
it("未确认 MotherShip 精确地址 → 拒绝询价", () => {
const { mothership_option_id: _a, mothership_display_label: _b, selected_from_mothership: _c, ...pickup } =
VALID_BODY.pickup_address;
expect(() =>
validateQuoteInput({
...VALID_BODY,
pickup_address: pickup,
}),
).toThrow(ValidationError);
});
it("E4.7quantity 字段拒绝", () => {
expect(() =>
rejectQuantityField({ ...VALID_BODY, quantity: 5 }),
).toThrow("请使用托盘数 pallet_count");
});
it("E4.6pallet_count 超 25", () => {
expect(() =>
validateQuoteInput({ ...VALID_BODY, pallet_count: 26 }),
).toThrow(/托盘数超出/);
});
it("E4.6:单托重量超 9999", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
weight: { value: 10_000, unit: "lb" },
}),
).toThrow(/重量超出/);
});
it("E4.6:长度超 999 in", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
dimensions: { length: 1000, width: 40, height: 48, unit: "in" },
}),
).toThrow(/尺寸超出/);
});
it("边界25 托、9999 lb、999×99×99 in 通过", () => {
const result = validateQuoteInput({
...VALID_BODY,
weight: { value: 9_999, unit: "lb" },
dimensions: { length: 999, width: 99, height: 99, unit: "in" },
pallet_count: 25,
});
expect(result.palletCount).toBe(25);
});
it("SQL 注入拦截", () => {
expect(() =>
validateQuoteInput({
...VALID_BODY,
pickup_address: {
...VALID_BODY.pickup_address,
street: "'; DROP TABLE users; --",
},
}),
).toThrow(/无效,请检查/);
});
it("kg/cm 换算", () => {
const result = validateQuoteInput({
...VALID_BODY,
weight: { value: 227, unit: "kg" },
dimensions: { length: 120, width: 100, height: 100, unit: "cm" },
});
expect(result.weightLb).toBe(500.45);
expect(result.dimLIn).toBe(47.24);
});
});
describe("MOTHERSHIP_LIMITS", () => {
it("托盘 1-25", () => {
expect(MOTHERSHIP_LIMITS.palletCount.max).toBe(25);
});
});

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
mapPageTextToErrorCode,
mapUrlToErrorCode,
} from "@/modules/rpa/error-mapper";
describe("error-mapper", () => {
it("无运力页 → CARRIER_NO_CAPACITY", () => {
expect(mapPageTextToErrorCode("No carriers available for this lane")).toBe(
"CARRIER_NO_CAPACITY",
);
});
it("不支持邮编 → ADDRESS_NOT_SUPPORTED", () => {
expect(mapPageTextToErrorCode("This zip code is not supported")).toBe(
"ADDRESS_NOT_SUPPORTED",
);
});
it("验证码 URL → RPA_CAPTCHA", () => {
expect(mapUrlToErrorCode("https://example.com/captcha")).toBe(
"RPA_CAPTCHA",
);
});
it("login URL 无凭据 → null由 adapter 判 STRUCT_CHANGE", () => {
delete process.env.MOTHERSHIP_EMAIL;
delete process.env.MOTHERSHIP_PASSWORD;
expect(mapUrlToErrorCode("https://example.com/login")).toBe(null);
});
it("login URL 有凭据 → SESSION_EXPIRED", () => {
process.env.MOTHERSHIP_EMAIL = "test@example.com";
process.env.MOTHERSHIP_PASSWORD = "secret";
expect(mapUrlToErrorCode("https://example.com/login")).toBe(
"SESSION_EXPIRED",
);
});
});

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
applyMarkup,
applyMarkupToQuotes,
type RawQuoteTier,
} from "@/modules/pricing/engine";
const RAW_TIERS: RawQuoteTier[] = [
{
service_level: "standard",
rate_option: "lowest",
carrier: "Mothership",
transit_days: "5-7",
transit_description: "最低价格",
raw_freight: 320,
surcharges: 15,
raw_total: 335,
},
{
service_level: "standard",
rate_option: "fastest",
carrier: "Mothership",
transit_days: "3-4",
transit_description: "最快",
raw_freight: 365,
surcharges: 0,
raw_total: 365,
},
{
service_level: "guaranteed",
rate_option: "lowest",
carrier: "Mothership",
transit_days: "4-5",
transit_description: "保价最低",
raw_freight: 380,
surcharges: 0,
raw_total: 380,
},
{
service_level: "guaranteed",
rate_option: "fastest",
carrier: "Mothership",
transit_days: "2-3",
transit_description: "保价最快",
raw_freight: 410,
surcharges: 0,
raw_total: 410,
},
];
describe("TC-403 加价可复算task-114", () => {
it("从 DB raw_freight + markup 重算 final_total 误差 ≤0.01", () => {
const markupPercent = 10;
const marked = applyMarkupToQuotes(RAW_TIERS, markupPercent);
for (let i = 0; i < RAW_TIERS.length; i++) {
const raw = RAW_TIERS[i];
const expected = applyMarkup(
raw.raw_freight,
raw.raw_total,
markupPercent,
);
const actual = marked[i];
expect(Math.abs(actual.final_total - expected.finalTotal)).toBeLessThanOrEqual(
0.01,
);
expect(Math.abs(actual.markup_amount - expected.markupAmount)).toBeLessThanOrEqual(
0.01,
);
}
});
it("TC-103 精确到分markup_amount=32.00 final_total=367.00", () => {
const result = applyMarkup(320, 335, 10);
expect(result.markupAmount).toBe(32);
expect(result.finalTotal).toBe(367);
});
});

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { createRunPaths } from "@/scripts/human-commit-baseline/paths";
describe("human-commit-baseline paths", () => {
it("为 runId 生成完整产物路径", () => {
const p = createRunPaths("abc12345");
expect(p.root).toContain("human-commit-baseline");
expect(p.root).toContain("abc12345");
expect(p.har).toContain("baseline.har");
expect(p.trace).toContain("trace.zip");
expect(p.triggerAfterPickup).toContain("after-pickup");
});
});

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
diffHumanRpaNetwork,
summarizeNetwork,
type NetworkEvent,
} from "@/scripts/human-commit-baseline/network-diff";
const humanSample: NetworkEvent[] = [
{
ts: 100,
iso: "",
phase: "page_loaded",
kind: "request",
method: "POST",
url: "https://services.mothership.com/axel/location/search",
isLocationSearch: true,
},
{
ts: 200,
iso: "",
phase: "page_loaded",
kind: "response",
method: "POST",
url: "https://services.mothership.com/axel/location/search",
status: 200,
isLocationSearch: true,
},
{
ts: 900,
iso: "",
phase: "page_loaded",
kind: "request",
method: "GET",
url: "https://services.mothership.com/axel/location/place/ChIJabc",
isPlaceCommit: true,
},
{
ts: 1100,
iso: "",
phase: "page_loaded",
kind: "response",
method: "GET",
url: "https://services.mothership.com/axel/location/place/ChIJabc",
status: 200,
isPlaceCommit: true,
},
];
const rpaMissingPlace: NetworkEvent[] = [
{
ts: 100,
iso: "",
phase: "rpa_pickup",
kind: "request",
method: "POST",
url: "https://services.mothership.com/axel/location/search",
isLocationSearch: true,
},
{
ts: 200,
iso: "",
phase: "rpa_pickup",
kind: "response",
method: "POST",
url: "https://services.mothership.com/axel/location/search",
status: 200,
isLocationSearch: true,
},
];
describe("network-diff", () => {
it("真人 sample 识别 search→place 完整链", () => {
const s = summarizeNetwork(humanSample, "human");
expect(s.placeGet200).toBe(1);
expect(s.chains[0]?.complete).toBe(true);
expect(s.chains[0]?.gapSearchResponseToPlaceRequestMs).toBe(700);
});
it("RPA 缺 place 时 verdict=RPA_MISSING_PLACE", () => {
const humanFull = [...humanSample, ...humanSample.map((e, i) => ({ ...e, ts: e.ts + 2000 + i }))];
const d = diffHumanRpaNetwork(humanFull, rpaMissingPlace, "human1", "rpa1");
expect(d.verdict).toBe("RPA_MISSING_PLACE");
expect(d.gaps.some((g) => g.includes("place"))).toBe(true);
});
});

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
diffAxelTraces,
normalizeAxelSequence,
type AxelTraceEvent,
} from "../../../scripts/lib/axel-event-tracer";
describe("axel-event-tracer", () => {
it("diff 识别 RPA 缺失 place commit", () => {
const manual: AxelTraceEvent[] = [
{
at: 1,
phase: "manual-pickup",
method: "POST",
url: "https://services.mothership.com/axel/location/search",
status: 200,
kind: "search",
searchPlaceIds: ["ChIJ_manual"],
},
{
at: 2,
phase: "manual-pickup",
method: "GET",
url: "https://services.mothership.com/axel/location/place/ChIJ_manual",
status: 200,
kind: "place",
placeId: "ChIJ_manual",
},
];
const rpa: AxelTraceEvent[] = [
{
at: 1,
phase: "rpa-fillAddress",
method: "POST",
url: "https://services.mothership.com/axel/location/search",
status: 200,
kind: "search",
searchPlaceIds: ["ChIJ_manual"],
},
];
const diff = diffAxelTraces(manual, rpa);
expect(diff.verdict).toBe("RPA_MISSING_PLACE_COMMIT");
expect(diff.manualPlaceCommits).toEqual(["ChIJ_manual"]);
expect(diff.rpaPlaceCommits).toEqual([]);
expect(normalizeAxelSequence(manual).some((l) => l.includes("GET place/ChIJ_manual"))).toBe(
true,
);
});
});

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
countAxelQuoteTiers,
extractAxelQuoteContract,
} from "@/scripts/phase0/lib/axel-quote-contract";
import { pickBestQuoteEntry, scoreQuoteBody } from "@/scripts/phase0/lib/har-parse";
import { buildQuoteContractSchema } from "@/scripts/phase0/lib/schema-infer";
const AXEL_SAMPLE = {
data: {
rates: {
standard: {
lowest: { price: 100, days: 5, serviceType: "lowest" },
fastest: { price: 200, days: 2, serviceType: "fastest" },
bestValue: { price: 150, days: 3, serviceType: "bestValue" },
},
guaranteed: {
bestValue: { price: 250, days: 2, serviceType: "bestValue" },
},
},
availableRates: {
rate_a: { price: 100, serviceLevel: "standard", serviceType: "lowest" },
rate_b: { price: 200, serviceLevel: "standard", serviceType: "fastest" },
rate_c: { price: 110, serviceLevel: "guaranteed", serviceType: "lowest" },
rate_d: { price: 220, serviceLevel: "guaranteed", serviceType: "fastest" },
},
},
};
describe("phase0 axel-quote-contract", () => {
it("识别 data.rates 四档与 availableRates", () => {
const c = extractAxelQuoteContract(AXEL_SAMPLE);
expect(c.fourTierCoverage.standardLowest).toBe(true);
expect(c.fourTierCoverage.standardFastest).toBe(true);
expect(c.availableRateCount).toBe(4);
expect(countAxelQuoteTiers(AXEL_SAMPLE).pricedLeafCount).toBeGreaterThanOrEqual(4);
});
});
describe("phase0 har-parse", () => {
it("pickBestQuoteEntry 识别 axel/quote 响应", () => {
const har = {
log: {
entries: [
{
request: {
url: "https://services.mothership.com/axel/quote",
method: "POST",
},
response: {
status: 200,
content: {
mimeType: "application/json",
text: JSON.stringify(AXEL_SAMPLE),
},
},
},
],
},
};
const picked = pickBestQuoteEntry(har.log!.entries!);
expect(picked).not.toBeNull();
expect(scoreQuoteBody(picked!.body)).toBeGreaterThanOrEqual(5);
});
});
describe("phase0 schema-infer", () => {
it("buildQuoteContractSchema 产出含 body 与 sourceHar", () => {
const doc = buildQuoteContractSchema({
body: AXEL_SAMPLE,
harEntry: {
url: "https://services.mothership.com/axel/quote",
method: "POST",
status: 200,
},
});
expect(doc.$schema).toContain("json-schema.org");
expect(doc.sourceHar.url).toContain("axel/quote");
expect(doc.body.type).toBe("object");
});
});

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { pickBestMothershipCandidate } from "@/scripts/lib/pick-mothership-candidate";
describe("pickBestMothershipCandidate", () => {
it("优先匹配门牌号与城市,惩罚 Bridge 歧义", () => {
const picked = pickBestMothershipCandidate(
[
{
option_id: "ChIJ-bridge",
display_label: "1234 Market Street Bridge, Philadelphia, PA, USA",
formatted_address: "1234 Market Street Bridge, Philadelphia, PA, USA",
street: "1234 Market Street Bridge",
city: "Philadelphia",
state: "PA",
zip: "",
},
{
option_id: "ChIJ-good",
display_label: "1234 Market Street, Philadelphia, PA, USA",
formatted_address: "1234 Market Street, Philadelphia, PA, USA",
street: "1234 Market Street",
city: "Philadelphia",
state: "PA",
zip: "",
},
],
{ street: "1234 Market Street", city: "Philadelphia", state: "PA" },
);
expect(picked?.option_id).toBe("ChIJ-good");
});
});

@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.sh");
describe("record-mothership.sh (task-121)", () => {
const content = readFileSync(SCRIPT_PATH, "utf8");
it("存在且包含 headed 与 codegen 启动", () => {
expect(content).toContain("RPA_HEADLESS=false");
expect(content).toContain("playwright");
expect(content).toContain("codegen");
});
it("禁止 GD-15 黑名单 URL", () => {
expect(content).toContain("dashboard.mothership.com");
expect(content).toContain("www.mothership.com/quote");
expect(content).toContain("check_url");
});
it("产出录制脚本与 storageState 路径", () => {
expect(content).toContain(".rpa/recordings");
expect(content).toContain("mothership-storage.json");
expect(content).toContain("--save-storage");
});
});
const TS_SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.ts");
describe("record-mothership.ts (Windows 跨平台)", () => {
const content = readFileSync(TS_SCRIPT_PATH, "utf8");
it("存在且包含 headed codegen 与 GD-15 校验", () => {
expect(content).toContain('RPA_HEADLESS: "false"');
expect(content).toContain("playwright");
expect(content).toContain("codegen");
expect(content).toContain("isForbiddenQuoteUrl");
});
it("产出录制脚本与 storageState 路径", () => {
expect(content).toContain(".rpa/recordings");
expect(content).toContain("mothership-storage.json");
expect(content).toContain("--save-storage");
});
});

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { buildNetworkDiagnostics } from "@/workers/rpa/address-adapter/diagnostics";
describe("address-adapter diagnostics", () => {
it("buildNetworkDiagnostics 要求 place 200 才算 httpOk", () => {
const diag = buildNetworkDiagnostics(
{
getObservedPlaceCommitIds: () => ["ChIJ_pickup", "ChIJ_delivery"],
getPlaceNetworkLog: () => [
{
placeId: "ChIJ_delivery",
status: 200,
url: "https://services.mothership.com/axel/location/place/ChIJ_delivery",
at: 1,
},
],
hasPlaceCommit: (id) => id === "ChIJ_delivery",
hasSuccessfulPlaceNetworkRequest: (id) => id === "ChIJ_delivery",
},
"ChIJ_delivery",
"ChIJ_pickup",
);
expect(diag.placeRequestHttpOk).toBe(true);
expect(diag.pickupLostAfterDelivery).toBe(false);
expect(diag.placeNetworkEvents).toHaveLength(1);
});
it("buildNetworkDiagnostics 不在 bridge 层判定 pickupLost改由 widget DOM", () => {
const diag = buildNetworkDiagnostics(
{
getObservedPlaceCommitIds: () => ["ChIJ_delivery"],
getPlaceNetworkLog: () => [
{
placeId: "ChIJ_delivery",
status: 200,
url: "",
at: 1,
},
],
hasPlaceCommit: () => true,
hasSuccessfulPlaceNetworkRequest: () => true,
},
"ChIJ_delivery",
"ChIJ_pickup",
);
expect(diag.pickupLostAfterDelivery).toBe(false);
});
});

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { AddressCommitStateMachine } from "@/workers/rpa/address-adapter/commit-state-machine";
import { formatDiagnosticsFailure } from "@/workers/rpa/address-adapter/diagnostics";
import { getRpaAddressMode } from "@/lib/rpa/address-mode";
describe("AddressCommitStateMachine", () => {
it("COMMITTED 可进入下游", () => {
const sm = new AddressCommitStateMachine();
sm.transition("COMMITTED");
expect(sm.canProceedToCargo()).toBe(true);
expect(() => sm.assertCommittedForDownstream("pickup")).not.toThrow();
});
it("FAILED 禁止进入 cargo", () => {
const sm = new AddressCommitStateMachine();
sm.transition("FAILED");
expect(sm.canProceedToCargo()).toBe(false);
expect(() => sm.assertCommittedForDownstream("pickup")).toThrow(/COMMITTED/);
});
});
describe("formatDiagnosticsFailure", () => {
it("区分 no_place_request", () => {
const msg = formatDiagnosticsFailure({
address: "123 Main",
placeId: "ChIJ_x",
method: "keyboard",
events: [],
placeRequestObserved: false,
status: "no_place_request",
commitState: "FAILED",
});
expect(msg).toContain("未发起 GET place 请求");
expect(msg).toContain("selection event");
});
});
describe("getRpaAddressMode", () => {
it("默认 real", () => {
const prev = process.env.RPA_ADDRESS_MODE;
delete process.env.RPA_ADDRESS_MODE;
expect(getRpaAddressMode()).toBe("real");
process.env.RPA_ADDRESS_MODE = prev;
});
it("mock 显式启用", () => {
const prev = process.env.RPA_ADDRESS_MODE;
process.env.RPA_ADDRESS_MODE = "mock";
expect(getRpaAddressMode()).toBe("mock");
process.env.RPA_ADDRESS_MODE = prev;
});
});

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import type { QuoteRequest } from "@/modules/providers/quote-provider";
import {
evaluateAddressesVisuallyComplete,
widgetContainsConfirmedAddress,
} from "@/workers/rpa/address-commit";
function basePickup(overrides?: Partial<QuoteRequest["pickup"]>) {
return {
street: "1234 Warehouse Street",
city: "Farmers Branch",
state: "TX",
zip: "75234",
placeId: "p1",
formattedAddress: "1234 Warehouse Street Farmers Branch TX",
selectedFromSuggestions: true,
mothershipOptionId: "o1",
mothershipDisplayLabel: "1234 Warehouse Road Farmers Branch TX",
selectedFromMothership: true,
...overrides,
};
}
describe("widgetContainsConfirmedAddress", () => {
it("匹配门牌号 + 州", () => {
const text =
"1234 Warehouse Road Farmers Branch, TX 5678 Distribution Drive Fort Worth, TX";
expect(widgetContainsConfirmedAddress(text, basePickup())).toBe(true);
});
it("错误 street 不匹配", () => {
const text = "999 Wrong Street Fort Worth, TX";
expect(widgetContainsConfirmedAddress(text, basePickup())).toBe(false);
});
it("多词城市须完整匹配West Seattle", () => {
const address = basePickup({
street: "3131 Western Avenue",
city: "West Seattle",
state: "WA",
mothershipDisplayLabel: "3131 Western AvenueWest Seattle, WA",
});
expect(
widgetContainsConfirmedAddress(
"3131 Western Avenue West Seattle, WA",
address,
),
).toBe(true);
expect(
widgetContainsConfirmedAddress(
"3131 Western Avenue, Seattle, WA, USA",
address,
),
).toBe(false);
expect(
widgetContainsConfirmedAddress("3131 Western Ave Seattle, WA", address),
).toBe(false);
});
it("确认标签无门牌号时不强制 widget 含用户输入门牌号", () => {
const address = basePickup({
street: "5678 Distribution Way",
city: "Farmers Branch",
state: "TX",
mothershipDisplayLabel: "Distribution Way, Farmers Branch, TX, USA",
});
const widget =
"1234 Warehouse Street Los Angeles, CA Distribution Way Farmers Branch, TX";
expect(widgetContainsConfirmedAddress(widget, address)).toBe(true);
});
});
describe("evaluateAddressesVisuallyComplete", () => {
it("截图态:双州码 + Freight details 视为地址完成", () => {
const text =
"Seattle, WA 293 East Pike Street Seattle, WA Pelican Hotel Miami Beach, FL Freight details Get a freight quote";
expect(evaluateAddressesVisuallyComplete(text)).toBe(true);
});
it("仅单地址不算完成", () => {
expect(evaluateAddressesVisuallyComplete("293 East Pike Street Seattle, WA")).toBe(
false,
);
});
});

@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import type { Locator, Page } from "playwright";
import { visibleStreetInput } from "@/workers/rpa/address-side";
function mockTextbox(isEditable: boolean, visible = true): Locator {
const loc = {
isVisible: vi.fn().mockResolvedValue(visible),
isEditable: vi.fn().mockResolvedValue(isEditable),
isDisabled: vi.fn().mockResolvedValue(false),
getAttribute: vi.fn().mockResolvedValue(null),
inputValue: vi.fn().mockResolvedValue(""),
} as unknown as Locator;
return loc;
}
function mockPageWithTextboxes(boxes: Locator[]): Page {
const widget = {
getByRole: vi.fn().mockReturnValue({
count: vi.fn().mockResolvedValue(boxes.length),
nth: (i: number) => boxes[i],
}),
};
return {
locator: vi.fn().mockReturnValue({
first: () => widget,
}),
} as unknown as Page;
}
describe("visibleStreetInput", () => {
it("派送:仅 1 个 textbox 时返回 null强制先点送货至", async () => {
const page = mockPageWithTextboxes([mockTextbox(false)]);
const result = await visibleStreetInput(page, "delivery");
expect(result).toBeNull();
});
it("派送:分区已打开且仅 1 个含提货文案的 textbox 时返回 null", async () => {
const pickupBox = mockTextbox(true);
(pickupBox as { inputValue: ReturnType<typeof vi.fn> }).inputValue = vi
.fn()
.mockResolvedValue("1234 Warehouse Street, Los Angeles, CA, USA");
const page = mockPageWithTextboxes([pickupBox]);
const result = await visibleStreetInput(page, "delivery", {
deliverySectionOpened: true,
});
expect(result).toBeNull();
});
it("派送:分区已打开时取最后一个可编辑框", async () => {
const page = mockPageWithTextboxes([
mockTextbox(false),
mockTextbox(true),
]);
const result = await visibleStreetInput(page, "delivery", {
deliverySectionOpened: true,
});
expect(result).toBeTruthy();
});
it("提货:单 textbox 可编辑时直接返回", async () => {
const page = mockPageWithTextboxes([mockTextbox(true)]);
const result = await visibleStreetInput(page, "pickup");
expect(result).toBeTruthy();
});
});

@ -0,0 +1,225 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { Page } from "playwright";
import type { QuoteRequest } from "@/modules/providers/quote-provider";
import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step";
import type { RPAContext } from "@/workers/rpa/kernel/context";
const mockSelectAddress = vi.fn();
const mockWaitForCommit = vi.fn();
const mockGetDiagnostics = vi.fn();
const mockGetCommitState = vi.fn();
vi.mock("@/workers/rpa/address-adapter", () => ({
createAddressAdapter: vi.fn(() => ({
selectAddress: mockSelectAddress,
waitForCommit: mockWaitForCommit,
getCommittedAddress: vi.fn(),
getDiagnostics: mockGetDiagnostics,
getCommitState: mockGetCommitState,
})),
formatDiagnosticsFailure: vi.fn((d) => d.status ?? "failed"),
}));
vi.mock("@/lib/rpa/address-mode", () => ({
isRpaAddressMockMode: vi.fn().mockReturnValue(false),
getRpaAddressMode: vi.fn().mockReturnValue("real"),
}));
vi.mock("@/workers/rpa/address-commit", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/workers/rpa/address-commit")>();
return {
...actual,
blurAddressEditing: vi.fn().mockResolvedValue(undefined),
dismissAddressSuggestionDropdown: vi.fn().mockResolvedValue(undefined),
settlePickupBeforeDelivery: vi.fn().mockResolvedValue(undefined),
assertDualAddressStable: vi.fn().mockResolvedValue(undefined),
ensureDualAddressCommitted: vi.fn().mockResolvedValue(undefined),
isPickupReadyForDeliveryInput: vi.fn().mockResolvedValue(true),
lockPickupIntoChip: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("@/workers/rpa/address-suggestions", () => ({
isAddressSuggestionDropdownOpen: vi.fn().mockResolvedValue(false),
getAddressConfirmationSurfaceText: vi.fn().mockResolvedValue(""),
}));
vi.mock("@/workers/rpa/address-side", () => ({
openAddressSide: vi.fn().mockResolvedValue(undefined),
visibleStreetInput: vi.fn(),
}));
vi.mock("@/workers/rpa/cargo-lock", () => ({
settleAddressFieldsForCargo: vi.fn().mockResolvedValue(undefined),
assertBothAddressesLocked: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/workers/rpa/kernel/step-guard", () => ({
runKernelSubStep: vi.fn(
(_step: string, _label: string, fn: () => Promise<unknown>) => fn(),
),
}));
vi.mock("@/workers/rpa/page-prep", () => ({
stabilizeQuoteLandingPage: vi.fn().mockResolvedValue(undefined),
}));
import { openAddressSide } from "@/workers/rpa/address-side";
function baseRequest(overrides?: Partial<QuoteRequest>): QuoteRequest {
return {
cargoHash: "hash",
quoteSessionId: undefined,
pickup: {
street: "1234 Warehouse Street",
city: "Farmers Branch",
state: "TX",
zip: "75234",
placeId: "p1",
formattedAddress: "1234 Warehouse Street Farmers Branch TX",
selectedFromSuggestions: true,
mothershipOptionId: "o1",
mothershipDisplayLabel: "1234 Warehouse Road Farmers Branch TX",
selectedFromMothership: true,
},
delivery: {
street: "5678 Distribution Way",
city: "Fort Worth",
state: "TX",
zip: "76106",
placeId: "d1",
formattedAddress: "5678 Distribution Way Fort Worth TX",
selectedFromSuggestions: true,
mothershipOptionId: "o2",
mothershipDisplayLabel: "5678 Distribution Drive Fort Worth TX",
selectedFromMothership: true,
},
weightLb: 500,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 2,
cargoType: "general_freight",
...overrides,
};
}
function mockCtx(widgetText = ""): RPAContext {
const page = {
keyboard: { press: vi.fn().mockResolvedValue(undefined) },
waitForTimeout: vi.fn().mockResolvedValue(undefined),
locator: vi.fn().mockReturnValue({
innerText: vi.fn().mockResolvedValue(widgetText),
}),
url: vi.fn().mockReturnValue("https://example.com"),
} as unknown as Page;
return {
page,
selectors: {
resolve: vi.fn().mockReturnValue("#widget"),
specs: vi.fn().mockReturnValue([]),
widget: vi.fn(),
},
exec: { evaluateOnLocator: vi.fn() },
runtime: { evaluateOnLocator: vi.fn() },
getWidgetScrollAt: vi.fn().mockReturnValue(0),
setWidgetScrollAt: vi.fn(),
state: {
markAddressSide: vi.fn(),
settleAddressEditing: vi.fn().mockResolvedValue(undefined),
axelPlaceLocations: { pickup: { placeId: "ChIJ_test" } },
},
} as unknown as RPAContext;
}
describe("fillAddressStep", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelectAddress.mockImplementation(async (params) => {
params.ctx.state.axelPlaceLocations[params.side] = {
placeId: "ChIJ_test",
};
return {
success: true,
placeId: "ChIJ_test",
formattedAddress: "test",
committed: true,
};
});
mockWaitForCommit.mockResolvedValue({
success: true,
placeId: "ChIJ_test",
formattedAddress: "test",
committed: true,
});
mockGetCommitState.mockReturnValue("COMMITTED");
mockGetDiagnostics.mockReturnValue({
address: "test",
placeId: "ChIJ_test",
method: "mothership",
events: [],
placeRequestObserved: true,
status: "ok",
commitState: "COMMITTED",
});
});
it("有 quote_session 时仍走完整填表(禁止跳过)", async () => {
const ctx = mockCtx(
"1234 Warehouse Road Farmers Branch TX 5678 Distribution Drive Fort Worth TX",
);
const loc = {
isEditable: vi.fn().mockResolvedValue(true),
waitFor: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
};
const { visibleStreetInput } = await import("@/workers/rpa/address-side");
vi.mocked(visibleStreetInput).mockResolvedValue(loc as never);
await fillAddressStep(
ctx,
baseRequest({ quoteSessionId: "session-uuid" }),
);
expect(openAddressSide).toHaveBeenCalled();
expect(mockSelectAddress).toHaveBeenCalled();
});
it("无 quote_session 时走完整填表", async () => {
const ctx = mockCtx(
"1234 Warehouse Road Farmers Branch TX 5678 Distribution Drive Fort Worth TX",
);
const loc = {
isEditable: vi.fn().mockResolvedValue(true),
waitFor: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
};
const { visibleStreetInput } = await import("@/workers/rpa/address-side");
vi.mocked(visibleStreetInput).mockResolvedValue(loc as never);
await fillAddressStep(ctx, baseRequest());
expect(openAddressSide).toHaveBeenCalled();
expect(mockSelectAddress).toHaveBeenCalledTimes(2);
});
it("widget 未体现确认地址时抛错", async () => {
const { assertDualAddressStable } = await import(
"@/workers/rpa/address-commit"
);
vi.mocked(assertDualAddressStable).mockRejectedValueOnce({
code: "ADDRESS_NOT_CONFIRMED",
});
const ctx = mockCtx("999 Wrong Street Fort Worth TX");
const loc = {
isEditable: vi.fn().mockResolvedValue(true),
waitFor: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
};
const { visibleStreetInput } = await import("@/workers/rpa/address-side");
vi.mocked(visibleStreetInput).mockResolvedValue(loc as never);
await expect(fillAddressStep(ctx, baseRequest())).rejects.toMatchObject({
code: "ADDRESS_NOT_CONFIRMED",
});
});
});

@ -0,0 +1,433 @@
import { describe, expect, it } from "vitest";
import {
addressInputMatchesQuery,
arrowDownCountForPacIndex,
buildAddressAutocompleteQueries,
buildAddressBindingLabels,
buildAddressEnumerationQueries,
extractStreetFromLabel,
formatAddressQuery,
formatMothershipFormattedAddress,
isMotherShipEchoCandidate,
isMotherShipEnumerationRowForTest,
labelToCandidate,
resolveAddressAutocompleteInput,
scoreSuggestionLabelMatch,
} from "@/workers/rpa/address-suggestions";
const laQuery = {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
};
describe("address-suggestions", () => {
it("addressInputMatchesQuery已有等效文案时不应再 fill 清空", () => {
expect(
addressInputMatchesQuery(
"293 Pike St, Seattle, Washington",
"293 Pike St, Seattle, WA, USA",
),
).toBe(true);
expect(addressInputMatchesQuery("", "293 Pike St")).toBe(false);
expect(
addressInputMatchesQuery("123 Main", "456 Oak Ave, Dallas, TX, USA"),
).toBe(false);
});
it("arrowDownCountForPacIndexGoogle Places 默认高亮首项index 0 无需 ArrowDown", () => {
expect(arrowDownCountForPacIndex(0)).toBe(0);
expect(arrowDownCountForPacIndex(1)).toBe(1);
expect(arrowDownCountForPacIndex(2)).toBe(2);
});
it("formatAddressQuery 不含邮编(仅街道+城市+州+国家)", () => {
expect(formatAddressQuery(laQuery)).toBe(
"1234 Warehouse Blvd, Los Angeles, CA, USA",
);
});
it("formatAddressQuery 无 zip 字段时仍含国家", () => {
expect(
formatAddressQuery({ street: "1 Main", city: "Austin", state: "TX", zip: "" }),
).toBe("1 Main, Austin, TX, USA");
});
it("formatMothershipFormattedAddress 不含邮编", () => {
expect(
formatMothershipFormattedAddress(
"1234 Warehouse Street",
"Los Angeles",
"CA",
),
).toBe("1234 Warehouse Street, Los Angeles, CA, USA");
});
it("pipe 副行含 USA 时州码取 TX 而非 USA", () => {
const item = labelToCandidate(
"5678 Distribution Dr|Garland, Dallas, TX, USA",
{
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
);
expect(item?.city).toBe("Garland");
expect(item?.state).toBe("TX");
expect(item?.display_label).toBe("5678 Distribution DrGarland, TX");
});
it("无法解析时不回退用户输入街道", () => {
expect(
labelToCandidate("invalid short", {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
}),
).toBeNull();
});
it("仅街道行联想可补全 city/stateWarehouse Street/Avenue", () => {
const query = {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
};
const street = labelToCandidate("1234 Warehouse Street", query);
const avenue = labelToCandidate("1234 Warehouse Avenue", query);
expect(street?.street).toBe("1234 Warehouse Street");
expect(avenue?.street).toBe("1234 Warehouse Avenue");
expect(street?.city).toBe("Los Angeles");
expect(isMotherShipEchoCandidate(street!, query)).toBe(false);
expect(street?.option_id).not.toBe(avenue?.option_id);
});
it("pipe 联想主行等于 streetToken 时仍应保留Garland 等副行)", () => {
const query = {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
};
expect(isMotherShipEnumerationRowForTest("5678 Distribution Dr|Garland, Texas, USA", query)).toBe(true);
expect(isMotherShipEnumerationRowForTest("5678 Distribution Dr|Wilmer, TX, USA", query)).toBe(true);
expect(isMotherShipEnumerationRowForTest("5678 Distribution Dr|Dallas, TX, USA", query)).toBe(false);
});
it("不同街道名不算 echoWarehouse Street vs Blvd", () => {
const item = labelToCandidate(
"1234 Warehouse Street|Los Angeles, California, USA",
{
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
);
expect(item).not.toBeNull();
if (item) {
expect(
isMotherShipEchoCandidate(item, {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
}),
).toBe(false);
}
});
it("isMotherShipEchoCandidate 识别用户输入回显", () => {
const echo = labelToCandidate(
"5678 Distribution Dr|Dallas, TX, USA",
{
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
);
expect(echo).not.toBeNull();
if (echo) {
expect(
isMotherShipEchoCandidate(echo, {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
}),
).toBe(true);
}
const real = labelToCandidate(
"5678 Distribution Dr|Garland, Dallas, TX, USA",
{
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
);
expect(real).not.toBeNull();
if (real) {
expect(
isMotherShipEchoCandidate(real, {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
}),
).toBe(false);
}
});
it("buildAddressEnumerationQueries 完整 query 优先(含 USA", () => {
expect(
buildAddressEnumerationQueries({
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
})[0],
).toBe("5678 Distribution Dr, Dallas, TX, USA");
});
it("enrichCandidateFromQuery 联想缺门牌时补全用户 street", () => {
const item = labelToCandidate("Distribution WayFarmers Branch, TX", {
street: "5678 Distribution Way",
city: "Farmers Branch",
state: "TX",
zip: "",
});
expect(item?.street).toBe("5678 Distribution Way");
expect(item?.display_label).toContain("5678");
expect(item?.formatted_address).toContain("5678 Distribution Way");
});
it("buildAddressBindingLabels 完整街道优先于粘连 label", () => {
const labels = buildAddressBindingLabels(
{
street: "5678 Distribution Way",
city: "Farmers Branch",
state: "TX",
zip: "",
},
"Distribution WayFarmers Branch, TX",
);
expect(labels[0]).toBe("5678 Distribution Way, Farmers Branch, TX");
expect(labels).toContain("Distribution WayFarmers Branch, TX");
});
it("pac-item 结构化标签保留 suburb 差异", () => {
const garland = labelToCandidate(
"5678 Distribution Dr|Garland, Dallas, TX, USA",
{
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
);
const wilmer = labelToCandidate(
"5678 Distribution Dr|Wilmer, Dallas, TX, USA",
{
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
);
expect(garland?.city).toBe("Garland");
expect(wilmer?.city).toBe("Wilmer");
expect(garland?.display_label).not.toBe(wilmer?.display_label);
});
it("粘连 innerText 解析 suburbFarmers Branch", () => {
const item = labelToCandidate(
"5678 Distribution Way Farmers Branch, Dallas, TX, USA",
{
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
);
expect(item?.street).toBe("5678 Distribution Way");
expect(item?.city).toBe("Farmers Branch");
expect(item?.display_label).toContain("Farmers Branch");
});
it("解析粘连格式(录制 112133", () => {
const item = labelToCandidate(
"1234 Warehouse StreetLos Angeles, CA",
laQuery,
);
expect(item?.street).toBe("1234 Warehouse Street");
expect(item?.city).toBe("Los Angeles");
expect(item?.state).toBe("CA");
expect(item?.zip).toBe("90001");
expect(item?.display_label).toBe("1234 Warehouse StreetLos Angeles, CA");
expect(item?.formatted_address).toBe(
"1234 Warehouse Street, Los Angeles, CA, USA",
);
});
it("候选 zip 从用户输入补全", () => {
const item = labelToCandidate("5678 Distribution DrEagle Pass, TX", {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
});
expect(item?.zip).toBe("75201");
expect(item?.display_label).not.toMatch(/\d{5}/);
});
it("scoreSuggestionLabelMatch 对齐 USA/粘连差异", () => {
const userLabel = "1234 Warehouse Street Los Angeles, CA, USA";
const domLabel = "1234 Warehouse StreetLos Angeles, CA";
expect(scoreSuggestionLabelMatch(userLabel, domLabel, laQuery)).toBeGreaterThanOrEqual(
90,
);
});
it("scoreSuggestionLabelMatch 区分 West Seattle 与 Western Ave", () => {
const query = {
street: "3131 Western Avenue",
city: "West Seattle",
state: "WA",
zip: "",
};
const userLabel = "3131 Western AvenueWest Seattle, WA";
const wrongDom = "3131 Western Ave Seattle, WA, USA";
const rightDom = "3131 Western Avenue West Seattle, WA, USA";
expect(scoreSuggestionLabelMatch(userLabel, wrongDom, query)).toBeLessThan(
scoreSuggestionLabelMatch(userLabel, rightDom, query),
);
});
it("解析无门牌号街道Fifth Avenue / Hollywood Boulevard", () => {
const fifth = labelToCandidate("Fifth Avenue New York, NY, USA", {
street: "Fifth Avenue",
city: "New York",
state: "NY",
zip: "",
});
expect(fifth?.street).toBe("Fifth Avenue");
expect(fifth?.city).toBe("New York");
expect(fifth?.state).toBe("NY");
const hollywood = labelToCandidate(
"Hollywood Boulevard Los Angeles, CA, USA",
{
street: "Hollywood Boulevard",
city: "Los Angeles",
state: "CA",
zip: "",
},
);
expect(hollywood?.street).toBe("Hollywood Boulevard");
expect(hollywood?.city).toBe("Los Angeles");
});
it("normalizeParsedStreet 去除误并入街道的城市名", () => {
const item = labelToCandidate(
"5678 Distribution Dr Garland, Dallas, TX",
{
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
);
expect(item?.street).toBe("5678 Distribution Dr");
expect(item?.city).toBe("Garland");
});
it("buildAddressAutocompleteQueries preferConfirmedLabel 时完整 POI 标签优先", () => {
const queries = buildAddressAutocompleteQueries(
{
street: "Washington State Convention Center Main Garage, Pike Street",
city: "Seattle",
state: "WA",
zip: "",
},
{
mothershipLabel:
"Washington State Convention Center Main Garage, Pike Street, Seattle, Washington, USA",
preferConfirmedLabel: true,
},
);
expect(queries[0]).toBe(
"Washington State Convention Center Main Garage, Pike Street, Seattle, Washington",
);
});
it("buildAddressAutocompleteQueries 从 mothershipLabel 提取纯街道查询(触发多候选联想)", () => {
const queries = buildAddressAutocompleteQueries(
{
street: "La Concha Key West, Autograph Collection Duval Street",
city: "Key West",
state: "FL",
zip: "",
},
{ mothershipLabel: "La Concha Key West, Autograph Collection, Duval Street, Key West, FL" },
);
expect(queries[0]).toBe("Duval Street, Key West, FL, USA");
expect(queries[1]).toBe("Duval Street, Key West, FL");
const poiIdx = queries.indexOf(
"La Concha Key West, Autograph Collection Duval Street, Key West, FL, USA",
);
expect(poiIdx).toBeGreaterThan(1);
});
it("extractStreetFromLabel 处理 POI 与街道粘连(无逗号)", () => {
expect(
extractStreetFromLabel(
"La Concha Key West, Autograph Collection Duval Street, FL",
),
).toBe("Duval Street");
});
it("resolveAddressAutocompleteInput 用纯街道替换 POI street", () => {
const resolved = resolveAddressAutocompleteInput(
{
street: "La Concha Key West, Autograph Collection Duval Street",
city: "Key West",
state: "FL",
zip: "",
},
{
mothershipLabel:
"La Concha Key West, Autograph Collection Duval Street, FL",
},
);
expect(resolved.street).toBe("Duval Street");
});
it("buildAddressAutocompleteQueries 无 mothershipLabel 时保留原有行为", () => {
const queries = buildAddressAutocompleteQueries({
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
});
expect(queries[0]).toBe("5678 Distribution Dr, Dallas, TX, USA");
});
it("buildAddressAutocompleteQueries 处理粘连格式 mothershipLabel", () => {
const queries = buildAddressAutocompleteQueries(
{
street: "3131 Western AvenueWest Seattle",
city: "West Seattle",
state: "WA",
zip: "",
},
{ mothershipLabel: "3131 Western AvenueWest Seattle, WA" },
);
expect(queries[0]).toBe("3131 Western Avenue, West Seattle, WA, USA");
});
});

@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
import { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge";
import { commitViaAxelPlacePrefetch } from "@/workers/rpa/axel-place-prefetch";
describe("commitViaAxelPlacePrefetch", () => {
it("GET 200 但 fill 回退不合成 commit", async () => {
const bridge = new AxelSelectionBridge();
const page = {
context: () => ({
cookies: vi.fn().mockResolvedValue([]),
request: {
get: vi.fn().mockResolvedValue({
status: () => 200,
ok: () => true,
json: async () => ({
placeId: "ChIJ_test_place_id_00",
city: "LA",
}),
}),
},
}),
evaluate: vi.fn().mockResolvedValue(false),
keyboard: { press: vi.fn().mockResolvedValue(undefined) },
waitForTimeout: vi.fn().mockResolvedValue(undefined),
} as unknown as Page;
const input = {
click: vi.fn().mockResolvedValue(undefined),
fill: vi.fn().mockResolvedValue(undefined),
};
const result = await commitViaAxelPlacePrefetch(
page,
bridge,
"ChIJ_test_place_id_00",
"1234 Warehouse Street, Los Angeles, CA, USA",
input as never,
);
expect(result.ok).toBe(false);
expect(result.location).toBeTruthy();
expect(bridge.hasPlaceCommit("ChIJ_test_place_id_00")).toBe(false);
});
});

@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { AxelSelectionBridge, formatPlaceCommitFailure, normalizeAxelPlaceId } from "@/workers/rpa/axel-selection-bridge";
describe("AxelSelectionBridge", () => {
it("resolveSelection 从 googlePlacesResults 绑定 placeId", () => {
const bridge = new AxelSelectionBridge();
bridge.ingestSearchResults({
googlePlacesResults: [
{
description: "1234 Warehouse Street, Los Angeles, CA 90001, USA",
mainText: "1234 Warehouse Street",
placeId: "ChIJ_test_la",
secondaryText: "Los Angeles, CA 90001, USA",
},
],
});
const binding = bridge.resolveSelection(
"1234 Warehouse StreetLos Angeles, CA",
{
street: "1234 Warehouse Street",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
);
expect(binding).toEqual({
label: "1234 Warehouse StreetLos Angeles, CA",
placeId: "ChIJ_test_la",
description: "1234 Warehouse Street, Los Angeles, CA 90001, USA",
});
});
it("记录 place/{id} 200 为 commit", () => {
const bridge = new AxelSelectionBridge();
bridge.recordPlaceCommit("ChIJ_commit_ok");
expect(bridge.hasPlaceCommit("ChIJ_commit_ok")).toBe(true);
expect(bridge.getObservedPlaceCommitIds()).toContain("ChIJ_commit_ok");
});
it("normalizeAxelPlaceId 从 base64 复合串提取 ChIJ", () => {
const raw =
"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol";
expect(normalizeAxelPlaceId(raw)).toBe("ChIJywDT6CXGwoARuoz5HIJe20g");
});
it("normalizeAxelPlaceId 截断 ChIJ 后粘连 base64 尾巴", () => {
const composite =
"ChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol";
expect(normalizeAxelPlaceId(composite)).toBe("ChIJywDT6CXGwoARuoz5HIJe20g");
});
it("resolveSelection 有门牌 query 时优先带门牌的 placeId", () => {
const bridge = new AxelSelectionBridge();
bridge.ingestSearchResults({
googlePlacesResults: [
{
description: "Distribution Way, Farmers Branch, TX, USA",
mainText: "Distribution Way",
placeId: "ChIJ_generic_way",
secondaryText: "Farmers Branch, TX, USA",
},
{
description: "5678 Distribution Way, Farmers Branch, TX, USA",
mainText: "5678 Distribution Way",
placeId: "ChIJ_numbered_way",
secondaryText: "Farmers Branch, TX, USA",
},
],
});
const binding = bridge.resolveSelection("Distribution WayFarmers Branch, TX", {
street: "5678 Distribution Way",
city: "Farmers Branch",
state: "TX",
zip: "",
});
expect(binding?.placeId).toBe("ChIJ_numbered_way");
});
it("无 search 结果时 resolveSelection 返回 null", () => {
const bridge = new AxelSelectionBridge();
expect(
bridge.resolveSelection("1234 Warehouse StreetLos Angeles, CA"),
).toBeNull();
});
it("formatPlaceCommitFailure 区分无请求与 HTTP 错误", () => {
expect(
formatPlaceCommitFailure("ChIJ_expected", ["keyboard-bound-0"], {
committedPlaceIds: [],
placeNetworkLog: [],
}),
).toContain("未发起 GET place 请求");
expect(
formatPlaceCommitFailure("ChIJ_expected", ["pac-item-0"], {
committedPlaceIds: [],
placeNetworkLog: [{ placeId: "ChIJ_other", status: 404, url: "", at: 0 }],
}),
).toContain("placeId 不匹配");
});
});

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { BROWSER_FETCH_AND_INJECT_PLACE } from "@/workers/rpa/fix-place/browser-place-commit";
describe("browser-place-commit", () => {
it("BROWSER_FETCH_AND_INJECT_PLACE 为 Playwright 可执行 async 函数体", () => {
expect(BROWSER_FETCH_AND_INJECT_PLACE.trim().startsWith("async (arg)")).toBe(
true,
);
expect(BROWSER_FETCH_AND_INJECT_PLACE).not.toContain("(async function");
});
});

@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
evaluateAddressesLockedForSubmit,
evaluateCargoCommitted,
type CargoCommitProbe,
} from "@/workers/rpa/cargo-lock";
describe("cargo commit barrier", () => {
const committed: CargoCommitProbe = {
hasSummaryChip: true,
visibleEditableCargoInputs: 0,
editorOpen: false,
internalPalletCount: 2,
internalWeight: 227,
widgetTextPreview: "2 托盘s",
};
it("summary + 无 editable input + internal pallet → committed", () => {
expect(evaluateCargoCommitted(committed, 2)).toBe(true);
});
it("内联步输入仍可见但摘要完整 → committed", () => {
expect(
evaluateCargoCommitted(
{
hasSummaryChip: true,
visibleEditableCargoInputs: 5,
editorOpen: true,
internalPalletCount: 2,
internalWeight: 226,
widgetTextPreview:
"2 托盘s 40W, x 48L, x 48H, x 226Lbs 货运详情",
},
2,
),
).toBe(true);
});
it("摘要不完整且仍有 editable → 未 committed", () => {
expect(
evaluateCargoCommitted(
{
hasSummaryChip: false,
visibleEditableCargoInputs: 2,
editorOpen: true,
internalPalletCount: null,
internalWeight: null,
widgetTextPreview: "货运详情",
},
2,
),
).toBe(false);
});
it("摘要含托盘+尺寸但无重量 → 未 committed", () => {
expect(
evaluateCargoCommitted(
{
hasSummaryChip: true,
visibleEditableCargoInputs: 0,
editorOpen: true,
internalPalletCount: 2,
internalWeight: null,
widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x -Lbs",
},
2,
),
).toBe(false);
});
it("摘要含 226Lbs 无空格 → committed", () => {
expect(
evaluateCargoCommitted(
{
hasSummaryChip: true,
visibleEditableCargoInputs: 0,
editorOpen: false,
internalPalletCount: 2,
internalWeight: 226,
widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 226Lbs",
},
2,
),
).toBe(true);
});
it("editor 打开且仍有 editable → 未 committed", () => {
expect(
evaluateCargoCommitted(
{ ...committed, editorOpen: true, visibleEditableCargoInputs: 2 },
2,
),
).toBe(false);
});
it("摘要重量与请求单托不符 → 未 committed", () => {
expect(
evaluateCargoCommitted(
{
hasSummaryChip: true,
visibleEditableCargoInputs: 0,
editorOpen: false,
internalPalletCount: 2,
internalWeight: 2296,
widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 2296Lbs",
},
{
palletCount: 2,
weight: {
displayValue: "500",
unit: "lb",
weightLbPerPallet: 500,
palletCount: 2,
},
},
),
).toBe(false);
});
});
describe("address lock barrier", () => {
it("双州码 → locked", () => {
expect(
evaluateAddressesLockedForSubmit(
"1234 Warehouse St, Los Angeles, CA 90021 → 5678 Distribution Way, Dallas, TX 75234",
false,
),
).toBe(true);
});
it("单州码 → 未 locked", () => {
expect(
evaluateAddressesLockedForSubmit("Los Angeles, CA 90021 货运详情", false),
).toBe(false);
});
it("地址搜索仍可编辑 → 未 locked", () => {
expect(
evaluateAddressesLockedForSubmit(
"1234 Warehouse St, CA 90021 5678 Distribution Way, TX 75234",
true,
),
).toBe(false);
});
});

@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
import { isCargoEditorOpen } from "@/workers/rpa/cargo-editor";
function mockPage(visible: (selector: string) => boolean): Page {
const getByRole = vi.fn((_role: string, opts?: { name?: RegExp }) => ({
first: () => ({
isVisible: vi.fn().mockResolvedValue(
opts?.name?.test("ADD ITEM") ?? false,
),
}),
}));
const getByPlaceholder = vi.fn((pattern: RegExp) => ({
first: () => ({
isVisible: vi.fn().mockResolvedValue(/weight/i.test(pattern.source)),
}),
}));
return {
locator: vi.fn((selector: string) => ({
first: () => ({
isVisible: vi.fn().mockResolvedValue(visible(selector)),
}),
last: () => ({
isVisible: vi.fn().mockResolvedValue(visible(selector)),
}),
})),
getByRole,
getByPlaceholder,
getByText: vi.fn(() => ({
first: () => ({ isVisible: vi.fn().mockResolvedValue(false) }),
})),
} as unknown as Page;
}
describe("isCargoEditorOpen", () => {
it("role=dialog 可见时视为已打开", async () => {
const page = mockPage((s) => s.includes("dialog"));
expect(await isCargoEditorOpen(page)).toBe(true);
});
it("popoverWeight placeholder 可见时视为已打开(无 dialog", async () => {
const page = mockPage(() => false);
expect(await isCargoEditorOpen(page)).toBe(true);
});
it("无 dialog 且无 editor 信号时未打开", async () => {
const page = {
locator: vi.fn(() => ({
first: () => ({
isVisible: vi.fn().mockResolvedValue(false),
}),
})),
getByRole: vi.fn(() => ({
first: () => ({
isVisible: vi.fn().mockResolvedValue(false),
}),
})),
getByPlaceholder: vi.fn(() => ({
first: () => ({
isVisible: vi.fn().mockResolvedValue(false),
}),
})),
getByText: vi.fn(() => ({
first: () => ({
isVisible: vi.fn().mockResolvedValue(false),
}),
})),
} as unknown as Page;
expect(await isCargoEditorOpen(page)).toBe(false);
});
});

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { widgetHasFreightSummary } from "@/workers/rpa/cargo-reset";
describe("widgetHasFreightSummary", () => {
it("含托盘+尺寸摘要 → true", () => {
expect(
widgetHasFreightSummary(
"2 托盘s 40W, x 48L, x 44H, x 2079Lbs",
),
).toBe(true);
});
it("仅地址无货物 → false", () => {
expect(
widgetHasFreightSummary("Denver, CO Charlotte, NC 4800 South Race Street"),
).toBe(false);
});
});

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
isCargoStateReady,
type CargoStateEvidence,
} from "@/workers/rpa/cargo-state";
describe("isCargoStateReady", () => {
it("hydratingmounted 但无 interactive → not ready", () => {
const evidence: CargoStateEvidence = {
phase: "hydrating",
mountedCargoFields: [{ placeholder: "需要几个托盘?", ariaLabel: null }],
interactiveCargoFields: [],
blockedCargoFields: [
{
placeholder: "需要几个托盘?",
ariaLabel: null,
blockers: ["pointer_events_none"],
},
],
};
expect(isCargoStateReady(evidence)).toBe(false);
});
it("仅 interactive 非托盘字段 → not ready", () => {
const evidence: CargoStateEvidence = {
phase: "interactive",
mountedCargoFields: [{ placeholder: "长度", ariaLabel: null }],
interactiveCargoFields: [{ placeholder: "长度", ariaLabel: null }],
blockedCargoFields: [],
};
expect(isCargoStateReady(evidence)).toBe(false);
});
it("托盘 interactive → ready", () => {
const evidence: CargoStateEvidence = {
phase: "interactive",
mountedCargoFields: [{ placeholder: "需要几个托盘?", ariaLabel: null }],
interactiveCargoFields: [
{ placeholder: "需要几个托盘?", ariaLabel: null },
],
blockedCargoFields: [],
};
expect(isCargoStateReady(evidence)).toBe(true);
});
});

@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import type { CargoCommitProbe } from "@/workers/rpa/cargo-lock";
import {
buildWeightDisplayForUnit,
inferWeightUnitFromFieldHint,
isStaleCargoState,
probeWeightMatchesExpected,
resolveMotherShipWeightDisplay,
} from "@/workers/rpa/cargo-weight";
const expected500x2 = {
...resolveMotherShipWeightDisplay(500, "Weight (lbs)"),
palletCount: 2,
};
function probe(
overrides: Partial<CargoCommitProbe>,
): CargoCommitProbe {
return {
hasSummaryChip: true,
visibleEditableCargoInputs: 0,
editorOpen: false,
internalPalletCount: 2,
internalWeight: 500,
widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 500Lbs",
...overrides,
};
}
describe("inferWeightUnitFromFieldHint", () => {
it("lbs placeholder → lb", () => {
expect(inferWeightUnitFromFieldHint("Weight (lbs)")).toBe("lb");
});
it("kg placeholder → kg", () => {
expect(inferWeightUnitFromFieldHint("Weight (kg)")).toBe("kg");
});
it("合并多 spec 字符串含 kg 时仍优先 lb有 lbs 标记)", () => {
const joined = "Weight (lbs) Weight (kg) 重量(公斤)";
expect(inferWeightUnitFromFieldHint(joined)).toBe("lb");
});
});
describe("buildWeightDisplayForUnit", () => {
it("lb 页直接填原始 lb不换算", () => {
expect(buildWeightDisplayForUnit(503, "lb")).toEqual({
displayValue: "503",
unit: "lb",
weightLbPerPallet: 503,
});
});
it("kg 页才换算", () => {
const r = buildWeightDisplayForUnit(500, "kg");
expect(r.unit).toBe("kg");
expect(Number(r.displayValue)).toBeCloseTo(226.8, 0);
});
});
describe("resolveMotherShipWeightDisplay", () => {
it("lbs 字段填单托 lb", () => {
expect(resolveMotherShipWeightDisplay(500, "Weight (lbs)")).toEqual({
displayValue: "500",
unit: "lb",
weightLbPerPallet: 500,
});
});
it("kg 字段填单托 kg", () => {
const r = resolveMotherShipWeightDisplay(500, "Weight (kg)");
expect(r.unit).toBe("kg");
expect(Number(r.displayValue)).toBeCloseTo(226.8, 0);
});
it("503lb 在 lbs 页不得变成 228.16", () => {
expect(resolveMotherShipWeightDisplay(503, "Weight (lbs)").displayValue).toBe(
"503",
);
});
});
describe("probeWeightMatchesExpected", () => {
it("单托 500lb × 2 托 → 匹配", () => {
expect(probeWeightMatchesExpected(probe({}), expected500x2)).toBe(true);
});
it("widget 残留 2296lb → 不匹配", () => {
expect(
probeWeightMatchesExpected(
probe({
internalWeight: 2296,
widgetTextPreview: "2 托盘s x 2296Lbs",
}),
expected500x2,
),
).toBe(false);
});
it("托盘数不一致 → 不匹配", () => {
expect(
probeWeightMatchesExpected(
probe({ internalPalletCount: 4 }),
expected500x2,
),
).toBe(false);
});
it("摘要总重匹配 → 通过", () => {
expect(
probeWeightMatchesExpected(
{
hasSummaryChip: true,
visibleEditableCargoInputs: 0,
editorOpen: false,
internalPalletCount: 3,
internalWeight: null,
widgetTextPreview: "3 托盘s x 1371Lbs",
},
{
displayValue: "457",
unit: "lb",
weightLbPerPallet: 457,
palletCount: 3,
},
),
).toBe(true);
});
});
describe("isStaleCargoState", () => {
it("无摘要 → 非残留", () => {
expect(
isStaleCargoState(
probe({ hasSummaryChip: false, internalWeight: null }),
expected500x2,
),
).toBe(false);
});
it("摘要重量错误 → 残留", () => {
expect(
isStaleCargoState(
probe({ internalWeight: 2296, widgetTextPreview: "x 2296Lbs" }),
expected500x2,
),
).toBe(true);
});
});

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { DUAL_ADDRESS_PATCH_INIT } from "@/workers/rpa/inject/dual-address-patch-init";
import { isDualAddressPatchEnabled } from "@/lib/rpa/env";
describe("dual-address-patch", () => {
it("init script 包含 vault API 标识", () => {
expect(DUAL_ADDRESS_PATCH_INIT).toContain("__rpaDualAddressPatchInstalled");
expect(DUAL_ADDRESS_PATCH_INIT).toContain("__rpaDualAddressApi");
expect(DUAL_ADDRESS_PATCH_INIT).toContain("preservePickup");
});
it("显式 RPA_DUAL_ADDRESS_PATCH=true 时启用 patch", () => {
const prev = process.env.RPA_DUAL_ADDRESS_PATCH;
const prevQuote = process.env.RPA_QUOTE_MODE;
process.env.RPA_DUAL_ADDRESS_PATCH = "true";
process.env.RPA_QUOTE_MODE = "widget";
expect(isDualAddressPatchEnabled()).toBe(true);
process.env.RPA_DUAL_ADDRESS_PATCH = "false";
expect(isDualAddressPatchEnabled()).toBe(false);
process.env.RPA_DUAL_ADDRESS_PATCH = prev ?? "";
process.env.RPA_QUOTE_MODE = prevQuote ?? "";
});
});

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
buildWeightDisplayForUnit,
inferWeightUnitFromFieldHint,
} from "@/workers/rpa/cargo-weight";
import { classifyCargoFieldHint } from "@/workers/rpa/fill-cargo-in-drawer";
describe("classifyCargoFieldHint", () => {
it("识别托数/重量/尺寸", () => {
expect(classifyCargoFieldHint("How many pallets?")).toBe("pallet");
expect(classifyCargoFieldHint("Weight (lbs)")).toBe("weight");
expect(classifyCargoFieldHint("Weight (kg)")).toBe("weight");
expect(classifyCargoFieldHint("重量(公斤)")).toBe("weight");
expect(classifyCargoFieldHint("Length")).toBe("length");
expect(classifyCargoFieldHint("Width")).toBe("width");
expect(classifyCargoFieldHint("Height")).toBe("height");
expect(classifyCargoFieldHint("需要几个托盘?")).toBe("pallet");
});
it("kg placeholder 仅换算 kg 字段", () => {
const display = buildWeightDisplayForUnit(
503,
inferWeightUnitFromFieldHint("Weight (kg)"),
);
expect(display.unit).toBe("kg");
expect(display.displayValue).not.toBe("503");
const lbs = buildWeightDisplayForUnit(
503,
inferWeightUnitFromFieldHint("Weight (lbs)"),
);
expect(lbs.displayValue).toBe("503");
});
});

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
CHECK_PICKUP_IN_REACT_STATE,
INJECT_REACT_STATE,
INVOKE_CAPTURED_PLACE_CHANGED,
TRIGGER_GOOGLE_PLACE_CHANGED,
} from "@/workers/rpa/fix-place/browser-eval-scripts";
describe("fix-place/browser-eval-scripts", () => {
it("脚本为纯字符串,不含 esbuild helper 引用", () => {
const scripts = [
TRIGGER_GOOGLE_PLACE_CHANGED,
INVOKE_CAPTURED_PLACE_CHANGED,
CHECK_PICKUP_IN_REACT_STATE,
INJECT_REACT_STATE,
];
for (const script of scripts) {
expect(script).not.toMatch(/__name|__assign|__awaiter|__spread/);
expect(typeof script).toBe("string");
expect(script.length).toBeGreaterThan(10);
}
});
});

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { RESOLVE_CLICK_TARGET } from "@/workers/rpa/fix-place/cdp-click";
describe("cdp-click", () => {
it("RESOLVE_CLICK_TARGET 为纯字符串脚本", () => {
expect(typeof RESOLVE_CLICK_TARGET).toBe("string");
expect(RESOLVE_CLICK_TARGET).toContain("getBoundingClientRect");
expect(RESOLVE_CLICK_TARGET).not.toMatch(/__name|__assign/);
});
});

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
PLACE_DIAG_COLLECT_SNAPSHOT,
PLACE_DIAG_SET_MARK,
P0_PLACE_DIAG_INIT,
P2_FETCH_HIJACK_INIT,
} from "@/workers/rpa/fix-place/place-diagnostic-init";
import { interpretPlaceDiagnostic } from "@/workers/rpa/fix-place/place-diagnostic-probe";
describe("place-diagnostic-init", () => {
it("P0/P2 脚本为纯字符串", () => {
for (const script of [
P0_PLACE_DIAG_INIT,
P2_FETCH_HIJACK_INIT,
PLACE_DIAG_SET_MARK,
PLACE_DIAG_COLLECT_SNAPSHOT,
]) {
expect(typeof script).toBe("string");
expect(script).not.toMatch(/__name|__assign/);
}
expect(P0_PLACE_DIAG_INIT).toContain("maps.event.addListener");
expect(P0_PLACE_DIAG_INIT).toContain("fingerprintHandler");
expect(P2_FETCH_HIJACK_INIT).toContain("axel/location/place");
});
});
describe("interpretPlaceDiagnostic", () => {
it("无监听器时提示 input gm 定位", () => {
const lines = interpretPlaceDiagnostic(
{
ok: true,
topology: { instanceCount: 0, instances: [], inputProbes: [], monkeyPatchInstances: 0, capturedHandlers: 0 },
listeners: { total: 0, anyHasFetch: false, anyHasAxelPlace: false, rows: [] },
handlerEvents: [],
fetchLog: [],
},
"test",
);
expect(lines.some((l) => l.includes("无任何 place_changed 监听器"))).toBe(true);
});
it("有 handler 无 fetch 时提示 P2", () => {
const lines = interpretPlaceDiagnostic(
{
ok: true,
topology: { instanceCount: 1, instances: [], inputProbes: [], monkeyPatchInstances: 1, capturedHandlers: 1 },
listeners: { total: 1, anyHasFetch: false, anyHasAxelPlace: false, rows: [] },
handlerEvents: [{ type: "place_changed_handler", instanceId: "ac-1", getPlaceBefore: { place_id: "ChIJ_test", hasGeometry: true } }],
fetchLog: [],
},
"test",
);
expect(lines.some((l) => l.includes("P2 无 place fetch"))).toBe(true);
});
});

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
findGoldStandardTargets,
loadAllGoldStandardTargets,
scoreGoldTextMatch,
} from "@/workers/rpa/gold-standard-targets";
describe("gold-standard-targets", () => {
it("loadAllGoldStandardTargets 读取录证金样本", () => {
const targets = loadAllGoldStandardTargets();
expect(targets.length).toBeGreaterThan(0);
expect(targets[0]?.textPreview.length).toBeGreaterThan(12);
});
it("scoreGoldTextMatch 对完全匹配给高分", () => {
const text =
"La Concha Key West, Autograph Collection, Duval Street, Key West, Florida, USA";
const score = scoreGoldTextMatch(text, {
street: "Duval Street",
city: "Key West",
state: "FL",
zip: "",
}, text);
expect(score).toBeGreaterThanOrEqual(85);
});
it("scoreGoldTextMatch 拒绝仅城市相同的不同街道", () => {
const score = scoreGoldTextMatch(
"1234 Warehouse Street, Los Angeles, CA, USA",
{
street: "1234 Warehouse Street",
city: "Los Angeles",
state: "CA",
zip: "",
},
"Hollywood BoulevardLos Angeles, California, USA",
);
expect(score).toBe(0);
});
it("findGoldStandardTargets 按 description 匹配 Key West 金样本", () => {
const matches = findGoldStandardTargets(
"La Concha Key West, Autograph Collection, Duval Street, Key West, Florida, USA",
{
street: "Duval Street",
city: "Key West",
state: "FL",
zip: "",
},
"pickup",
40,
);
expect(matches.length).toBeGreaterThan(0);
expect(matches[0]?.textPreview).toMatch(/Key West|Duval/i);
});
});

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
COLLECT_CLIENT_STATE,
FILL_CARGO_FIELD,
PROBE_CARGO_UI,
WAIT_CARGO_INTERACTIVE,
} from "@/workers/rpa/kernel/browser-scripts";
describe("kernel/browser-scripts re-exports", () => {
it("脚本为纯字符串,不含 TS helper 引用", () => {
const scripts = [
PROBE_CARGO_UI,
WAIT_CARGO_INTERACTIVE,
FILL_CARGO_FIELD,
COLLECT_CLIENT_STATE,
];
for (const script of scripts) {
expect(script).not.toMatch(/__name|__assign|__awaiter|__spread/);
expect(typeof script).toBe("string");
expect(script.length).toBeGreaterThan(10);
}
});
});

@ -0,0 +1,118 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
import { fillCargoStep } from "@/workers/rpa/kernel/steps/cargo-step";
vi.mock("@/workers/rpa/fill-cargo-in-drawer", () => ({
fillCargoInDrawer: vi.fn().mockResolvedValue({
weightDisplay: {
displayValue: "500",
unit: "lb",
weightLbPerPallet: 500,
},
expected: {
palletCount: 2,
weight: {
displayValue: "500",
unit: "lb",
weightLbPerPallet: 500,
palletCount: 2,
},
},
}),
}));
vi.mock("@/workers/rpa/cargo-lock", () => ({
assertBothAddressesLocked: vi.fn().mockResolvedValue(undefined),
settleAddressFieldsForCargo: vi.fn().mockResolvedValue(undefined),
waitForCargoCommittedState: vi.fn().mockResolvedValue({
hasSummaryChip: true,
visibleEditableCargoInputs: 0,
editorOpen: false,
internalPalletCount: 2,
internalWeight: 500,
widgetTextPreview: "",
}),
}));
const stateCalls: string[] = [];
function mockCtx() {
const page = {
waitForTimeout: vi.fn().mockResolvedValue(undefined),
locator: vi.fn().mockReturnValue({
last: vi.fn().mockReturnValue({
locator: vi.fn().mockReturnValue({ first: vi.fn() }),
}),
filter: vi.fn().mockReturnValue({ nth: vi.fn() }),
first: vi.fn(),
}),
url: vi.fn().mockReturnValue("https://example.com"),
} as unknown as Page;
return {
page,
selectors: {
specs: vi.fn().mockReturnValue(["input"]),
optionalEnv: vi.fn().mockReturnValue(undefined),
resolve: vi.fn().mockReturnValue("#widget"),
widget: vi.fn(),
},
exec: {
evaluateOnLocator: vi.fn().mockResolvedValue(false),
},
runtime: {
evaluateOnLocator: vi.fn(),
},
getWidgetScrollAt: vi.fn().mockReturnValue(0),
setWidgetScrollAt: vi.fn(),
state: {
triggerCargoSection: vi.fn().mockResolvedValue(undefined),
settleAddressEditing: vi.fn(async () => {
stateCalls.push("settle");
}),
},
};
}
describe("fillCargoStep UI flow", () => {
it("顺序fillCargoInDrawer → waitForCargoCommittedState", async () => {
const { fillCargoInDrawer } = await import("@/workers/rpa/fill-cargo-in-drawer");
const { waitForCargoCommittedState } = await import("@/workers/rpa/cargo-lock");
const ctx = mockCtx() as never;
await fillCargoStep(ctx, {
cargoHash: "t",
pickup: {} as never,
delivery: {} as never,
weightLb: 500,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 2,
cargoType: "general_freight",
});
expect(fillCargoInDrawer).toHaveBeenCalled();
expect(waitForCargoCommittedState).toHaveBeenCalled();
});
it("fillCargoInDrawer 失败时向上抛出", async () => {
const { fillCargoInDrawer } = await import("@/workers/rpa/fill-cargo-in-drawer");
const { RpaError } = await import("@/modules/rpa/errors");
vi.mocked(fillCargoInDrawer).mockRejectedValueOnce(
new RpaError("STRUCT_CHANGE", "货物 drawer 内未找到任何重量输入框", {
retryable: true,
}),
);
const ctx = mockCtx() as never;
await expect(
fillCargoStep(ctx, {
cargoHash: "t",
pickup: {} as never,
delivery: {} as never,
weightLb: 500,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 2,
cargoType: "general_freight",
}),
).rejects.toMatchObject({ code: "STRUCT_CHANGE" });
});
});

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { RpaError } from "@/modules/rpa/errors";
import { RPAContext, resolveRPAContext } from "@/workers/rpa/kernel/context";
import {
assertRPAContext,
ensureArray,
isRPAContext,
snapshotContextContract,
} from "@/workers/rpa/kernel/context-validator";
function mockPage(): object {
return {
locator: () => ({
filter: () => ({ nth: () => ({}) }),
first: () => ({}),
}),
waitForTimeout: async () => undefined,
url: () => "https://example.com",
};
}
describe("context-validator", () => {
it("ensureArrayundefined → []", () => {
expect(ensureArray(undefined)).toEqual([]);
expect(ensureArray(null)).toEqual([]);
expect(ensureArray([1])).toEqual([1]);
});
it("resolveRPAContext已是 context 时不再包一层", () => {
const page = mockPage();
const ctx = new RPAContext(page as never);
const resolved = resolveRPAContext(ctx);
expect(resolved).toBe(ctx);
expect(isRPAContext(resolved)).toBe(true);
});
it("fromSession 误传 context 不再导致 page.locator 断裂", () => {
const page = mockPage();
const ctx = new RPAContext(page as never);
const wrapped = RPAContext.fromSession(ctx);
expect(wrapped).toBe(ctx);
expect(snapshotContextContract(wrapped).hasPage).toBe(true);
});
it("assertRPAContext缺 state 时 fail-fast", () => {
const page = mockPage();
const broken = {
page,
selectors: {},
exec: {},
runtime: {},
getWidgetScrollAt: () => 0,
setWidgetScrollAt: () => undefined,
};
expect(() => assertRPAContext(broken)).toThrow(RpaError);
});
});

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { SelectorGraph } from "@/workers/rpa/kernel/selector-graph";
import { RPAContext } from "@/workers/rpa/kernel/context";
import { isCargoStateReady } from "@/workers/rpa/kernel/state-engine";
function mockPage(): object {
return {
locator: () => ({
filter: () => ({ nth: () => ({}) }),
first: () => ({}),
}),
waitForTimeout: async () => undefined,
url: () => "https://example.com",
};
}
describe("SelectorGraph", () => {
it("resolve widget selector", () => {
const graph = new SelectorGraph();
expect(graph.resolve("RPA_SELECTOR_QUOTE_WIDGET")).toBeTruthy();
});
it("specs 永不为 undefined", () => {
const graph = new SelectorGraph();
expect(graph.specs("NOT_A_REAL_KEY")).toEqual([]);
});
});
describe("RPAContext", () => {
it("scroll 时间戳存于实例", () => {
const ctx = new RPAContext(mockPage() as never);
expect(ctx.getWidgetScrollAt()).toBe(0);
ctx.setWidgetScrollAt(123);
expect(ctx.getWidgetScrollAt()).toBe(123);
});
it("runtime 与 exec 同源", () => {
const ctx = new RPAContext(mockPage() as never);
expect(ctx.runtime).toBe(ctx.exec);
});
});
describe("isCargoStateReady", () => {
it("托盘 interactive → ready", () => {
expect(
isCargoStateReady({
phase: "interactive",
mountedCargoFields: [{ placeholder: "需要几个托盘?", ariaLabel: null }],
interactiveCargoFields: [
{ placeholder: "需要几个托盘?", ariaLabel: null },
],
blockedCargoFields: [],
}),
).toBe(true);
});
});

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { normalizeCargoEvidence } from "@/workers/rpa/kernel/normalize";
describe("normalizeCargoEvidence", () => {
it("undefined → 空 evidence", () => {
expect(normalizeCargoEvidence(undefined).phase).toBe("no_cargo_inputs");
expect(normalizeCargoEvidence(undefined).mountedCargoFields).toEqual([]);
});
it("数组字段 undefined → []", () => {
const evidence = normalizeCargoEvidence({
phase: "hydrating",
mountedCargoFields: undefined as never,
interactiveCargoFields: undefined as never,
blockedCargoFields: undefined as never,
});
expect(evidence.mountedCargoFields).toEqual([]);
});
});

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isCargoStateReady } from "@/workers/rpa/kernel/state-engine";
describe("isCargoStateReady", () => {
it("首页精简 widgetweight 字段可视为就绪", () => {
expect(
isCargoStateReady({
phase: "interactive",
mountedCargoFields: [],
interactiveCargoFields: [
{ placeholder: "Weight (lbs)", ariaLabel: null },
],
blockedCargoFields: [],
}),
).toBe(true);
});
it("无 pallet/weight 字段时未就绪", () => {
expect(
isCargoStateReady({
phase: "no_cargo_inputs",
mountedCargoFields: [],
interactiveCargoFields: [],
blockedCargoFields: [],
}),
).toBe(false);
});
});

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
buildDescriptionProbes,
labelsMatch,
suggestionLabelConflicts,
} from "@/workers/rpa/mothership-styled-suggestion-click";
describe("mothership-styled-locator", () => {
it("buildDescriptionProbes 生成多层文本探针", () => {
const probes = buildDescriptionProbes(
"Hollywood Boulevard, Los Angeles, California, USA",
{
street: "Hollywood Boulevard",
city: "Los Angeles",
state: "CA",
zip: "",
},
);
expect(probes).toContain("Hollywood Boulevard, Los Angeles, CA");
expect(probes.some((p) => p.includes("Hollywood"))).toBe(true);
});
it("labelsMatch 支持包含匹配与门牌号", () => {
expect(
labelsMatch(
"1234 Warehouse Street, Los Angeles, CA, USA",
"1234 Warehouse Street Los Angeles CA",
),
).toBe(true);
});
it("labelsMatch 拒绝 Bridge 误匹配", () => {
expect(
labelsMatch(
"1234 Market Street Bridge, Philadelphia, PA, USA",
"1234 Market Street, Philadelphia, PA, USA",
),
).toBe(false);
});
it("suggestionLabelConflicts 识别 Bridge 歧义", () => {
expect(
suggestionLabelConflicts(
"800 Hennepin Avenue Bridge, Minneapolis, MN, USA",
{ street: "800 Hennepin Avenue", city: "Minneapolis", state: "MN", zip: "" },
"800 Hennepin Avenue, Minneapolis, MN, USA",
),
).toBe(true);
});
});

@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { FIND_MOTHERSHIP_SUGGESTION_TARGET } from "@/workers/rpa/mothership-styled-suggestion-click";
describe("mothership-styled-suggestion-click", () => {
it("FIND_MOTHERSHIP_SUGGESTION_TARGET 为纯字符串locator 点击路径)", () => {
expect(typeof FIND_MOTHERSHIP_SUGGESTION_TARGET).toBe("string");
expect(FIND_MOTHERSHIP_SUGGESTION_TARGET).toContain("use-locator-click");
});
});

@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
const calls: string[] = [];
vi.mock("@/workers/rpa/kernel/steps/cargo-step", () => ({
fillCargoStep: vi.fn(async () => {
calls.push("cargo-step");
}),
}));
import { fillCargo } from "@/workers/rpa/mothership";
import { fillCargoStep } from "@/workers/rpa/kernel/steps/cargo-step";
const probeReq = {
cargoHash: "t",
pickup: {} as never,
delivery: {} as never,
weightLb: 500,
dimsIn: { l: 48, w: 40, h: 48 },
palletCount: 2,
cargoType: "general_freight",
};
describe("fillCargo thin wrapper", () => {
it("委托 kernel fillCargoStep", async () => {
calls.length = 0;
await fillCargo({} as Page, probeReq);
expect(calls).toEqual(["cargo-step"]);
expect(fillCargoStep).toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save