- 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
162 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|