mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
* feat(career): profile passport wall, home career card, shareable PNG card Career v3, WS2. The identity artifact leaves the plugin tab: - Profile: #v3-profile-passports-mount (core, one div) filled by career on v3:profile-rendered — per-instrument shelves of earned covers, hours, gig count, open-career link. Absent-not-empty. - Home: the plugin-count stat tile becomes #v3-dash-career-slot with the old stat as fallback content; career replaces it with a trading-card tile (leather + foil shine, badge count, hours, closest-stamp ask) on the existing v3:dashboard-rendered event. - Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom duplicated verbatim in settings-io/diagnostics-export — both refactored; copyImageBlob wraps ClipboardItem, returns false to signal the download fallback). Earned passports get Save/Copy card: a natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line); copy falls back to download with a notice when the clipboard refuses. - Mount-point convention documented in docs/plugin-v3-ui.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): external surfaces stay absent until a passport exists CodeRabbit on #955: a bare commitment produced a zero-passport wall and replaced the dashboard fallback. Docs also now say mounts may hold fallback content and plugins REPLACE, never append. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
180 lines
7.5 KiB
JavaScript
180 lines
7.5 KiB
JavaScript
// Passport UI pure-logic tests: load screen.js in a bare vm window and
|
|
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
function load(seed) {
|
|
const store = Object.assign({}, seed);
|
|
const window = {
|
|
console,
|
|
localStorage: {
|
|
getItem: (k) => (k in store ? store[k] : null),
|
|
setItem: (k, v) => { store[k] = String(v); },
|
|
},
|
|
document: {
|
|
readyState: 'complete',
|
|
getElementById: () => null,
|
|
querySelectorAll: () => [],
|
|
addEventListener: () => {},
|
|
},
|
|
notifications: [],
|
|
};
|
|
window.window = window;
|
|
window.globalThis = window;
|
|
window.fbNotify = { show: (n) => window.notifications.push(n) };
|
|
const context = vm.createContext(window);
|
|
// `document` and `localStorage` resolve as bare names inside the IIFE.
|
|
context.document = window.document;
|
|
context.localStorage = window.localStorage;
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
|
vm.runInContext(src, context, { filename: 'career/screen.js' });
|
|
return window;
|
|
}
|
|
|
|
test('module loads (and boots) in a bare vm window', () => {
|
|
const w = load();
|
|
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
|
|
});
|
|
|
|
test('ppKey normalizes case and whitespace', () => {
|
|
const { ppKey } = load().__careerPassportTest;
|
|
assert.equal(ppKey(' Blues Rock '), 'blues rock');
|
|
assert.equal(ppKey('FUNK'), 'funk');
|
|
assert.equal(ppKey(''), '');
|
|
assert.equal(ppKey(null), '');
|
|
});
|
|
|
|
test('ppJitter is deterministic and bounded', () => {
|
|
const { ppJitter } = load().__careerPassportTest;
|
|
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
|
|
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
|
|
const j = ppJitter(seed, 8);
|
|
assert.ok(j >= -8 && j <= 8, `${seed} → ${j}`);
|
|
}
|
|
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
|
|
});
|
|
|
|
test('detectNewBadges notifies once per badge, never after it is seen', () => {
|
|
const w = load();
|
|
const t = w.__careerPassportTest;
|
|
const view = {
|
|
instruments: {
|
|
guitar: {
|
|
passports: [
|
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
|
|
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
t.detectNewBadges(view);
|
|
assert.equal(w.notifications.length, 1);
|
|
assert.match(w.notifications[0].message, /Blues/);
|
|
// Same view again in the same session: no duplicate notification.
|
|
t.detectNewBadges(view);
|
|
assert.equal(w.notifications.length, 1);
|
|
// Seen (slam played) → a fresh session stays quiet too.
|
|
t.markBadgeSeen('guitar', 'blues');
|
|
// JSON-compare: vm objects carry a foreign Object prototype.
|
|
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
|
|
|
|
// Fresh session (new vm, empty notify cache) with the badge already seen:
|
|
// detection must stay silent.
|
|
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
|
w2.__careerPassportTest.detectNewBadges(view);
|
|
assert.equal(w2.notifications.length, 0);
|
|
});
|
|
|
|
test('a new badge triggers the crowd celebrate() exactly once', () => {
|
|
const w = load();
|
|
let calls = 0;
|
|
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
|
|
const view = { instruments: { guitar: { passports: [
|
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
|
w.__careerPassportTest.detectNewBadges(view);
|
|
assert.equal(calls, 1);
|
|
// Same session, same view: no re-celebration.
|
|
w.__careerPassportTest.detectNewBadges(view);
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test('ceremony degrades when the crowd layer is absent or throws', () => {
|
|
const w = load();
|
|
const view = { instruments: { guitar: { passports: [
|
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
|
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
|
|
w.__careerPassportTest.detectNewBadges(view);
|
|
assert.equal(w.notifications.length, 1);
|
|
// celebrate() throwing must not break detection.
|
|
const w2 = load();
|
|
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
|
|
w2.__careerPassportTest.detectNewBadges(view);
|
|
assert.equal(w2.notifications.length, 1);
|
|
});
|
|
|
|
test('seenBadges tolerates corrupt stored values', () => {
|
|
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
|
const w = load({ 'feedBack-career-badges-seen': bad });
|
|
const t = w.__careerPassportTest;
|
|
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
|
|
// And detection still works on top of the recovered empty state.
|
|
t.detectNewBadges({ instruments: { guitar: { passports: [
|
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
|
|
assert.equal(w.notifications.length, 1, `stored ${bad}`);
|
|
}
|
|
});
|
|
|
|
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
|
|
const { fmtHours } = load().__careerPassportTest;
|
|
assert.equal(fmtHours(0), '');
|
|
assert.equal(fmtHours(59), '');
|
|
assert.equal(fmtHours(60), '1 min');
|
|
assert.equal(fmtHours(1800), '30 min');
|
|
assert.equal(fmtHours(3600), '1 h');
|
|
assert.equal(fmtHours(51120), '14.2 h');
|
|
assert.equal(fmtHours(null), '');
|
|
assert.equal(fmtHours('junk'), '');
|
|
});
|
|
|
|
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
|
const { ppFillFraction } = load().__careerPassportTest;
|
|
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
|
|
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
|
|
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
|
|
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
|
|
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
|
|
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
|
|
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
|
assert.equal(ppFillFraction(null), 0);
|
|
});
|
|
|
|
test('careerTotals / wall + dash card stay absent without commitment', () => {
|
|
const w = load();
|
|
const t = w.__careerPassportTest;
|
|
// No _pp at all → null; committed-less view → null (absent-not-empty).
|
|
assert.equal(t.careerTotals(), null);
|
|
t.setView({ config: { instruments: ['guitar'] },
|
|
instruments: { guitar: { committed_at: null, passports: [] } } });
|
|
assert.equal(t.careerTotals(), null);
|
|
// Committed but zero passports opened: still absent (no zero-wall).
|
|
t.setView({ config: { instruments: ['guitar'] },
|
|
instruments: { guitar: { committed_at: 'x', passports: [] } } });
|
|
assert.equal(t.careerTotals(), null);
|
|
// Committed with an earned badge + hours → totals aggregate.
|
|
t.setView({ config: { instruments: ['guitar', 'bass'] },
|
|
instruments: {
|
|
guitar: { committed_at: 'x', passports: [
|
|
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
|
|
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
|
|
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
|
|
bass: { committed_at: null, passports: [] },
|
|
} });
|
|
const totals = t.careerTotals();
|
|
assert.equal(totals.badges, 1);
|
|
assert.equal(totals.seconds, 3720);
|
|
assert.equal(totals.walls.length, 1);
|
|
});
|