grouping.test.mjs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 分组 / 顺序 单元测试:`node --test`
  14. */
  15. import test from 'node:test';
  16. import assert from 'node:assert/strict';
  17. import {
  18. aspectSimilarity, groupMaterials, inferLayout, orderWithinGroup,
  19. textSimilarity, timeProximity, applyGroupOverrides, DEFAULT_ORDERING_RULE,
  20. } from '../grouping.mjs';
  21. import { reorderAssets } from '../lib.mjs';
  22. import { makeTinyPng } from '../case-intake.mjs';
  23. const BASE = 1_700_000_000_000;
  24. function grid(count, overrides = {}) {
  25. return Array.from({ length: count }, (_, i) => ({
  26. kind: 'image',
  27. localPath: `/tmp/g${String(i + 1).padStart(2, '0')}.png`,
  28. width: 400,
  29. height: 400,
  30. mtimeMs: BASE + i * 1000,
  31. ocrText: '帝国理工 计算机科学 考前冲刺 提分',
  32. ...overrides,
  33. }));
  34. }
  35. test('textSimilarity:完全相同为 1,无关文本接近 0', () => {
  36. assert.equal(textSimilarity('帝国理工提分', '帝国理工提分'), 1);
  37. assert.ok(textSimilarity('帝国理工提分案例', '帝国理工提分反馈') > 0.3);
  38. assert.ok(textSimilarity('帝国理工提分', '完全无关的另一段内容') < 0.2);
  39. });
  40. test('aspectSimilarity:同比例为 1,缺失尺寸返回 null', () => {
  41. assert.equal(aspectSimilarity({ width: 400, height: 400 }, { width: 800, height: 800 }), 1);
  42. assert.equal(aspectSimilarity({ width: 400, height: 400 }, {}), null);
  43. assert.ok(aspectSimilarity({ width: 400, height: 400 }, { width: 1600, height: 900 }) < 0.8);
  44. });
  45. test('timeProximity:窗口内衰减,窗口外为 0', () => {
  46. const a = { mtimeMs: BASE };
  47. assert.equal(timeProximity(a, { mtimeMs: BASE }, 60_000), 1);
  48. assert.ok(timeProximity(a, { mtimeMs: BASE + 30_000 }, 60_000) === 0.5);
  49. assert.equal(timeProximity(a, { mtimeMs: BASE + 120_000 }, 60_000), 0);
  50. });
  51. test('inferLayout:9→3x3 / 4→2x2 / 6→3x2 / 1→1x1', () => {
  52. assert.equal(inferLayout(9), '3x3');
  53. assert.equal(inferLayout(4), '2x2');
  54. assert.equal(inferLayout(6), '3x2');
  55. assert.equal(inferLayout(1), '1x1');
  56. });
  57. test('九宫格:9 张同批图 → 1 组,layout 3x3,order 1..9(左上→右下)', () => {
  58. const groups = groupMaterials(grid(9));
  59. assert.equal(groups.length, 1);
  60. assert.equal(groups[0].layout, '3x3');
  61. assert.equal(groups[0].count, 9);
  62. assert.equal(groups[0].orderingRule, DEFAULT_ORDERING_RULE);
  63. assert.deepEqual(groups[0].items.map((i) => i.order), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
  64. assert.deepEqual(groups[0].items.map((i) => i.groupId), Array(9).fill(groups[0].groupId));
  65. });
  66. test('6 张连发图 → 1 组 3x2(同事连发 6 张是同一个素材)', () => {
  67. const groups = groupMaterials(grid(6));
  68. assert.equal(groups.length, 1);
  69. assert.equal(groups[0].layout, '3x2');
  70. });
  71. test('不同批次不误并组:尺寸/时间/内容都不同 → 2 组', () => {
  72. const items = [
  73. ...grid(3),
  74. {
  75. kind: 'image', localPath: '/tmp/z1.png', width: 1600, height: 900,
  76. mtimeMs: BASE - 5 * 86_400_000, ocrText: '香港大学 申诉',
  77. },
  78. ];
  79. const groups = groupMaterials(items);
  80. assert.equal(groups.length, 2);
  81. assert.deepEqual(groups.map((g) => g.count).sort(), [1, 3]);
  82. });
  83. test('尺寸 + 内容两个信号一致 → 并组(时间是弱信号,不足即被 2/3 规则覆盖)', () => {
  84. const items = grid(2);
  85. items[1] = { ...items[1], mtimeMs: BASE + 5 * 86_400_000 };
  86. const groups = groupMaterials(items);
  87. assert.equal(groups.length, 1, 'aspect + text 两个信号一致即同组');
  88. });
  89. test('只有 1 个信号一致 → 不并组(避免把不同素材误并成一组)', () => {
  90. const items = [
  91. ...grid(1),
  92. {
  93. kind: 'image', localPath: '/tmp/other.png', width: 1600, height: 900,
  94. mtimeMs: BASE + 5 * 86_400_000, ocrText: '帝国理工 计算机科学 考前冲刺 提分',
  95. },
  96. ];
  97. assert.equal(groupMaterials(items).length, 2, '仅内容相似、尺寸与时间都不同 → 判为两组');
  98. });
  99. test('裸图无 OCR:靠尺寸 + 时间仍能成组', () => {
  100. const items = grid(4, { ocrText: '' });
  101. const groups = groupMaterials(items);
  102. assert.equal(groups.length, 1);
  103. assert.equal(groups[0].count, 4);
  104. });
  105. test('orderWithinGroup:按时间升序还原发送顺序;无时间时文件名自然序', () => {
  106. const withTime = [
  107. { localPath: '/tmp/b.png', mtimeMs: BASE + 2000 },
  108. { localPath: '/tmp/a.png', mtimeMs: BASE + 1000 },
  109. ];
  110. assert.deepEqual(orderWithinGroup(withTime).map((i) => i.localPath), ['/tmp/a.png', '/tmp/b.png']);
  111. const noTime = [
  112. { localPath: '/tmp/img10.png' },
  113. { localPath: '/tmp/img2.png' },
  114. ];
  115. assert.deepEqual(orderWithinGroup(noTime).map((i) => i.localPath), ['/tmp/img2.png', '/tmp/img10.png']);
  116. });
  117. test('reorderAssets:把第 9 张移到第 1 位后重新编号 1..9,元素完整', () => {
  118. const groups = groupMaterials(grid(9));
  119. const before = groups[0].items;
  120. const after = reorderAssets(before, 9, 1);
  121. assert.equal(after[0].localPath, '/tmp/g09.png');
  122. assert.deepEqual(after.map((a) => a.order), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
  123. assert.equal(after.length, 9);
  124. assert.deepEqual(new Set(after.map((a) => a.localPath)).size, 9);
  125. });
  126. test('reorderAssets:越界报错', () => {
  127. assert.throws(() => reorderAssets(grid(3), 5, 1), /越界/);
  128. });
  129. test('applyGroupOverrides:人工指定分组与顺序优先', () => {
  130. const groups = groupMaterials(grid(4));
  131. const overridden = applyGroupOverrides(groups, {
  132. groups: [{
  133. groupId: 'manual',
  134. items: [
  135. { localPath: '/tmp/g04.png', order: 1 },
  136. { localPath: '/tmp/g01.png', order: 2 },
  137. ],
  138. }],
  139. });
  140. const manual = overridden.find((g) => g.groupId === 'manual');
  141. assert.ok(manual);
  142. assert.deepEqual(manual.items.map((i) => i.localPath), ['/tmp/g04.png', '/tmp/g01.png']);
  143. assert.deepEqual(manual.reasons, ['manual-override']);
  144. });
  145. test('makeTinyPng 产出可被 image-size 识别的合法 PNG', async () => {
  146. const fs = await import('node:fs');
  147. const os = await import('node:os');
  148. const path = await import('node:path');
  149. const { readImageInfo } = await import('../image-size.mjs');
  150. const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'caseget-')), 'p.png');
  151. fs.writeFileSync(file, makeTinyPng(320, 240));
  152. const info = readImageInfo(file);
  153. assert.equal(info.ok, true);
  154. assert.equal(info.ext, 'png');
  155. assert.equal(info.width, 320);
  156. assert.equal(info.height, 240);
  157. });