import { afterAll, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { decodePng, dHash, hamming, hashFile, hashGray, imageSimilarity, pHash, resize } from '../cli/lib/imagehash'; import { compare, findApps, fingerprint, jaccard, nameSimilarity, projectStructure, report, screenshotSimilarity, textSimilarity, words } from '../cli/lib/originality'; import { loadConfig } from '../cli/lib/config'; import { encodePng } from './build-fixtures'; const ROOT = join(import.meta.dir, '..'); const tmp = mkdtempSync(join(tmpdir(), 'sb-orig-')); afterAll(() => rmSync(tmp, { recursive: true, force: true })); const put = (base: string, rel: string, data: string | Buffer): void => { mkdirSync(join(base, rel, '..'), { recursive: true }); writeFileSync(join(base, rel), data); }; // pictures: a disc on a gradient (icon A), the same disc shifted in hue (a recolour), a striped square (another app) const disc = (hue: number) => (x: number, y: number): [number, number, number, number] => { const inside = (x - 32) ** 2 + (y - 30) ** 2 < 18 ** 2; return inside ? [(200 + hue) % 256, 60, (40 + hue) % 256, 255] : [20 + x, 30 + y, 120, 255]; }; const stripes = (x: number, y: number): [number, number, number, number] => ((Math.floor(x / 8) + Math.floor(y / 16)) % 2 ? [240, 240, 240, 255] : [10, 80, 30, 255]); const scene = (k: number) => (x: number, y: number): [number, number, number, number] => { const bar = y > 20 + k * 15 && y < 40 + k * 15; return bar ? [250, 200, 0, 255] : [30, 30 + x, 90 + k * 30, 255]; }; describe('image hashes', () => { test('PNG decoding: RGBA, RGB, grey, Sub filter, alpha over white', () => { const rgba = decodePng(encodePng(4, 2, (x, y) => [x * 60, y * 100, 0, 255])); expect(rgba.w).toBe(4); expect(Math.round(rgba.px[1]!)).toBe(Math.round(0.299 * 60)); const filtered = decodePng(encodePng(4, 2, (x, y) => [x * 60, y * 100, 0, 255], { filter: 1 })); expect([...filtered.px]).toEqual([...rgba.px]); expect(Math.round(decodePng(encodePng(1, 1, () => [0, 0, 0, 0])).px[0]!)).toBe(255); expect(Math.round(decodePng(encodePng(1, 1, () => [100, 100, 100], { colorType: 0 })).px[0]!)).toBe(100); expect(Math.round(decodePng(encodePng(1, 1, () => [0, 255, 0], { colorType: 2 })).px[0]!)).toBe(Math.round(0.587 * 255)); expect(() => decodePng(Buffer.from('GIF89a........'))).toThrow('not a PNG'); }); test('same picture at another size hashes the same; a recolour is close; another picture is far', () => { const a = hashGray(decodePng(encodePng(64, 64, disc(0)))); const big = hashGray(decodePng(encodePng(128, 128, (x, y) => disc(0)(x / 2, y / 2)))); const recolour = hashGray(decodePng(encodePng(64, 64, disc(25)))); const other = hashGray(decodePng(encodePng(64, 64, stripes))); expect(imageSimilarity(a, a)).toBe(1); expect(imageSimilarity(a, big)).toBeGreaterThan(0.9); expect(imageSimilarity(a, recolour)).toBeGreaterThan(0.8); expect(imageSimilarity(a, other)).toBeLessThan(0.75); expect(dHash(resize(decodePng(encodePng(8, 8, stripes)), 32, 32))).toHaveLength(16); expect(pHash(decodePng(encodePng(8, 8, stripes)))).toHaveLength(16); expect(hamming('ffff', '0000')).toBe(16); }); test('hashFile: PNG in TypeScript; other files say why when they cannot be read', () => { const d = join(tmp, 'files'); put(d, 'a.png', encodePng(16, 16, stripes)); put(d, 'b.jpg', Buffer.from('not really a jpeg')); expect(hashFile(join(d, 'a.png')).hash).not.toBeNull(); const j = hashFile(join(d, 'b.jpg')); expect(j.hash).toBeNull(); expect(j.reason).toBeTruthy(); }); }); describe('text and structure', () => { test('words, cosine + shingles, names', () => { expect(words('The FREE app: Merge, match & win!')).toEqual(['merge', 'match', 'win']); const a = 'Merge fruit tiles to grow a garden. Daily puzzles, calm music and hundreds of levels to relax.'; const copied = 'Merge fruit tiles to grow a garden. Daily puzzles, calm music and hundreds of levels to unwind.'; const other = 'Track your running pace and heart rate with GPS, plan intervals and share routes with friends.'; expect(textSimilarity(a, copied).score).toBeGreaterThan(0.8); expect(textSimilarity(a, other).score).toBeLessThan(0.1); expect(nameSimilarity('Fruit Merge Garden', 'Fruit Merge Garden 2')).toBeGreaterThan(0.85); expect(nameSimilarity('Fruit Merge', 'Pace Runner')).toBeLessThan(0.3); expect(jaccard(new Set([1, 2]), new Set([2, 3]))).toBeCloseTo(1 / 3); expect(screenshotSimilarity([], [])).toBeNull(); }); test('project structure skips build output and dependencies', () => { const d = join(tmp, 'struct'); put(d, 'src/main.ts', 'console.log(1)'); put(d, 'node_modules/x/index.js', 'x'); put(d, 'android/app/build/outputs/a.aab', 'x'); put(d, '.git/HEAD', 'ref'); const s = projectStructure(d); expect([...s.paths]).toEqual(['src/main.ts']); expect(s.contents.size).toBe(1); }); }); /** One app folder: config, App Store listing, icon, three screenshots, a few source files. */ function app(dir: string, o: { name: string; bundle: string; subtitle: string; description: string; keywords: string; icon: (x: number, y: number) => [number, number, number, number]; scene: (k: number) => (x: number, y: number) => [number, number, number, number]; src: Record }): string { put(dir, 'storebound.config.ts', `export default { app: { name: ${JSON.stringify(o.name)}, privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' }, languages: [{ id: 'en', apple: 'en-US' }], apple: { bundleId: '${o.bundle}' }, renders: 'store/renders' };\n`); put(dir, 'store/appstore.md', `# listing\n\n## en-US\n\n### subtitle\n\`\`\`\n${o.subtitle}\n\`\`\`\n\n### promotionalText\n\`\`\`\n${o.subtitle}\n\`\`\`\n\n### keywords\n\`\`\`\n${o.keywords}\n\`\`\`\n\n### description\n\`\`\`\n${o.description}\n\`\`\`\n`); put(dir, 'store/icon.png', encodePng(64, 64, o.icon)); for (let k = 0; k < 3; k++) put(dir, `store/renders/en/phone-6.9/0${k + 1}.png`, encodePng(48, 96, o.scene(k))); for (const [f, t] of Object.entries(o.src)) put(dir, f, t); return dir; } const engine = { 'src/engine.ts': 'export const grid = 8; // merge engine\n'.repeat(20), 'src/levels.json': JSON.stringify(Array.from({ length: 40 }, (_, i) => ({ i, goal: i * 10 }))), 'src/ui/board.ts': 'export function board() { return 1 }\n' }; describe('originality report', () => { const ws = join(tmp, 'ws'); const me = app(join(ws, 'fruit'), { name: 'Fruit Merge Garden', bundle: 'com.example.fruit', subtitle: 'Merge fruit, grow a garden', keywords: 'merge,fruit,garden,puzzle,relax', description: 'Merge fruit tiles to grow a garden. Daily puzzles, calm music and hundreds of levels to relax after work.', icon: disc(0), scene, src: { ...engine, 'src/theme.ts': 'export const theme = "fruit";' } }); app(join(ws, 'candy'), { name: 'Candy Merge Garden', bundle: 'com.example.candy', subtitle: 'Merge candy, grow a garden', keywords: 'merge,candy,garden,puzzle,relax', description: 'Merge candy tiles to grow a garden. Daily puzzles, calm music and hundreds of levels to relax after work.', icon: disc(30), scene, src: { ...engine, 'src/theme.ts': 'export const theme = "candy";' } }); app(join(ws, 'pace'), { name: 'Pace Runner', bundle: 'com.example.pace', subtitle: 'Running pace and heart rate', keywords: 'run,pace,gps,interval,heart', description: 'Track your running pace and heart rate with GPS, plan intervals and share routes with friends.', icon: stripes, scene: k => (x, y) => (Math.floor((x + k * 7) / 6) % 2 ? [255, 255, 255, 255] : [y * 2, 0, 0, 255]), src: { 'App/Run.swift': 'struct Run {}', 'App/Map.swift': 'struct Map {}' } }); test('a reskin is flagged with reasons and suggestions; a different app is not', async () => { const self = (await loadConfig(join(me, 'storebound.config.ts'))).config; const others = findApps([ws], me); expect(others.map(p => p.split('/').slice(-2, -1)[0])).toEqual(['candy', 'pace']); const fps = await Promise.all(others.map(async p => { const l = await loadConfig(p); return fingerprint(l.config, l.root); })); const r = report(fingerprint(self, me), fps); const candy = r.pairs.find(p => p.other.name === 'Candy Merge Garden')!; const pace = r.pairs.find(p => p.other.name === 'Pace Runner')!; expect(candy.flagged).toBe(true); expect(candy.overall).toBeGreaterThan(0.7); expect(candy.axes.screenshots.score).toBe(1); expect(candy.axes.icon.score!).toBeGreaterThan(0.6); expect(candy.reasons.join()).toContain('screenshots'); expect(candy.suggestions.join('\n')).toContain('Unlike Candy Merge Garden'); expect(pace.flagged).toBe(false); expect(pace.overall).toBeLessThan(0.35); expect(r.pairs[0]!.other.name).toBe('Candy Merge Garden'); expect(r.flagged).toBe(1); expect(compare(fps[1]!, fps[1]!).overall).toBe(1); }); test('CLI: --json, exit 1 on a near-duplicate; text table otherwise', () => { const run = (args: string[]) => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'originality', ...args], { cwd: me, stdout: 'pipe', stderr: 'pipe' }); const j = run(['--against', '..', '--json']); expect(j.exitCode).toBe(1); const out = JSON.parse(j.stdout.toString()); expect(out.flagged).toBe(1); expect(out.pairs.map((p: { other: { name: string } }) => p.other.name)).toEqual(['Candy Merge Garden', 'Pace Runner']); const only = run(["--against", "../pace"]); expect(only.exitCode).toBe(0); expect(only.stdout.toString()).toContain('Pace Runner'); const none = run([]); expect(none.exitCode).toBe(1); expect(none.stderr.toString()).toContain('nothing to compare with'); }); test('scan --originality adds the 4.3 / repetitive content findings to the scan report', () => { const scan = (args: string[]) => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'scan', ...args], { cwd: me, stdout: 'pipe', stderr: 'pipe' }); const j = JSON.parse(scan(['--originality', '--against', '..', '--json']).stdout.toString()); const f = j.findings.filter((x: { rule: string }) => x.rule === 'originality'); expect(f.length).toBeGreaterThan(0); expect(f[0].guideline).toBe('4.3'); expect(f[0].title).toContain('Candy Merge Garden'); expect(f[0].skill).toBe('portfolio-originality'); expect(j.originality.flagged).toBe(1); expect(j.originality.pairs.map((p: { app: string }) => p.app)).toEqual(['Candy Merge Garden', 'Pace Runner']); const txt = scan(['--originality', '--against', '../pace']).stdout.toString(); expect(txt).toContain('Originality (4.3 / Play repetitive content): 1 app(s) compared, 0 near-duplicate(s)'); const plain = JSON.parse(scan(['--json']).stdout.toString()); expect(plain.originality).toBeUndefined(); expect(plain.findings.some((x: { rule: string }) => x.rule === 'originality')).toBe(false); }); });