/** @jest-environment node */ import { alignRunsToZipEntriesByOrder, buildRunsWithLegacyIndex, buildServerPreviewFileName, groupPreviewRowsIntoRuns, matchRunsToZipEntries, parseLegacyPreviewFileName, parseTaskDetailPreviewFileName, } from '@/lib/task-detail-asset-edit' function row(id: string, fileName: string, sortOrder = 0): any { return { id, assetType: 'main-preview', fileUrl: `s3://${id}`, fileName, sortOrder, skuCode: null } } describe('任务-详情预览名解析', () => { test('客户端单图命名提取来源基础名', () => { expect(parseTaskDetailPreviewFileName('IMG_1001.webp')).toEqual({ label: 'IMG_1001', sliceIndex: null, }) }) test('客户端切片命名', () => { expect(parseTaskDetailPreviewFileName('长图-001.webp')).toEqual({ label: '长图', sliceIndex: 1, }) }) test('服务端生成命名携带来源锚', () => { const name = buildServerPreviewFileName('IMG_2002', 3) expect(parseTaskDetailPreviewFileName(name)).toEqual({ label: 'IMG_2002', sliceIndex: null, }) }) test('服务端长图切片命名可解析 sliceIndex', () => { const name = buildServerPreviewFileName('IMG_2002', 3, 1) expect(parseTaskDetailPreviewFileName(name)).toEqual({ label: 'IMG_2002', sliceIndex: 1, }) }) test('非 webp 返回 null', () => { expect(parseTaskDetailPreviewFileName('photo.jpg')).toBeNull() }) }) describe('任务-详情预览行切分', () => { test('相邻同名但独立的单预览保持独立 run', () => { const runs = groupPreviewRowsIntoRuns([ row('a', 'cover.webp', 0), row('b', 'cover.webp', 1), ]) expect(runs).not.toBeNull() expect(runs!.length).toBe(2) }) test('连续切片合并为一个 run,序号重置后开启新 run', () => { const runs = groupPreviewRowsIntoRuns([ row('a', 'detail-001.webp', 0), row('b', 'detail-002.webp', 1), row('c', 'detail-001.webp', 2), row('d', 'detail-002.webp', 3), ]) expect(runs).not.toBeNull() expect(runs!.length).toBe(2) expect(runs![0].rows.map((item: any) => item.id)).toEqual(['a', 'b']) expect(runs![1].rows.map((item: any) => item.id)).toEqual(['c', 'd']) }) test('历史同锚旧 __srv 无 slice 预览按相邻行兼容归并', () => { const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp' const runs = groupPreviewRowsIntoRuns([ row('a', oldSrv, 0), row('b', oldSrv, 1), row('c', '1_03-preview.webp', 2), ]) expect(runs).not.toBeNull() expect(runs!.length).toBe(2) expect(runs![0].rows.map((item: any) => item.id)).toEqual(['a', 'b']) }) test('出现无法解析的名字时整体放弃切分', () => { expect(groupPreviewRowsIntoRuns([row('a', 'unknown.png')])).toBeNull() }) }) describe('run 与 ZIP 条目的名字匹配', () => { const entryNames = ['IMG_A.jpg', 'IMG_B.png', 'IMG_C.jpg'] test('按基础名唯一命中(顺序无关)', () => { const runs = groupPreviewRowsIntoRuns([ row('a', 'IMG_C.webp', 0), row('b', 'IMG_A.webp', 1), row('c', 'IMG_B.webp', 2), ])! expect(matchRunsToZipEntries(runs, entryNames)).toEqual([2, 0, 1]) }) test('单个无锚 run 与剩余条目一一配对', () => { const srvName = buildServerPreviewFileName('IMG_B', 1) const runs = groupPreviewRowsIntoRuns([ row('a', 'IMG_A.webp', 0), row('b', srvName, 1), row('c', 'IMG_C.webp', 2), ])! expect(matchRunsToZipEntries(runs, entryNames)).toEqual([0, 1, 2]) }) test('两个以上无锚 run 拒绝编辑', () => { // 历史服务端兜底命名(旧格式,不带来源锚)无法确认对应关系 const runs = groupPreviewRowsIntoRuns([ row('a', 'main-preview-1736000000000-0-aaa0000.webp', 0), row('b', 'main-preview-1736000000000-1-bbb1111.webp', 1), ])! expect(() => matchRunsToZipEntries(runs, ['x.jpg', 'y.png'])).toThrow() }) test('数量不一致直接拒绝', () => { const runs = groupPreviewRowsIntoRuns([row('a', 'IMG_A.webp')])! expect(() => matchRunsToZipEntries(runs, entryNames)).toThrow() }) test('同名条目导致歧义时拒绝', () => { // 预览行只有一份 cover,但包里有两个同名条目 const runs = groupPreviewRowsIntoRuns([ row('a', 'cover.webp', 0), row('b', 'other.webp', 1), ])! expect(() => matchRunsToZipEntries(runs, ['cover.jpg', 'COVER.png'])).toThrow() }) test('真实形态:1_02 两个旧 __srv preview 对一个 ZIP 条目', () => { const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp' const runs = groupPreviewRowsIntoRuns([ row('a', '1_01-preview.webp', 0), row('b', oldSrv, 1), row('c', oldSrv, 2), row('d', '1_03-preview.webp', 3), ])! expect(runs.length).toBe(3) expect(runs[1].rows.map((item: any) => item.id)).toEqual(['b', 'c']) expect(matchRunsToZipEntries(runs, ['1_01.jpg', '1_02.jpg', '1_03.jpg'])).toEqual([0, 1, 2]) }) }) describe('旧代预览名(detail-preview-{时间戳}-{序号}-...)兼容', () => { const legacyName = (index: number, slice?: number, salt = 'abc1234') => `detail-preview-1785466728480-${index}${slice !== undefined ? `-slice${slice}` : ''}-${salt}.webp` test('解析旧代命名提取图片序号与切片', () => { expect(parseLegacyPreviewFileName(legacyName(0, 0))).toEqual({ imageIndex: 0, sliceIndex: 0 }) expect(parseLegacyPreviewFileName(legacyName(2))).toEqual({ imageIndex: 2, sliceIndex: null }) expect(parseLegacyPreviewFileName('main-preview-1785466728480-5-slice1-xyz99.png')).toBeNull() expect(parseLegacyPreviewFileName('详情_01.jpg')).toBeNull() }) test('纯旧代命名素材:序号升序对齐 ZIP 条目位置', () => { const rows = [0, 1, 2, 3].map((index) => row(`r${index}`, legacyName(index), index)) const runs = buildRunsWithLegacyIndex(rows) expect(runs).not.toBeNull() const aligned = alignRunsToZipEntriesByOrder(runs!, ['详情_01.jpg', '详情_02.jpg', '详情_03.jpg', '详情_04.jpg']) expect(aligned).toEqual([0, 1, 2, 3]) }) test('切片行归并进同序号 run', () => { const rows = [ row('a0', legacyName(0, 0), 0), row('a1', legacyName(0, 1), 1), row('b', legacyName(1), 2), ] const runs = buildRunsWithLegacyIndex(rows) expect(runs!.length).toBe(2) expect(runs![0].rows.length).toBe(2) const aligned = alignRunsToZipEntriesByOrder(runs!, ['详情_01.jpg', '详情_02.jpg']) expect(aligned).toEqual([0, 1]) }) test('混合命名(编辑后一张变服务端名):锚定 + 旧代序号共存', () => { const srvName = buildServerPreviewFileName('详情_02', 1) const rows = [ row('a', legacyName(0), 0), row('b', srvName, 1), row('c', legacyName(2), 2), ] const runs = buildRunsWithLegacyIndex(rows) const aligned = alignRunsToZipEntriesByOrder(runs!, ['详情_01.jpg', '详情_02.jpg', '详情_03.jpg']) expect(aligned).not.toBeNull() // srv 行锚定到条目 1,两条旧代行占 0 和 2 expect(aligned![runs!.findIndex((run) => run.legacyIndex === null)]).toBe(1) expect(aligned![0]).toBe(0) expect(aligned![2]).toBe(2) }) test('混合旧代序号与历史无 slice 的 __srv 双切片时仍可对齐', () => { const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp' const rows = [ row('a', legacyName(0), 0), row('b', oldSrv, 1), row('c', oldSrv, 2), row('d', legacyName(2), 3), ] const runs = buildRunsWithLegacyIndex(rows) expect(runs).not.toBeNull() expect(runs!.length).toBe(3) expect(runs![1].rows.map((item: any) => item.id)).toEqual(['b', 'c']) const aligned = alignRunsToZipEntriesByOrder(runs!, ['1_01.jpg', '1_02.jpg', '1_03.jpg']) expect(aligned).toEqual([0, 1, 2]) }) test('删除后再编辑:条目与序号同删,相对顺序对齐依然成立', () => { // 原 4 张删掉序号 1:剩条目 3 个、旧代序号 {0,2,3} const rows = [0, 2, 3].map((index) => row(`r${index}`, legacyName(index), index)) const runs = buildRunsWithLegacyIndex(rows) const aligned = alignRunsToZipEntriesByOrder(runs!, ['详情_01.jpg', '详情_03.jpg', '详情_04.jpg']) expect(aligned).toEqual([0, 1, 2]) }) test('序号重复直接拒绝', () => { const rows = [ row('a', legacyName(0, 0), 0), row('b', legacyName(0, 1), 1), ] const runs = buildRunsWithLegacyIndex(rows) expect(runs!.length).toBe(1) }) test('数量不一致拒绝', () => { const rows = [0, 1].map((index) => row(`r${index}`, legacyName(index), index)) const runs = buildRunsWithLegacyIndex(rows) expect(alignRunsToZipEntriesByOrder(runs!, ['详情_01.jpg'])).toBeNull() }) test('存在无法解析的行时返回 null(保持拒绝)', () => { const rows = [ row('a', legacyName(0), 0), row('b', '不是预览图.jpg', 1), ] expect(buildRunsWithLegacyIndex(rows)).toBeNull() }) test('新命名素材不受影响:走既有锚定路径', () => { const rows = [ row('a', 'photo-a.webp', 0), row('b', 'photo-b.webp', 1), ] const runs = buildRunsWithLegacyIndex(rows) expect(runs!.every((run) => run.legacyIndex === null)).toBe(true) const aligned = alignRunsToZipEntriesByOrder(runs!, ['photo-a.jpg', 'photo-b.jpg']) expect(aligned).toEqual([0, 1]) }) }) describe('历史客户端 -preview 尾巴命名兼容', () => { test('剥掉 -preview 尾巴得到来源锚', () => { expect(parseTaskDetailPreviewFileName('主图3-preview.webp')).toEqual({ label: '主图3', sliceIndex: null }) expect(parseTaskDetailPreviewFileName('4-preview.webp')).toEqual({ label: '4', sliceIndex: null }) expect(parseTaskDetailPreviewFileName('GBSFY-SJZ0030 (1)-preview.webp')).toEqual({ label: 'GBSFY-SJZ0030 (1)', sliceIndex: null }) }) test('真实形态:26 行 -preview 命名主图与 26 条目主锚定直接配对', () => { const labels = ['主图3 拷贝 3', '主图3 拷贝', '主图3', '主图4', '4', '5', '6', '7', '主图1 拷贝', '主图1', '主图2', '主图2拷贝', '1', '2', '3 拷贝 2', '3', '功能主图4', '功能主图5', '功能主图1', '功能主图2', '功能主图3', '功能主图3-2', '功能主图4-2', '功能主图5-2', '功能主图1-2', '功能主图2-2'] const rows = labels.map((label, index) => row(`r${index}`, `${label}-preview.webp`, index)) const runs = groupPreviewRowsIntoRuns(rows) expect(runs?.length).toBe(26) const exts = ['.png', '.png', '.png', '.png', '.png', '.png', '.jpg', '.jpg', '.png', '.png', '.jpg', '.jpg', '.png', '.png', '.png', '.png', '.png', '.png', '.png', '.png', '.png', '.png', '.png', '.png', '.png', '.png'] const entries = labels.map((label, i) => label + exts[i]) const matched = matchRunsToZipEntries(runs!, entries) expect(matched).toEqual(labels.map((_, i) => i)) }) test('新代命名不受 -preview 剥离影响', () => { // 源文件本身叫 photo-preview.jpg 的新代命名:剥尾后锚对不上条目 → 拒绝而非错配 expect(parseTaskDetailPreviewFileName('photo-preview.webp')).toEqual({ label: 'photo', sliceIndex: null }) const runs = groupPreviewRowsIntoRuns([row('a', 'photo-preview.webp', 0)]) // 单图场景:锚剥错后走「剩余一一配对」兜底,1 对 1 无歧义,仍指向唯一条目 expect(matchRunsToZipEntries(runs!, ['photo-preview.jpg'])).toEqual([0]) }) })