// Run with node --test. Exercise the real SFC methods without a browser or API. const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const { parse } = require('@vue/compiler-sfc') const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue'), 'utf8') const script = parse(source).descriptor.script.content.replace(/import[\s\S]*?from ['"][^'"]+['"]/g, '').replace('export default', 'return') const product = id => ({ id, specContextVersion: 'v1', specs: [{ name: '颜色', role: 'color', values: [{ name: '黑', source: 'import' }] }] }) const reply = id => ({ data: { product: product(id), autoMatchTimeoutSeconds: 130 } }) const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } } function instance(get = async id => reply(id), post = async () => ({ data: {} })) { const options = new Function('getShopeeProduct', 'autoMatchShopeeSpecMappings', 'createRequestId', 'ElMessage', 'PddProductDetailDrawer', script)(get, post, () => 'id', { success() {}, warning() {} }, {}) const vm = { modelValue: true, productId: 1, $emit() {}, $confirm: async () => {}, ...options.methods } Object.assign(vm, options.data.call(vm)) Object.assign(vm, { loadedProductId: 1, detailDrawerOpen: true }) vm.detail.product = product(1); vm.detail.pddLoaded = true // Focus method lifecycle, not the already-covered candidate predicates. Object.defineProperty(vm, 'canAutoMatch', { get: () => !vm.detail.aiMatching && !vm.hasMappingChanges }) vm.hasMappingChanges = false return vm } const tick = () => new Promise(resolve => setImmediate(resolve)) test('close/reopen same product refreshes, old result/finally cannot clear a new operation', async () => { const first = deferred(), second = deferred(); let calls = 0, reads = 0 const vm = instance(async id => { reads++; return reply(id) }, () => (++calls === 1 ? first.promise : second.promise)) const old = vm.autoMatchMappings(); await tick() vm.modelValue = false; vm.closeInternals() assert.equal(vm.detail.aiMatching, false) vm.modelValue = true; await vm.requestSwitch(1) assert.equal(reads, 2) const fresh = vm.autoMatchMappings(); await tick() first.resolve({ data: { confirmedCount: 99 } }); await old assert.equal(vm.detail.aiMatching, true) assert.equal(vm.detail.matchMessage.includes('99'), false) second.resolve({ data: { confirmedCount: 1 } }); await fresh assert.equal(vm.detail.aiMatching, false) }) test('duplicate clicks do not send a second POST and dynamic budget is forwarded', async () => { const result = deferred(); let calls = 0, seconds const vm = instance(undefined, (id, data, budget) => { calls++; seconds = budget; return result.promise }) const running = vm.autoMatchMappings(); await tick(); await vm.autoMatchMappings() assert.equal(calls, 1); assert.equal(seconds, 130) result.resolve({ data: {} }); await running; assert.equal(vm.detail.aiMatching, false) }) for (const reason of ['timeout', 'Network Error', 'business failure']) { test(`${reason}: one readback, no automatic POST retry`, async () => { let reads = 0, posts = 0 const vm = instance(async id => { reads++; return reply(id) }, async () => { posts++; throw new Error(reason) }) await vm.autoMatchMappings() assert.equal(reads, 2); assert.equal(posts, 1); assert.equal(vm.detail.aiMatching, false) assert.match(vm.detail.matchMessage, /已刷新当前保存结果/) }) } test('readback failure ends busy with explicit unknown result', async () => { let reads = 0 const vm = instance(async id => { if (++reads > 1) throw new Error('offline'); return reply(id) }, async () => { throw new Error('timeout') }) await vm.autoMatchMappings(); assert.equal(vm.detail.aiMatching, false); assert.match(vm.detail.matchMessage, /结果暂未确认/) }) test('prepare failure or missing budget does not POST', async () => { for (const get of [async () => { throw new Error('offline') }, async id => ({ data: { product: product(id) } })]) { let posts = 0; const vm = instance(get, async () => { posts++ }) await vm.autoMatchMappings(); assert.equal(posts, 0); assert.equal(vm.detail.aiMatching, false) } }) test('switch while readback is pending never writes old data or messages', async () => { const pending = deferred(); let reads = 0 const vm = instance(async id => { if (++reads === 2) return pending.promise; return reply(id) }) const old = vm.autoMatchMappings(); await tick() vm.productId = 2; await vm.requestSwitch(2) pending.resolve(reply(1)); await old assert.equal(vm.detail.product.id, 2); assert.equal(vm.detail.matchMessage, ''); assert.equal(vm.detail.aiMatching, false) }) test('manual drafts prevent matching and refused discard prevents refresh', async () => { let calls = 0; const vm = instance(async id => { calls++; return reply(id) }) vm.hasMappingChanges = true; vm.$confirm = async () => { throw new Error('cancel') } await vm.autoMatchMappings(); await vm.reload(); assert.equal(calls, 0) }) test('API uses derived time plus margin only for auto-match; suggestions stay unchanged', async () => { const apiSource = fs.readFileSync(path.join(__dirname, '../../src/api/goauto/shopee-products.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace(/export function/g, 'function') const calls = [] const api = new Function('request', apiSource + '\nreturn { autoMatchShopeeSpecMappings, suggestShopeeColorMappings, suggestShopeeSizeMappings };')(config => { calls.push(config); return Promise.resolve({}) }) for (const seconds of [16, 130, 370, 1210]) { await api.autoMatchShopeeSpecMappings(1, {}, seconds) assert.equal(calls.at(-1).timeout, (seconds + 10) * 1000) } await assert.rejects(api.autoMatchShopeeSpecMappings(1, {}, undefined)) assert.equal(calls.length, 4) await api.suggestShopeeColorMappings(1, {}); assert.equal(calls.at(-1).timeout, 610000) await api.suggestShopeeSizeMappings(1, {}); assert.equal(calls.at(-1).timeout, 610000) })