// Copyright (c) 未来飞马 // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // Trademark Notice: // The MPL-2.0 license grants copyright permissions for source code only. // It does NOT grant any rights to use trademarks including "未来飞马", // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步". // Any use of these trademarks requires separate written permission. /** * 分组 / 顺序 单元测试:`node --test` */ import test from 'node:test'; import assert from 'node:assert/strict'; import { aspectSimilarity, groupMaterials, inferLayout, orderWithinGroup, textSimilarity, timeProximity, applyGroupOverrides, DEFAULT_ORDERING_RULE, } from '../grouping.mjs'; import { reorderAssets } from '../lib.mjs'; import { makeTinyPng } from '../case-intake.mjs'; const BASE = 1_700_000_000_000; function grid(count, overrides = {}) { return Array.from({ length: count }, (_, i) => ({ kind: 'image', localPath: `/tmp/g${String(i + 1).padStart(2, '0')}.png`, width: 400, height: 400, mtimeMs: BASE + i * 1000, ocrText: '帝国理工 计算机科学 考前冲刺 提分', ...overrides, })); } test('textSimilarity:完全相同为 1,无关文本接近 0', () => { assert.equal(textSimilarity('帝国理工提分', '帝国理工提分'), 1); assert.ok(textSimilarity('帝国理工提分案例', '帝国理工提分反馈') > 0.3); assert.ok(textSimilarity('帝国理工提分', '完全无关的另一段内容') < 0.2); }); test('aspectSimilarity:同比例为 1,缺失尺寸返回 null', () => { assert.equal(aspectSimilarity({ width: 400, height: 400 }, { width: 800, height: 800 }), 1); assert.equal(aspectSimilarity({ width: 400, height: 400 }, {}), null); assert.ok(aspectSimilarity({ width: 400, height: 400 }, { width: 1600, height: 900 }) < 0.8); }); test('timeProximity:窗口内衰减,窗口外为 0', () => { const a = { mtimeMs: BASE }; assert.equal(timeProximity(a, { mtimeMs: BASE }, 60_000), 1); assert.ok(timeProximity(a, { mtimeMs: BASE + 30_000 }, 60_000) === 0.5); assert.equal(timeProximity(a, { mtimeMs: BASE + 120_000 }, 60_000), 0); }); test('inferLayout:9→3x3 / 4→2x2 / 6→3x2 / 1→1x1', () => { assert.equal(inferLayout(9), '3x3'); assert.equal(inferLayout(4), '2x2'); assert.equal(inferLayout(6), '3x2'); assert.equal(inferLayout(1), '1x1'); }); test('九宫格:9 张同批图 → 1 组,layout 3x3,order 1..9(左上→右下)', () => { const groups = groupMaterials(grid(9)); assert.equal(groups.length, 1); assert.equal(groups[0].layout, '3x3'); assert.equal(groups[0].count, 9); assert.equal(groups[0].orderingRule, DEFAULT_ORDERING_RULE); assert.deepEqual(groups[0].items.map((i) => i.order), [1, 2, 3, 4, 5, 6, 7, 8, 9]); assert.deepEqual(groups[0].items.map((i) => i.groupId), Array(9).fill(groups[0].groupId)); }); test('6 张连发图 → 1 组 3x2(同事连发 6 张是同一个素材)', () => { const groups = groupMaterials(grid(6)); assert.equal(groups.length, 1); assert.equal(groups[0].layout, '3x2'); }); test('不同批次不误并组:尺寸/时间/内容都不同 → 2 组', () => { const items = [ ...grid(3), { kind: 'image', localPath: '/tmp/z1.png', width: 1600, height: 900, mtimeMs: BASE - 5 * 86_400_000, ocrText: '香港大学 申诉', }, ]; const groups = groupMaterials(items); assert.equal(groups.length, 2); assert.deepEqual(groups.map((g) => g.count).sort(), [1, 3]); }); test('尺寸 + 内容两个信号一致 → 并组(时间是弱信号,不足即被 2/3 规则覆盖)', () => { const items = grid(2); items[1] = { ...items[1], mtimeMs: BASE + 5 * 86_400_000 }; const groups = groupMaterials(items); assert.equal(groups.length, 1, 'aspect + text 两个信号一致即同组'); }); test('只有 1 个信号一致 → 不并组(避免把不同素材误并成一组)', () => { const items = [ ...grid(1), { kind: 'image', localPath: '/tmp/other.png', width: 1600, height: 900, mtimeMs: BASE + 5 * 86_400_000, ocrText: '帝国理工 计算机科学 考前冲刺 提分', }, ]; assert.equal(groupMaterials(items).length, 2, '仅内容相似、尺寸与时间都不同 → 判为两组'); }); test('裸图无 OCR:靠尺寸 + 时间仍能成组', () => { const items = grid(4, { ocrText: '' }); const groups = groupMaterials(items); assert.equal(groups.length, 1); assert.equal(groups[0].count, 4); }); test('orderWithinGroup:按时间升序还原发送顺序;无时间时文件名自然序', () => { const withTime = [ { localPath: '/tmp/b.png', mtimeMs: BASE + 2000 }, { localPath: '/tmp/a.png', mtimeMs: BASE + 1000 }, ]; assert.deepEqual(orderWithinGroup(withTime).map((i) => i.localPath), ['/tmp/a.png', '/tmp/b.png']); const noTime = [ { localPath: '/tmp/img10.png' }, { localPath: '/tmp/img2.png' }, ]; assert.deepEqual(orderWithinGroup(noTime).map((i) => i.localPath), ['/tmp/img2.png', '/tmp/img10.png']); }); test('reorderAssets:把第 9 张移到第 1 位后重新编号 1..9,元素完整', () => { const groups = groupMaterials(grid(9)); const before = groups[0].items; const after = reorderAssets(before, 9, 1); assert.equal(after[0].localPath, '/tmp/g09.png'); assert.deepEqual(after.map((a) => a.order), [1, 2, 3, 4, 5, 6, 7, 8, 9]); assert.equal(after.length, 9); assert.deepEqual(new Set(after.map((a) => a.localPath)).size, 9); }); test('reorderAssets:越界报错', () => { assert.throws(() => reorderAssets(grid(3), 5, 1), /越界/); }); test('applyGroupOverrides:人工指定分组与顺序优先', () => { const groups = groupMaterials(grid(4)); const overridden = applyGroupOverrides(groups, { groups: [{ groupId: 'manual', items: [ { localPath: '/tmp/g04.png', order: 1 }, { localPath: '/tmp/g01.png', order: 2 }, ], }], }); const manual = overridden.find((g) => g.groupId === 'manual'); assert.ok(manual); assert.deepEqual(manual.items.map((i) => i.localPath), ['/tmp/g04.png', '/tmp/g01.png']); assert.deepEqual(manual.reasons, ['manual-override']); }); test('makeTinyPng 产出可被 image-size 识别的合法 PNG', async () => { const fs = await import('node:fs'); const os = await import('node:os'); const path = await import('node:path'); const { readImageInfo } = await import('../image-size.mjs'); const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'caseget-')), 'p.png'); fs.writeFileSync(file, makeTinyPng(320, 240)); const info = readImageInfo(file); assert.equal(info.ok, true); assert.equal(info.ext, 'png'); assert.equal(info.width, 320); assert.equal(info.height, 240); });