const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/quick-link.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return') const row = { id: 1, shopeeProductId: 11 } const tick = () => new Promise(resolve => setImmediate(resolve)) const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } } function instance(overrides = {}) { const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 } const dependencies = { ElMessage: { warning() {} }, ElMessageBox: { confirm: async () => {}, close() {} }, listDevices: async () => ({ data: { items: [{ id: 10, status: 'offline', name: 'A' }, { id: 20, status: 'online', name: 'B' }] } }), getShopeeProduct: async id => { calls.reads++; return { data: { product: { id, specContextVersion: 'before', pddProductId: calls.link.length ? 55 : null }, autoMatchTimeoutSeconds: 370 } } }, linkShopeeProductPdd: async (id, body) => { calls.link.push(body); return { data: { product: { id, specContextVersion: 'after' }, collectionSource: { taskId: 9, pddProductId: 55 } } } }, autoMatchShopeeSpecMappings: async (id, body, seconds) => { calls.match.push({ id, body, seconds }); return { data: { unmatchedCount: 0 } } }, previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: true }] } }), createRequestId: () => 'request-id', readQuickLinkDevice: () => null, rememberQuickLinkDevice: (user, id) => { calls.saved.push(id); return true }, ...overrides } const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies)) const vm = { ...options.methods, ...options.data(), canPurchase: true, products: [row], $store: { getters: { userId: 1 } }, purchaseReady: () => ({ processNextAction: 'open_pdd_link' }), loadPurchaseReadiness: async () => { calls.refresh++ } } return { vm, calls, options } } test('first selection continues original row and forwards frozen context / #254 budget', async () => { const { vm, calls } = instance() await vm.quickStart(row); assert.equal(calls.link.length, 0) vm.quickPicker.deviceId = 10; vm.quickSaveDevice(); await tick() assert.equal(calls.link.length, 1); assert.equal(calls.link[0].latestCollection.sybProductId, 1) assert.equal(calls.link[0].latestCollection.deviceId, 10) assert.equal(calls.match[0].body.specContextVersion, 'after'); assert.equal(calls.match[0].seconds, 370) assert.equal(vm.quickBusyId, null) }) test('switch saves shared preference only; cancel changes nothing', async () => { const { vm, calls } = instance(); vm.quickDeviceId = 10 await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickCancelPicker() assert.equal(vm.quickDeviceId, 10); assert.equal(calls.saved.length, 0) await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickSaveDevice() assert.equal(vm.quickDeviceId, 20); assert.equal(calls.link.length, 0) }) test('in-flight device is frozen and double click cannot duplicate POST', async () => { const wait = deferred(); let links = 0, device const { vm } = instance({ getShopeeProduct: () => wait.promise, linkShopeeProductPdd: async (id, body) => { links++; device = body.latestCollection.deviceId; return { data: { product: { specContextVersion: 'after' }, collectionSource: {} } } } }) vm.quickDeviceId = 10; const running = vm.quickStart(row) vm.quickDeviceId = 20; await vm.quickStart(row) wait.resolve({ data: { product: { specContextVersion: 'before' }, autoMatchTimeoutSeconds: 130 } }); await running assert.equal(links, 1); assert.equal(device, 10) }) test('leaving during prepare prevents later association / matching', async () => { const wait = deferred(); const { vm, calls, options } = instance({ getShopeeProduct: () => wait.promise }) vm.quickDeviceId = 10; const running = vm.quickStart(row); options.beforeUnmount.call(vm) wait.resolve({ data: { product: { specContextVersion: 'before' } } }); await running assert.equal(calls.link.length, 0); assert.equal(calls.match.length, 0) }) for (const error of ['timeout', 'Network Error', 'AI 匹配服务暂时不可用']) { test(`${error}: finite readback, end loading, no automatic write retry`, async () => { let matches = 0 const { vm, calls } = instance({ autoMatchShopeeSpecMappings: async () => { matches++; throw new Error(error) } }) vm.quickDeviceId = 10; await vm.quickStart(row) assert.equal(calls.link.length, 1); assert.equal(matches, 1); assert.equal(calls.reads, 2) assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /当前已有关联/) }) } test('failed recovery reports unknown and clears busy', async () => { const { vm } = instance({ getShopeeProduct: async () => { throw new Error('offline') } }) vm.quickDeviceId = 10; await vm.quickStart(row) assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /结果未确认/) }) test('definite candidate rejection explains cause without AI or a write retry', async () => { let links = 0 const { vm, calls } = instance({ linkShopeeProductPdd: async () => { links++; throw { response: { status: 409, data: { message: '最近一次临时采集不完整,请先补采' } } } } }) vm.quickDeviceId = 10; await vm.quickStart(row) assert.equal(links, 1); assert.equal(calls.match.length, 0); assert.equal(calls.reads, 1) assert.equal(vm.quickBusyId, null); assert.equal(vm.quickMessages[1], '最近一次临时采集不完整,请先补采') }) test('manual-required readiness retains association without AI call', async () => { const { vm, calls } = instance({ previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: false, eligible: false }] } }) }) vm.quickDeviceId = 10; await vm.quickStart(row) assert.equal(calls.link.length, 1); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /人工处理/) }) test('preference isolates user and API environment, handles storage failure', () => { const source = fs.readFileSync(path.join(__dirname, '../../src/utils/quick-link-device-preference.js'), 'utf8').replace(/export function/g, 'function') const map = new Map(); const process = { env: { VUE_APP_BASE_API: '/a' } } const storage = { getItem: k => map.get(k), setItem: (k, v) => map.set(k, v) } const p = new Function('localStorage', 'process', source + '; return {readQuickLinkDevice,rememberQuickLinkDevice}')(storage, process) p.rememberQuickLinkDevice(1, 10); assert.equal(p.readQuickLinkDevice(1), 10); assert.equal(p.readQuickLinkDevice(2), null) process.env.VUE_APP_BASE_API = '/b'; assert.equal(p.readQuickLinkDevice(1), null) storage.setItem = () => { throw new Error('denied') }; assert.equal(p.rememberQuickLinkDevice(1, 20), false) }) function replacementInstance(overrides = {}) { const writes = [], previews = [], confirms = [] const context = instance({ getShopeeProduct: async () => ({ data: { product: { pddProductId: 44, specContextVersion: 'old' }, autoMatchTimeoutSeconds: 130 } }), ElMessageBox: { confirm: async (...args) => { confirms.push(args) }, close() {} }, linkShopeeProductPdd: async (id, body) => { const r = body.latestCollection.replacement ;(r.preview ? previews : writes).push(body) return { data: { product: { specContextVersion: 'new', pddProductId: r.preview ? 44 : 55 }, collectionSource: { taskId: 9, pddProductId: 55, targetVersion: 'target', title: '新商品', previousTitle: '原商品', deviceName: 'A' } } } }, ...overrides }) context.vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: 'pdd_collection_failed' }) context.vm.quickDeviceId = 10 return { ...context, writes, previews, confirms } } test('replacement confirms once and submits the frozen source', async () => { const { vm, writes, previews, confirms, calls } = replacementInstance() await vm.quickStart(row) assert.equal(previews.length, 1); assert.equal(confirms.length, 1); assert.equal(writes.length, 1) assert.deepEqual(writes[0].latestCollection.replacement, { preview: false, expectedPddProductId: 44, taskId: 9, targetPddProductId: 55, targetVersion: 'target' }) assert.equal(calls.match.length, 1) }) test('cancel replacement never writes or matches', async () => { const { vm, writes, calls } = replacementInstance({ ElMessageBox: { confirm: async () => { throw 'cancel' } } }) await vm.quickStart(row) assert.equal(writes.length, 0); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /已取消/) }) test('same product continues matching without replacement confirmation', async () => { const { vm, confirms, calls } = replacementInstance({ linkShopeeProductPdd: async () => ({ data: { product: { specContextVersion: 'old' }, collectionSource: { taskId: 9, pddProductId: 44, targetVersion: 'same' } } }) }) await vm.quickStart(row) assert.equal(confirms.length, 0); assert.equal(calls.match.length, 1) }) test('failed replacement readback does not call old association a success', async () => { const { vm, calls } = replacementInstance({ linkShopeeProductPdd: async () => { throw new Error('timeout') } }) await vm.quickStart(row) assert.match(vm.quickMessages[1], /仍为原商品/); assert.equal(calls.match.length, 0) }) test('replacement is not shown for collecting, successful, unknown or other manual stages', () => { const { vm } = replacementInstance() for (const stage of ['pdd_collecting', 'purchase_succeeded', 'order_review', 'task_created', 'color_mapping', 'manual_action']) { vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: stage }) assert.equal(vm.quickCanLink(row), false, stage) } })