mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
* feat(career): passport visuals pack — tilt, emerging ink, gold foil Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps: - Trading-card tilt on EARNED artifacts only (shelf covers + the badge page stamp): pointer-tracked perspective rotateX/Y with a glint sweep following the pointer. The cover's jitter rotation moves into a CSS var so the tilt transform composes with it; the blanket cover-hover translate is scoped :not(.pp-tilt) so it can't fight the tilt. Hover-capable pointers only; off under prefers-reduced-motion. - Emerging-stamp ink: the ghost stamp fills by qualifying/required via a conic-gradient — the stamp visibly carves in, no numbers added. - Gold foil preview: the "coming" note gains a dashed foil chip with a periodic shimmer — honest, never earnable-looking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): tilt rAF race + freshly-slammed stamp becomes a card CodeRabbit on #944: a queued tilt frame closed over the departed card and re-applied vars after pointerleave; and the just-slammed stamp never regained pp-tilt until the next open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
153 lines
6.2 KiB
JavaScript
153 lines
6.2 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);
|
|
});
|