mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
394 lines
18 KiB
TypeScript
394 lines
18 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
|
|
|
|
const BASE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clip1"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clip1)"><rect width="1000" height="1000" fill="#123456"/></g></svg>`;
|
|
|
|
// The renderer's published colours: the white keyline halo under a deep-rose
|
|
// fill rect, with white digits on top.
|
|
const RING = '#ffffff';
|
|
const FILL = '#af1d3f';
|
|
|
|
function decode(dataUrl: string): string {
|
|
return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
|
|
}
|
|
|
|
type Rect = { x: number; y: number; w: number; h: number; rx: number };
|
|
|
|
function rectByFill(svg: string, fill: string): Rect {
|
|
const match = new RegExp(
|
|
`<rect[^>]*\\bx="(-?[\\d.]+)"[^>]*\\by="(-?[\\d.]+)"[^>]*\\bwidth="([\\d.]+)"[^>]*\\bheight="([\\d.]+)"[^>]*\\brx="([\\d.]+)"[^>]*fill="${fill}"`,
|
|
).exec(svg);
|
|
expect(match).not.toBeNull();
|
|
const [, x, y, w, h, rx] = match!.map(Number);
|
|
return { x, y, w, h, rx };
|
|
}
|
|
|
|
/** The badge fill rect: the deep-rose rounded rect the digits sit on. */
|
|
function badge(svg: string): Rect {
|
|
return rectByFill(svg, FILL);
|
|
}
|
|
|
|
/** The keyline halo: the white rect drawn underneath, inflated by the ring width. */
|
|
function halo(svg: string): Rect {
|
|
return rectByFill(svg, RING);
|
|
}
|
|
|
|
function fontSize(svg: string): number {
|
|
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
|
|
}
|
|
|
|
function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
|
|
const [minX, minY, width, height] = /viewBox="([^"]+)"/
|
|
.exec(svg)![1]
|
|
.trim()
|
|
.split(/[\s,]+/)
|
|
.map(Number);
|
|
return { minX, minY, width, height };
|
|
}
|
|
|
|
describe('formatBadgeCount', () => {
|
|
it('returns an empty string for zero and below', () => {
|
|
expect(formatBadgeCount(0)).toBe('');
|
|
expect(formatBadgeCount(-3)).toBe('');
|
|
});
|
|
|
|
it('returns the count verbatim from 1 to 99', () => {
|
|
expect(formatBadgeCount(1)).toBe('1');
|
|
expect(formatBadgeCount(9)).toBe('9');
|
|
expect(formatBadgeCount(47)).toBe('47');
|
|
expect(formatBadgeCount(99)).toBe('99');
|
|
});
|
|
|
|
it('caps at 99+ above 99', () => {
|
|
// Gmail caps at 20; matching it was tried and reverted. A lower cap means a
|
|
// typical inbox needs three glyphs almost always, and three glyphs do not
|
|
// fit at the full font size — so "99+" rendered permanently smaller than a
|
|
// real two-digit count would have.
|
|
expect(formatBadgeCount(100)).toBe('99+');
|
|
expect(formatBadgeCount(133)).toBe('99+');
|
|
expect(formatBadgeCount(1000)).toBe('99+');
|
|
});
|
|
|
|
it('returns an empty string for non-finite input', () => {
|
|
expect(formatBadgeCount(Number.NaN)).toBe('');
|
|
expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('renderBadgedFavicon', () => {
|
|
it('returns null when the count is zero', () => {
|
|
expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
|
|
});
|
|
|
|
it('returns null when the source is not SVG', () => {
|
|
expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
|
|
});
|
|
|
|
it('returns null when the root element is not <svg>', () => {
|
|
expect(renderBadgedFavicon('<html><body/></html>', 3)).toBeNull();
|
|
});
|
|
|
|
it('returns null when the root has no viewBox', () => {
|
|
const noViewBox = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>`;
|
|
expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
|
|
});
|
|
|
|
it('returns a percent-encoded svg data URL', () => {
|
|
const url = renderBadgedFavicon(BASE_SVG, 3);
|
|
expect(url).not.toBeNull();
|
|
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
|
|
});
|
|
|
|
it('draws a badge and the count text', () => {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
expect(svg).toContain('<rect');
|
|
expect(svg).toContain('>3<');
|
|
});
|
|
|
|
it('renders 99+ for large counts', () => {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
|
expect(svg).toContain('>99+<');
|
|
});
|
|
|
|
it('preserves the base artwork and its clipPath id', () => {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
expect(svg).toContain('id="_clip1"');
|
|
expect(svg).toContain('#123456');
|
|
});
|
|
|
|
it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
expect(svg).toContain('width="16"');
|
|
expect(svg).toContain('height="16"');
|
|
expect(svg).toContain('viewBox="0 0 1000 1000"');
|
|
expect(svg).not.toContain('1000pt');
|
|
});
|
|
|
|
it('draws a deep-rose badge with white digits inside a white keyline halo', () => {
|
|
// The keyline pair is the contrast guarantee: against dark or saturated
|
|
// artwork the white ring separates the badge; against pale artwork the ring
|
|
// vanishes but the deep fill is the edge. Digit contrast (white on the
|
|
// fill) is fixed and independent of the artwork. Neither half may be
|
|
// dropped: a bare coloured badge was invisible on Bulwark's own red icon,
|
|
// and the flat white band that replaced it read as a sticker over the logo.
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
expect(svg).toMatch(new RegExp(`<rect[^>]*fill="${RING}"`));
|
|
expect(svg).toMatch(new RegExp(`<rect[^>]*fill="${FILL}"`));
|
|
expect(svg).toMatch(/<text[^>]*fill="#ffffff"/);
|
|
});
|
|
|
|
it('draws the halo before the fill rect, so the ring sits underneath', () => {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
expect(svg.indexOf(`fill="${RING}"`)).toBeLessThan(svg.indexOf(`fill="${FILL}"`));
|
|
});
|
|
|
|
it('inflates the halo by the ring width on every side of the fill rect', () => {
|
|
// The ring is a rect underneath, not a stroke: a stroke straddles the edge,
|
|
// halving the visible keyline and shrinking the fill.
|
|
const RING_W = 0.0625 * 1000;
|
|
for (const count of [7, 47, 250]) {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
|
|
const b = badge(svg);
|
|
const g = halo(svg);
|
|
expect(g.x).toBeCloseTo(b.x - RING_W, 5);
|
|
expect(g.y).toBeCloseTo(b.y - RING_W, 5);
|
|
expect(g.w).toBeCloseTo(b.w + 2 * RING_W, 5);
|
|
expect(g.h).toBeCloseTo(b.h + 2 * RING_W, 5);
|
|
expect(g.rx).toBeCloseTo(b.rx + RING_W, 5);
|
|
}
|
|
});
|
|
|
|
it('shrinks the font as the label grows so three glyphs still fit', () => {
|
|
const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
|
expect(fontSize(three)).toBeLessThan(fontSize(one));
|
|
});
|
|
|
|
it('returns null rather than throwing when the source contains a lone surrogate', () => {
|
|
const bad = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>abc\uD800def</title></svg>`;
|
|
expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
|
|
expect(renderBadgedFavicon(bad, 3)).toBeNull();
|
|
});
|
|
|
|
it('sizes the badge to the label, and only "99+" fills the full icon width', () => {
|
|
// The badge is only as wide as its digits need — "5" must not cover as much
|
|
// of the icon as "99+". The halo is never wider than the icon, and three
|
|
// glyphs, whose font is budgeted against the full span, grow to exactly
|
|
// fill it.
|
|
const w = (count: number) => halo(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
|
|
expect(w(7)).toBeLessThan(w(47));
|
|
expect(w(47)).toBeLessThan(w(250));
|
|
expect(w(250)).toBeCloseTo(1000, 5);
|
|
});
|
|
|
|
it('renders the published geometry at 1, 2, and 3 glyphs', () => {
|
|
// Scaled to a 0 0 1000 1000 viewBox: ring 62.5, fill height 480, padding 45
|
|
// per side, font capped at 460 and budgeted against the full span:
|
|
// font = min(460, (1000 - 125 - 90) / (len * 0.6)),
|
|
// w = len * 0.6 * font + 90 (capped at 875), halo flush bottom-right.
|
|
const expected: Record<string, { haloX: number; haloW: number; font: number }> = {
|
|
'5': { haloX: 509, haloW: 491, font: 460 }, // textW = 1 * 0.6 * 460 = 276
|
|
'15': { haloX: 233, haloW: 767, font: 460 }, // textW = 2 * 0.6 * 460 = 552
|
|
'250': { haloX: 0, haloW: 1000, font: 785 / 1.8 }, // "99+" shrinks, halo fills the span
|
|
};
|
|
for (const [count, want] of Object.entries(expected)) {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
|
|
const g = halo(svg);
|
|
expect(g.x).toBeCloseTo(want.haloX, 5);
|
|
expect(g.w).toBeCloseTo(want.haloW, 5);
|
|
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
|
|
expect(g.y).toBeCloseTo(1000 - 480 - 125, 5);
|
|
expect(g.h).toBeCloseTo(480 + 125, 5);
|
|
const b = badge(svg);
|
|
expect(b.h).toBeCloseTo(480, 5);
|
|
expect(b.rx).toBeCloseTo(0.3 * 480, 5);
|
|
}
|
|
});
|
|
|
|
it('anchors the badge to the right edge, including on a negative-origin viewBox', () => {
|
|
// Corner-anchored, not centred: the badge grows leftwards from the
|
|
// bottom-right corner, so the halo's right edge sits on minX + span
|
|
// whatever the label. Centring was rejected — at a single digit it lands
|
|
// under the middle of the mark.
|
|
const cases: [string, number, number][] = [
|
|
// [base svg, minX, span]
|
|
[BASE_SVG, 0, 1000],
|
|
[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24"><rect x="-4" y="-4" width="24" height="24" fill="#123456"/></svg>`, -4, 24],
|
|
];
|
|
for (const [svgSource, minX, span] of cases) {
|
|
for (const count of [7, 47, 250]) {
|
|
const { x, w } = halo(decode(renderBadgedFavicon(svgSource, count)!));
|
|
expect(x + w).toBeCloseTo(minX + span, 5);
|
|
expect(x).toBeGreaterThanOrEqual(minX);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
|
|
// The font is budgeted against the full icon span, not against the fitted
|
|
// badge, so one or two glyphs always land at FONT_MAX; only three force a
|
|
// shrink — and their badge then grows to fill the icon.
|
|
const FONT_MAX = 0.46 * 1000;
|
|
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
|
|
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
|
|
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
|
expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
|
|
expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
|
|
expect(fontSize(three)).toBeLessThan(FONT_MAX);
|
|
});
|
|
|
|
it('rounds the badge corners — neither an oval nor a hard square', () => {
|
|
// rx = h / 2 was the pill: at one digit it read as a circle, at two an
|
|
// oval, and "99+" was a smudge — round ends squander exactly the horizontal
|
|
// space three glyphs need. rx = 0 was the slab. Guard against a silent
|
|
// revert to either.
|
|
for (const count of [7, 47, 250]) {
|
|
const { h, rx } = badge(decode(renderBadgedFavicon(BASE_SVG, count)!));
|
|
expect(rx).toBeCloseTo(0.3 * h, 5);
|
|
expect(rx).toBeGreaterThan(0);
|
|
expect(rx).toBeLessThan(h / 2);
|
|
}
|
|
});
|
|
|
|
it('draws the digits at font-weight 600, in both the attribute and the style', () => {
|
|
// 600, not the 500 the black-on-white band used: light-on-dark digits lose
|
|
// stroke weight to anti-aliasing at 16px.
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
expect(svg).toMatch(/<text[^>]*font-weight="600"/);
|
|
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*600/);
|
|
});
|
|
|
|
it('keeps the badge entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
|
|
// The halo is flush to the bottom and right edges and, at three glyphs, to
|
|
// the left edge too — but it must never overflow any of them.
|
|
for (const count of [7, 47, 250]) {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
|
|
const { x, y, w, h } = halo(svg);
|
|
expect(x).toBeGreaterThanOrEqual(0);
|
|
expect(y).toBeGreaterThanOrEqual(0);
|
|
expect(x + w).toBeLessThanOrEqual(1000);
|
|
expect(y + h).toBeLessThanOrEqual(1000);
|
|
}
|
|
});
|
|
|
|
// A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
|
|
// negative number: dropping `minX +` from the anchoring passed it. Anchor
|
|
// against a viewBox whose origin is negative, where the halo's own x is
|
|
// legitimately negative, so the offset is genuinely pinned.
|
|
it('anchors the badge to the viewBox origin, including a negative origin', () => {
|
|
const NEGATIVE_ORIGIN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-40 -40 240 240"><rect x="-40" y="-40" width="240" height="240" fill="#123456"/></svg>`;
|
|
for (const count of [7, 47, 250]) {
|
|
const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
|
|
const { x, y, w, h } = halo(svg);
|
|
expect(x).toBeGreaterThanOrEqual(-40);
|
|
expect(y).toBeGreaterThanOrEqual(-40);
|
|
expect(x + w).toBeLessThanOrEqual(200);
|
|
expect(y + h).toBeLessThanOrEqual(200);
|
|
// Anchored to the bottom-right: in a viewBox running from -40 to 200, the
|
|
// halo's bottom edge and its right edge both sit well past the midpoint.
|
|
expect(x + w).toBeGreaterThan(80);
|
|
expect(y + h).toBeGreaterThan(80);
|
|
}
|
|
});
|
|
|
|
it('fits the label inside the badge, with padding, for every label length', () => {
|
|
// The core badge invariant: textW + 2 * pad <= w, where the glyph advance
|
|
// and padding are the renderer's own published constants. PAD_FACTOR is a
|
|
// fraction of the icon span, not of the fitted badge, so the padding is the
|
|
// same at every label length.
|
|
const GLYPH_ADV = 0.6;
|
|
const PAD_FACTOR = 0.045;
|
|
for (const count of [7, 47, 250]) {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
|
|
const { w } = badge(svg);
|
|
const label = count > 99 ? '99+' : String(count);
|
|
const textW = label.length * GLYPH_ADV * fontSize(svg);
|
|
const pad = PAD_FACTOR * 1000;
|
|
expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
|
|
}
|
|
});
|
|
|
|
it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
|
|
const url = renderBadgedFavicon(BASE_SVG, 3)!;
|
|
// encodeURI leaves "#" bare, which the browser reads as a fragment
|
|
// delimiter: everything after the first colour would be silently dropped.
|
|
expect(url).toContain('%23');
|
|
expect(url).not.toContain('#');
|
|
});
|
|
|
|
it('returns an empty label, and no badge, for a fractional count below one', () => {
|
|
expect(formatBadgeCount(0.5)).toBe('');
|
|
expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
|
|
});
|
|
|
|
it('returns null when the root svg has no SVG namespace', () => {
|
|
// Non-null but unrenderable: a data URL built from this would show nothing.
|
|
const noNs = `<svg viewBox="0 0 100 100"><rect width="100" height="100"/></svg>`;
|
|
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
|
|
});
|
|
|
|
it('beats a stylesheet in the base SVG, keeping the badge colours fixed', () => {
|
|
// Presentation attributes lose to any CSS rule in the document. A branded
|
|
// base carrying `rect { fill: #db2d54 }` would otherwise repaint the halo,
|
|
// the fill and the digits in the artwork's own colour — exactly what the
|
|
// keyline exists to prevent.
|
|
const STYLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><style>rect{fill:#db2d54}text{fill:#db2d54}</style><rect width="1000" height="1000"/></svg>`;
|
|
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
|
|
expect(svg).toMatch(new RegExp(`<rect[^>]*style="[^"]*fill:\\s*${RING}`));
|
|
expect(svg).toMatch(new RegExp(`<rect[^>]*style="[^"]*fill:\\s*${FILL}`));
|
|
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#ffffff/);
|
|
});
|
|
|
|
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
|
|
// The base may be an admin-uploaded file, which upstream serves under a
|
|
// sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
|
|
// same-origin data: URL would un-fence it, so sanitise before serialising.
|
|
const HOSTILE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" onload="alert(1)"><script>alert(2)</script><foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject><rect width="100" height="100" onclick="alert(3)" ONMOUSEOVER="alert(4)" fill="#123456"/></svg>`;
|
|
const url = renderBadgedFavicon(HOSTILE, 3)!;
|
|
expect(url).not.toBeNull();
|
|
const svg = decode(url);
|
|
expect(svg).not.toContain('<script');
|
|
expect(svg).not.toContain('foreignObject');
|
|
expect(svg.toLowerCase()).not.toContain('onload');
|
|
expect(svg.toLowerCase()).not.toContain('onclick');
|
|
expect(svg.toLowerCase()).not.toContain('onmouseover');
|
|
expect(svg).not.toContain('alert');
|
|
// The legitimate artwork survives.
|
|
expect(svg).toContain('#123456');
|
|
});
|
|
|
|
it('normalises a non-square viewBox to a square, so the badge stays legible', () => {
|
|
// A 100x20 wordmark: span = min(w, h) = 20 previously produced a ~2px-tall
|
|
// smudge on a 16px icon. Squaring the viewBox first sizes the badge against
|
|
// the rendered box instead.
|
|
const WORDMARK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><rect width="100" height="20" fill="#123456"/></svg>`;
|
|
const svg = decode(renderBadgedFavicon(WORDMARK, 42)!);
|
|
|
|
const vb = viewBoxOf(svg);
|
|
expect(vb.width).toBe(100);
|
|
expect(vb.height).toBe(100);
|
|
expect(vb.minX).toBe(0);
|
|
expect(vb.minY).toBe(-40); // centred: (100 - 20) / 2 above and below
|
|
|
|
const g = halo(svg);
|
|
// Sized against the square side (100), not the 20-unit short axis: fill
|
|
// height 0.48 * 100 plus a 6.25-unit ring each side.
|
|
expect(g.h).toBeCloseTo(48 + 12.5, 5);
|
|
// Two glyphs at FONT_MAX (46) plus padding and ring:
|
|
// 2 * 0.6 * 46 + 2 * 4.5 + 2 * 6.25 = 76.7, anchored to the right of the
|
|
// squared span.
|
|
expect(g.w).toBeCloseTo(76.7, 5);
|
|
expect(g.x + g.w).toBeCloseTo(100, 5);
|
|
// Still in bounds of the normalised viewBox.
|
|
expect(g.x).toBeGreaterThanOrEqual(vb.minX);
|
|
expect(g.y).toBeGreaterThanOrEqual(vb.minY);
|
|
expect(g.x + g.w).toBeLessThanOrEqual(vb.minX + vb.width);
|
|
expect(g.y + g.h).toBeLessThanOrEqual(vb.minY + vb.height);
|
|
});
|
|
|
|
it('leaves a square viewBox untouched', () => {
|
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
|
expect(svg).toContain('viewBox="0 0 1000 1000"');
|
|
});
|
|
});
|