Files
backspace/packages/web/src/utils/profileStyle.test.ts
T
devsyncwrldandClaude Opus 5 e6eba7a2b7
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
fix(profile): align the decorations, and make the effects actually look like something
Three complaints, three causes.

Decorations sat off-centre. <Avatar>'s box is `size + 2 × ringWidth`, but the
overlay was positioned with a percentage inset of that box — so it drifted by
exactly the ring whenever one was present. That is why the crown floated above
the head on the profile card while looking fine on the editor tiles, which
have no ring. DecoratedAvatar now takes `size` and `ringWidth` and computes
the inset in pixels, so the picture's edge always lands at a known radius in
the SVG viewBox and every decoration is drawn against it.

The ugly ones were ugly. Flames were a blob with a black circle punched out of
it; they are now fourteen tongues of alternating height on a gradient. The
crown's band sits ON the picture's top edge instead of hovering above it.
Frost, stars and hearts were placed by eye and are now on a computed ring.
Nine more: holo disc (a conic gradient — the honest way to draw one, and CSS
has it where SVG does not), chain, cyber brackets, lightning, spikes,
butterflies, sakura, bubbles, flower. Twenty in total.

Name effects were thin. Gradient was a two-stop ramp; it now has a lit middle.
Neon was one shadow, now five layered with a buzz. Glitch was a keyframed
text-shadow, now a real RGB split duplicated through ::before/::after from
data-text. Added chrome and gold (banded metal with a specular line — the y2k
look the reference is after), holo, rainbow, ice, 3D extrude, and outline.
Sixteen effects, thirteen fonts with Audiowide, Bungee and Monoton added.

The editor tiles now sample the user's own name rather than "Gg" or the effect
label: an effect reads differently on a short word than on the name it will
carry. Two per row instead of three, and section rows gained an icon.

One test failed on the catalog growth and was right to: it used 'rainbow' as
an example of an unknown id, and rainbow is now a real effect. It uses an
absurd id instead, and a new test walks every catalog id through the
normaliser so a catalog and the normaliser can never drift apart silently.

Verified: shared build, web build, server typecheck, 518 web tests (1 new).
Not verified: pixels — no browser here; the geometry is now computed rather
than guessed, but a screenshot decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:46:20 -03:00

