storebound/tests/originality.test.ts
CRT 236977940e flows, listing translate, scan 2.2 + --originality, prices export with subscriptions, site sections
- storebound flow list|validate|run: repo-local workflows (store/workflows/*.yml, storebound.workflows.*), writes dry unless --apply, owner steps never run, --from / --resume; init templates for app and game; MCP flow_list + dry flow_run; release-flows skill
- storebound listing translate|check|reviewed: per-language listing drafts with store limits and keyword rules, agent or --translate hook, marked until reviewed; MCP listing_translate / listing_check; listing-localization skill
- scan: Apple 2.2 beta/demo/test wording rule; scan --originality [--against]
- prices export includes subscriptions; prices import skips their rows
- site: rejection scan section, Desktop app section with fixture-mode screenshots, Everything else grid, FAQ 4.3 answer
- README, CHANGELOG, adapters, API tables
2026-10-12 01:24:21 +08:00

162 lines
11 KiB
TypeScript

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, string> }): 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);
});
});