120 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS,
NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES,
} from '@backspace/shared/src/profile';
import { computeBadges, detectLinkIcon } from './profileStyle';
import type { User } from '@backspace/shared';
describe('normalizeProfileStyle', () => {
it('returns defaults for garbage', () => {
expect(normalizeProfileStyle(null)).toEqual(DEFAULT_PROFILE_STYLE);
expect(normalizeProfileStyle('nope')).toEqual(DEFAULT_PROFILE_STYLE);
expect(normalizeProfileStyle({ name: 'x', links: 'y', widgets: 3 })).toEqual(DEFAULT_PROFILE_STYLE);
});
it('falls back on unknown catalog ids instead of throwing', () => {
// Ids that are not in any catalog — deliberately absurd, so growing a
// catalog never quietly turns this case into a passing no-op.
const s = normalizeProfileStyle({ name: { font: 'wingdings', effect: 'interpretive-dance' }, decoration: 'lasers', effect: 'volcano', nameplate: 'zebra' });
expect(s.name.font).toBe('default');
expect(s.name.effect).toBe('solid');
expect(s.decoration).toBe('none');
expect(s.effect).toBe('none');
expect(s.nameplate).toBe('none');
});
it('rejects bad colours, lowercases good ones', () => {
const s = normalizeProfileStyle({ name: { colors: ['#ABCDEF', 'red'] } });
expect(s.name.colors).toEqual(['#abcdef', DEFAULT_PROFILE_STYLE.name.colors[1]]);
});
it('bounds every string', () => {
const s = normalizeProfileStyle({ pronouns: 'x'.repeat(500), widgets: { quote: { text: 'q'.repeat(999), author: 'a'.repeat(999) } } });
expect(s.pronouns).toHaveLength(PROFILE_LIMITS.pronouns);
expect(s.widgets.quote?.text).toHaveLength(PROFILE_LIMITS.quoteText);
expect(s.widgets.quote?.author).toHaveLength(PROFILE_LIMITS.quoteAuthor);
});
it('tag: uppercase alphanumerics, 2–4 chars, one emoji grapheme', () => {
expect(normalizeProfileStyle({ tag: { text: 'strt!', emoji: '🔥🔥', color: '#ff0000' } }).tag).toEqual({ text: 'STRT', emoji: '🔥', color: '#ff0000' });
expect(normalizeProfileStyle({ tag: { text: 'a' } }).tag).toBeNull();
expect(normalizeProfileStyle({ tag: { text: 'abcdefg' } }).tag?.text).toBe('ABCD');
});
it('links: https only, capped, label defaults to host', () => {
const s = normalizeProfileStyle({ links: [
{ label: '', url: 'https://www.twitch.tv/syncwrld' },
{ label: 'bad', url: 'http://insecure.example' },
{ label: 'js', url: 'javascript:alert(1)' },
...Array.from({ length: 10 }, (_, i) => ({ label: `l${i}`, url: `https://x.com/${i}` })),
] });
expect(s.links).toHaveLength(PROFILE_LIMITS.links);
expect(s.links[0]).toEqual({ label: 'twitch.tv', url: 'https://www.twitch.tv/syncwrld' });
expect(s.links.some((l) => l.url.startsWith('http:') || l.url.startsWith('javascript'))).toBe(false);
});
it('game covers: only Steam CDN hosts survive', () => {
const s = normalizeProfileStyle({ widgets: {
favoriteGame: { name: 'CS2', appId: 730, cover: 'https://cdn.cloudflare.steamstatic.com/steam/apps/730/library_600x900.jpg' },
rotation: [{ name: 'Sneaky', cover: 'https://evil.example/track.png' }],
} });
expect(s.widgets.favoriteGame?.cover).toContain('steamstatic');
expect(s.widgets.rotation[0]?.cover).toBeNull();
expect(s.widgets.rotation[0]?.name).toBe('Sneaky');
});
it('accepts every id the catalogs declare', () => {
// Guards the pairing between the catalogs and the normaliser: adding an id
// to one without the other would show up here rather than as a silent
// fallback in the UI.
for (const font of NAME_FONTS) expect(normalizeProfileStyle({ name: { font } }).name.font).toBe(font);
for (const effect of NAME_EFFECTS) expect(normalizeProfileStyle({ name: { effect } }).name.effect).toBe(effect);
for (const decoration of AVATAR_DECORATIONS) expect(normalizeProfileStyle({ decoration }).decoration).toBe(decoration);
for (const effect of PROFILE_EFFECTS) expect(normalizeProfileStyle({ effect }).effect).toBe(effect);
for (const nameplate of NAMEPLATES) expect(normalizeProfileStyle({ nameplate }).nameplate).toBe(nameplate);
});
it('is idempotent', () => {
const once = normalizeProfileStyle({ name: { font: 'comic', effect: 'neon', colors: ['#111111', '#222222'] }, pronouns: 'ele/dele' });
expect(normalizeProfileStyle(once)).toEqual(once);
});
});
describe('detectLinkIcon', () => {
it('maps known hosts and subdomains', () => {
expect(detectLinkIcon('https://www.twitch.tv/x')).toBe('twitch');
expect(detectLinkIcon('https://youtu.be/abc')).toBe('youtube');
expect(detectLinkIcon('https://open.spotify.com/user/x')).toBe('spotify');
expect(detectLinkIcon('https://steamcommunity.com/id/x')).toBe('steam');
expect(detectLinkIcon('https://twitter.com/x')).toBe('x');
expect(detectLinkIcon('https://streethosting.com.br')).toBe('link');
expect(detectLinkIcon('not a url')).toBe('link');
});
});
describe('computeBadges', () => {
const now = Date.UTC(2026, 8, 2);
const DAY = 86_400_000;
const base = { id: 'u1', username: 'u', displayName: null, avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, homeInstance: null, homeUserId: null, replicatedInstances: [] } as unknown as User;
it('derives owner, founder, tenure and stylist from facts already present', () => {
const space = { ownerId: 'u1', createdAt: now - 400 * DAY };
const owner = computeBadges({ ...base, createdAt: now - 400 * DAY }, { space, now });
expect(owner).toEqual(['owner', 'veteran']);
const founder = computeBadges({ ...base, id: 'u2', createdAt: now - 200 * DAY }, { space, member: { joinedAt: space.createdAt + 5 * DAY }, now });
expect(founder).toEqual(['founder', 'og']);
const late = computeBadges({ ...base, id: 'u3', createdAt: now - 10 * DAY }, { space, member: { joinedAt: space.createdAt + 90 * DAY }, now });
expect(late).toEqual([]);
const styled = computeBadges({ ...base, id: 'u4', createdAt: now, profileStyle: normalizeProfileStyle({ decoration: 'flames' }) }, { now });
expect(styled).toEqual(['stylist']);
});
it('admin comes first', () => {
expect(computeBadges({ ...base, isAdmin: true, createdAt: now }, { now })).toEqual(['admin']);
});
});