mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
test(app): remove useless frontend tests (#2819)
This commit is contained in:
1 parent
ed9528834d
commit
deac653a9e
341 files changed
-62758
No files matched your search
@@ -1,225 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {
|
||||
hanChunkForLanguage,
|
||||
noteLocale,
|
||||
noteText,
|
||||
requestedScriptChunks,
|
||||
resetScriptFontLoaderForTests,
|
||||
type ScriptChunk,
|
||||
whenScriptChunksSettled,
|
||||
} from '@app/features/theme/fonts/ScriptFontLoader';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const FONTS_CSS = join(process.cwd(), '..', 'packages', 'fonts', 'css');
|
||||
|
||||
function chunks(): Array<ScriptChunk> {
|
||||
return [...requestedScriptChunks()].sort();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetScriptFontLoaderForTests();
|
||||
document.documentElement.lang = '';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await whenScriptChunksSettled();
|
||||
});
|
||||
|
||||
describe('script font loading gate', () => {
|
||||
it('requests nothing for text that is entirely Latin, which is the whole point of the split', () => {
|
||||
noteText('Hello there, this is an ordinary English message with punctuation: 1, 2, 3!');
|
||||
noteText('Ça va? Grüße! Añadir — naïve café résumé');
|
||||
expect(chunks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('requests nothing for empty or absent text', () => {
|
||||
noteText('');
|
||||
noteText(null);
|
||||
noteText(undefined);
|
||||
expect(chunks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('requests the JP chunk on kana, which no other bundled family covers', () => {
|
||||
noteText('こんにちは');
|
||||
expect(chunks()).toEqual(['jp']);
|
||||
});
|
||||
|
||||
it('requests the KR chunk on hangul', () => {
|
||||
noteText('안녕하세요');
|
||||
expect(chunks()).toEqual(['kr']);
|
||||
});
|
||||
|
||||
it('requests only the family the cascade would select for Han, not all three', () => {
|
||||
noteText('你好世界');
|
||||
expect(chunks()).toEqual(['sc']);
|
||||
});
|
||||
|
||||
it('breaks the Han tie with the document language, never with it deciding whether to load', () => {
|
||||
document.documentElement.lang = 'ja';
|
||||
noteText('漢字');
|
||||
expect(chunks()).toEqual(['jp']);
|
||||
|
||||
resetScriptFontLoaderForTests();
|
||||
document.documentElement.lang = 'zh-TW';
|
||||
noteText('漢字');
|
||||
expect(chunks()).toEqual(['tc']);
|
||||
|
||||
resetScriptFontLoaderForTests();
|
||||
document.documentElement.lang = 'zh-CN';
|
||||
noteText('汉字');
|
||||
expect(chunks()).toEqual(['sc']);
|
||||
});
|
||||
|
||||
it('serves mixed-script text from a Latin locale, which is what a locale gate would get wrong', () => {
|
||||
document.documentElement.lang = 'en-US';
|
||||
noteText('Hey ソラ, did 김민준 see 你好?');
|
||||
expect(chunks()).toEqual(['jp', 'kr', 'sc']);
|
||||
});
|
||||
|
||||
it('never withholds a chunk because of the locale: a Japanese UI still loads Korean text', () => {
|
||||
document.documentElement.lang = 'ja';
|
||||
noteText('안녕하세요');
|
||||
expect(chunks()).toEqual(['kr']);
|
||||
});
|
||||
|
||||
it('requests each chunk once, so a busy render path cannot storm the network', () => {
|
||||
noteText('こんにちは');
|
||||
const first = requestedScriptChunks().size;
|
||||
for (let i = 0; i < 100; i += 1) noteText('こんにちは');
|
||||
expect(requestedScriptChunks().size).toBe(first);
|
||||
});
|
||||
|
||||
it('brings a CJK locale forward at activation without waiting for a codepoint', () => {
|
||||
noteLocale('ko');
|
||||
expect(chunks()).toEqual(['kr']);
|
||||
|
||||
resetScriptFontLoaderForTests();
|
||||
noteLocale('en-US');
|
||||
expect(chunks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('takes the tie-break on ideograph-adjacent text that is not itself an ideograph', () => {
|
||||
for (const [label, text] of [
|
||||
['iteration mark', '々'],
|
||||
['ideographic zero', '〇'],
|
||||
['kangxi radical', '⼀'],
|
||||
['ideographic full stop', '。'],
|
||||
['fullwidth exclamation', '!'],
|
||||
['ideographic space', ' '],
|
||||
] as const) {
|
||||
resetScriptFontLoaderForTests();
|
||||
document.documentElement.lang = 'en-US';
|
||||
noteText(text);
|
||||
expect(chunks(), `${label} announced no family`).toEqual(['sc']);
|
||||
}
|
||||
});
|
||||
|
||||
it('routes ideograph-adjacent text through the same tie-break as Han, not a fixed family', () => {
|
||||
document.documentElement.lang = 'ja';
|
||||
noteText('。');
|
||||
expect(chunks()).toEqual(['jp']);
|
||||
|
||||
resetScriptFontLoaderForTests();
|
||||
document.documentElement.lang = 'zh-TW';
|
||||
noteText('()');
|
||||
expect(chunks()).toEqual(['tc']);
|
||||
});
|
||||
|
||||
it('keeps halfwidth katakana on JP alone rather than also taking the Han tie-break', () => {
|
||||
document.documentElement.lang = 'en-US';
|
||||
noteText('ソラ');
|
||||
expect(chunks()).toEqual(['jp']);
|
||||
});
|
||||
|
||||
it('still requests nothing for ordinary Latin punctuation, which shares no block with CJK', () => {
|
||||
document.documentElement.lang = 'en-US';
|
||||
noteText('!?()[]{}<>@#$%^&*-_=+/\\|~`"\'.,;: 「」');
|
||||
resetScriptFontLoaderForTests();
|
||||
noteText('!?()[]{}<>@#$%^&*-_=+/\\|~`"\'.,;: ');
|
||||
expect(chunks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves the Han tie-break the same way the base --font-sans chain orders the families', () => {
|
||||
expect(hanChunkForLanguage('ja')).toBe('jp');
|
||||
expect(hanChunkForLanguage('ja-JP')).toBe('jp');
|
||||
expect(hanChunkForLanguage('zh-TW')).toBe('tc');
|
||||
expect(hanChunkForLanguage('zh-HK')).toBe('tc');
|
||||
expect(hanChunkForLanguage('zh-Hant')).toBe('tc');
|
||||
expect(hanChunkForLanguage('zh-CN')).toBe('sc');
|
||||
expect(hanChunkForLanguage('zh')).toBe('sc');
|
||||
expect(hanChunkForLanguage('en-US')).toBe('sc');
|
||||
expect(hanChunkForLanguage(null)).toBe('sc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ingest points that feed the gate', () => {
|
||||
const SRC = join(process.cwd(), 'src');
|
||||
const INGEST: Array<[string, string]> = [
|
||||
['features/guild/models/Guild.ts', 'guild names'],
|
||||
['features/guild/models/GuildRole.ts', 'role names'],
|
||||
['features/channel/models/Channel.ts', 'channel names'],
|
||||
['features/channel/state/ChannelDisplayName.ts', 'computed channel display names'],
|
||||
['features/user/utils/NicknameUtils.ts', 'display names and nicknames'],
|
||||
['features/messaging/components/markdown/index.tsx', 'message content'],
|
||||
];
|
||||
|
||||
for (const [path, what] of INGEST) {
|
||||
it(`observes ${what} at ingest (${path})`, () => {
|
||||
const source = readFileSync(join(SRC, path), 'utf8');
|
||||
expect(source).toContain("from '@app/features/theme/fonts/ScriptFontLoader'");
|
||||
expect(source).toMatch(/\bnoteText\(/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('the cascade the gate is computed from', () => {
|
||||
const variables = readFileSync(join(FONTS_CSS, 'variables.css'), 'utf8');
|
||||
const fallbacks = readFileSync(join(FONTS_CSS, 'locale-fallbacks.css'), 'utf8');
|
||||
|
||||
it('names every gated family in the base --font-sans, or the gate could never select them', () => {
|
||||
const chain = /--font-sans:([^;]*);/.exec(variables)?.[1] ?? '';
|
||||
for (const family of ['Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR']) {
|
||||
expect(chain).toContain(family);
|
||||
}
|
||||
});
|
||||
|
||||
it('orders the base chain SC before TC before JP, which is the no-signal Han tie-break', () => {
|
||||
const chain = /--font-sans:([^;]*);/.exec(variables)?.[1] ?? '';
|
||||
expect(chain.indexOf('Fluxer Sans SC')).toBeLessThan(chain.indexOf('Fluxer Sans TC'));
|
||||
expect(chain.indexOf('Fluxer Sans TC')).toBeLessThan(chain.indexOf('Fluxer Sans JP'));
|
||||
});
|
||||
|
||||
it('leaves kana to JP, so the Han tie-break cannot claim text that is only ever Japanese', () => {
|
||||
for (const sheet of ['script-sc.css', 'script-tc.css']) {
|
||||
const text = readFileSync(join(FONTS_CSS, sheet), 'utf8');
|
||||
for (const [, value] of text.matchAll(/unicode-range:([^;]*);/g)) {
|
||||
for (const part of value.split(',')) {
|
||||
const [start, end] = part.trim().replace('U+', '').split('-');
|
||||
const from = Number.parseInt(start, 16);
|
||||
const to = end === undefined ? from : Number.parseInt(end, 16);
|
||||
expect(from > 0x30ff || to < 0x3040, `${sheet} claims ${part.trim()}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('hoists the family hanChunkForLanguage picks, for every locale rule that exists', () => {
|
||||
const expected: Record<string, string> = {
|
||||
ja: 'Fluxer Sans JP',
|
||||
ko: 'Fluxer Sans KR',
|
||||
'zh-CN': 'Fluxer Sans SC',
|
||||
'zh-TW': 'Fluxer Sans TC',
|
||||
};
|
||||
for (const [tag, family] of Object.entries(expected)) {
|
||||
const rule = new RegExp(`:root:lang\\(${tag}\\)[^{]*\\{([^}]*)\\}`).exec(fallbacks);
|
||||
expect(rule, `no :root:lang(${tag}) rule in locale-fallbacks.css`).not.toBeNull();
|
||||
const chain = rule![1];
|
||||
const hoisted = /'(Fluxer Sans (?:SC|TC|JP|KR))'/.exec(chain)?.[1];
|
||||
expect(hoisted, `:root:lang(${tag}) hoists the wrong family`).toBe(family);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getMessageLayoutCssVariables, MESSAGE_LAYOUT_SPEC} from '@app/features/theme/layout/MessageLayoutSpec';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function collectStringValues(value: unknown, acc: Array<string>): void {
|
||||
if (typeof value === 'string') {
|
||||
acc.push(value);
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const nested of Object.values(value)) {
|
||||
collectStringValues(nested, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MESSAGE_LAYOUT_SPEC', () => {
|
||||
it('declares every length in rem so message layout scales with zoom', () => {
|
||||
const values: Array<string> = [];
|
||||
collectStringValues(MESSAGE_LAYOUT_SPEC, values);
|
||||
expect(values.length).toBeGreaterThan(0);
|
||||
for (const value of values) {
|
||||
expect(value === '100%' || /^-?\d+(?:\.\d+)?rem$/.test(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits CSS variables free of raw px lengths', () => {
|
||||
for (const value of Object.values(getMessageLayoutCssVariables())) {
|
||||
if (value.startsWith('calc(') || value.startsWith('clamp(')) {
|
||||
continue;
|
||||
}
|
||||
expect(value).not.toMatch(/\d+px/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearRemScaleCache,
|
||||
getRemScaleForDocument,
|
||||
REM_BASE_PX,
|
||||
remFromPx,
|
||||
startRemScaleTracking,
|
||||
} from '@app/features/theme/layout/RemFromPx';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface StubDocumentHarness {
|
||||
ownerDocument: Document;
|
||||
setRootFontSize: (fontSize: string) => void;
|
||||
getComputedStyleCallCount: () => number;
|
||||
animationFrameRequestCount: () => number;
|
||||
resizeListenerCount: () => number;
|
||||
resolutionListenerCount: () => number;
|
||||
watchedResolutionQueries: () => Array<string>;
|
||||
dispatchResize: () => void;
|
||||
setDevicePixelRatio: (devicePixelRatio: number) => void;
|
||||
setInnerWidth: (innerWidth: number) => void;
|
||||
setResolutionQueryMatches: (matches: boolean) => void;
|
||||
dispatchResolutionChange: () => void;
|
||||
}
|
||||
|
||||
function createStubDocumentHarness(initialRootFontSize: string): StubDocumentHarness {
|
||||
let rootFontSize = initialRootFontSize;
|
||||
let getComputedStyleCallCount = 0;
|
||||
let animationFrameRequestCount = 0;
|
||||
let resolutionQueryMatches = true;
|
||||
const resizeListeners = new Set<() => void>();
|
||||
const resolutionQueries = new Map<string, Set<() => void>>();
|
||||
const documentElement = {} as HTMLElement;
|
||||
const ownerWindow = {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 1280,
|
||||
getComputedStyle: () => {
|
||||
getComputedStyleCallCount += 1;
|
||||
return {fontSize: rootFontSize} as CSSStyleDeclaration;
|
||||
},
|
||||
requestAnimationFrame: () => {
|
||||
animationFrameRequestCount += 1;
|
||||
return 0;
|
||||
},
|
||||
cancelAnimationFrame: () => {},
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
if (type !== 'resize') return;
|
||||
resizeListeners.add(listener);
|
||||
},
|
||||
removeEventListener: (type: string, listener: () => void) => {
|
||||
if (type !== 'resize') return;
|
||||
resizeListeners.delete(listener);
|
||||
},
|
||||
matchMedia: (media: string) => {
|
||||
const listeners = resolutionQueries.get(media) ?? new Set<() => void>();
|
||||
resolutionQueries.set(media, listeners);
|
||||
return {
|
||||
media,
|
||||
matches: resolutionQueryMatches,
|
||||
addEventListener: (_type: string, listener: () => void) => listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener),
|
||||
} as unknown as MediaQueryList;
|
||||
},
|
||||
};
|
||||
const ownerDocument = {documentElement, defaultView: ownerWindow} as unknown as Document;
|
||||
return {
|
||||
ownerDocument,
|
||||
setRootFontSize: (fontSize) => {
|
||||
rootFontSize = fontSize;
|
||||
},
|
||||
getComputedStyleCallCount: () => getComputedStyleCallCount,
|
||||
animationFrameRequestCount: () => animationFrameRequestCount,
|
||||
resizeListenerCount: () => resizeListeners.size,
|
||||
resolutionListenerCount: () =>
|
||||
[...resolutionQueries.values()].reduce((total, listeners) => total + listeners.size, 0),
|
||||
watchedResolutionQueries: () =>
|
||||
[...resolutionQueries.entries()].filter(([, listeners]) => listeners.size > 0).map(([media]) => media),
|
||||
dispatchResize: () => {
|
||||
for (const listener of [...resizeListeners]) listener();
|
||||
},
|
||||
setDevicePixelRatio: (devicePixelRatio) => {
|
||||
ownerWindow.devicePixelRatio = devicePixelRatio;
|
||||
},
|
||||
setInnerWidth: (innerWidth) => {
|
||||
ownerWindow.innerWidth = innerWidth;
|
||||
},
|
||||
setResolutionQueryMatches: (matches) => {
|
||||
resolutionQueryMatches = matches;
|
||||
},
|
||||
dispatchResolutionChange: () => {
|
||||
for (const listeners of [...resolutionQueries.values()]) {
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('remFromPx', () => {
|
||||
it('uses a 16px base', () => {
|
||||
expect(REM_BASE_PX).toBe(16);
|
||||
});
|
||||
|
||||
it('converts whole pixel sizes to rem', () => {
|
||||
expect(remFromPx(16)).toBe('1rem');
|
||||
expect(remFromPx(40)).toBe('2.5rem');
|
||||
expect(remFromPx(80)).toBe('5rem');
|
||||
expect(remFromPx(0)).toBe('0rem');
|
||||
});
|
||||
|
||||
it('converts fractional pixel sizes without trailing-zero noise', () => {
|
||||
expect(remFromPx(10)).toBe('0.625rem');
|
||||
expect(remFromPx(3.2)).toBe('0.2rem');
|
||||
expect(remFromPx(4.8)).toBe('0.3rem');
|
||||
expect(remFromPx(2.5)).toBe('0.15625rem');
|
||||
});
|
||||
|
||||
it('preserves the sign of negative offsets', () => {
|
||||
expect(remFromPx(-64.8)).toBe('-4.05rem');
|
||||
});
|
||||
|
||||
it('always produces a value parseable as a rem length', () => {
|
||||
for (const px of [12, 14, 18, 24, 36, 55, 105, 140, 120]) {
|
||||
expect(remFromPx(px)).toMatch(/^-?\d+(?:\.\d+)?rem$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemScaleForDocument', () => {
|
||||
it('reports the root font size relative to the rem base', () => {
|
||||
const defaultRoot = createStubDocumentHarness('16px');
|
||||
const scaledRoot = createStubDocumentHarness('20px');
|
||||
const zoomedRoot = createStubDocumentHarness('24px');
|
||||
expect(getRemScaleForDocument(defaultRoot.ownerDocument)).toBe(1);
|
||||
expect(getRemScaleForDocument(scaledRoot.ownerDocument)).toBe(1.25);
|
||||
expect(getRemScaleForDocument(zoomedRoot.ownerDocument)).toBe(1.5);
|
||||
});
|
||||
|
||||
it('reads the computed root font size once for repeated calls', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
for (let call = 0; call < 200; call += 1) {
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
}
|
||||
expect(harness.getComputedStyleCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('never schedules per-frame invalidation work', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
getRemScaleForDocument(harness.ownerDocument);
|
||||
getRemScaleForDocument(harness.ownerDocument);
|
||||
expect(harness.animationFrameRequestCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('re-reads after a resize that changes the device pixel ratio', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setDevicePixelRatio(1.25);
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.25dppx)']);
|
||||
});
|
||||
|
||||
it('re-reads after a resize that changes the viewport width', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
harness.setInnerWidth(720);
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
});
|
||||
|
||||
it('ignores resizes that change neither the device pixel ratio nor the viewport width', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
for (let frame = 0; frame < 60; frame += 1) {
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
}
|
||||
expect(harness.getComputedStyleCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('re-reads after a browser zoom changes the device pixel ratio', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1dppx)']);
|
||||
harness.setRootFontSize('24px');
|
||||
harness.setDevicePixelRatio(1.5);
|
||||
harness.dispatchResolutionChange();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.5);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.5dppx)']);
|
||||
expect(harness.resolutionListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('re-binds the resolution query from a resize when the query never matched', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
harness.setResolutionQueryMatches(false);
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setDevicePixelRatio(1.5);
|
||||
harness.setResolutionQueryMatches(true);
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.5dppx)']);
|
||||
expect(harness.resolutionListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('re-reads after an explicit cache clear', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('12px');
|
||||
clearRemScaleCache(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(0.75);
|
||||
expect(harness.getComputedStyleCallCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('releases every listener when tracking is disposed', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
const stopTracking = startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
expect(harness.resizeListenerCount()).toBe(1);
|
||||
expect(harness.resolutionListenerCount()).toBe(1);
|
||||
stopTracking();
|
||||
expect(harness.resizeListenerCount()).toBe(0);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
harness.setRootFontSize('20px');
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
expect(harness.resizeListenerCount()).toBe(0);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('never registers listeners as a side effect of reading', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
for (let call = 0; call < 5; call += 1) {
|
||||
getRemScaleForDocument(harness.ownerDocument);
|
||||
}
|
||||
expect(harness.resizeListenerCount()).toBe(0);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('invalidates every document when cleared without a target', () => {
|
||||
const main = createStubDocumentHarness('16px');
|
||||
const popout = createStubDocumentHarness('16px');
|
||||
expect(getRemScaleForDocument(main.ownerDocument)).toBe(1);
|
||||
expect(getRemScaleForDocument(popout.ownerDocument)).toBe(1);
|
||||
main.setRootFontSize('20px');
|
||||
popout.setRootFontSize('20px');
|
||||
clearRemScaleCache();
|
||||
expect(getRemScaleForDocument(main.ownerDocument)).toBe(1.25);
|
||||
expect(getRemScaleForDocument(popout.ownerDocument)).toBe(1.25);
|
||||
});
|
||||
|
||||
it('falls back to the rem base when the document has no view or no usable root font size', () => {
|
||||
const detachedDocument = {documentElement: {} as HTMLElement, defaultView: null} as unknown as Document;
|
||||
const brokenRoot = createStubDocumentHarness('not-a-length');
|
||||
expect(getRemScaleForDocument(null)).toBe(1);
|
||||
expect(getRemScaleForDocument(detachedDocument)).toBe(1);
|
||||
expect(getRemScaleForDocument(brokenRoot.ownerDocument)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const MESSAGE_CSS = readFileSync(fileURLToPath(new URL('./Message.module.css', import.meta.url)), 'utf8');
|
||||
|
||||
const ISOLATED_NAMES = ['.messageUsername', '.repliedUsername'];
|
||||
const ISOLATED_TIMESTAMPS = [
|
||||
'.messageTimestamp',
|
||||
'.messageTimestampCompact',
|
||||
'.messageTimestampHover',
|
||||
'.messageTimestampCompactHover',
|
||||
];
|
||||
|
||||
function rules(css: string): Array<{selector: string; declarations: string}> {
|
||||
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const out: Array<{selector: string; declarations: string}> = [];
|
||||
const chain: Array<string> = [];
|
||||
let buffer = '';
|
||||
for (const char of source) {
|
||||
if (char === '{') {
|
||||
chain.push(buffer.split(/[;}]/).pop()?.trim().replace(/\s+/g, ' ') ?? '');
|
||||
buffer = '';
|
||||
} else if (char === '}') {
|
||||
if (chain.length > 0) {
|
||||
out.push({selector: chain.join(' '), declarations: buffer});
|
||||
chain.pop();
|
||||
}
|
||||
buffer = '';
|
||||
} else {
|
||||
buffer += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function declaredBidi(className: string): Array<string> {
|
||||
const selectorPattern = new RegExp(`\\${className}(?![\\w-])`);
|
||||
const out: Array<string> = [];
|
||||
for (const rule of rules(MESSAGE_CSS)) {
|
||||
if (!rule.selector.split(',').some((selector) => selectorPattern.test(selector))) continue;
|
||||
const match = rule.declarations.match(/unicode-bidi\s*:\s*([^;]+)/);
|
||||
if (match) out.push(match[1].trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('message bidi isolation', () => {
|
||||
it.each(ISOLATED_NAMES)('resolves %s in its own bidi paragraph', (className) => {
|
||||
expect(declaredBidi(className)).toContain('plaintext');
|
||||
});
|
||||
|
||||
it.each(ISOLATED_TIMESTAMPS)('keeps %s out of a neighbouring right-to-left run', (className) => {
|
||||
expect(declaredBidi(className)).toContain('isolate');
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readdirSync, readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const APP_SRC = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
|
||||
const FORBIDDEN_SMOOTHING_PROPERTIES = ['-webkit-font-smoothing', 'font-smooth', 'text-rendering'];
|
||||
const LAYER_PROMOTING_WILL_CHANGE = /transform|opacity|filter/;
|
||||
const WILL_CHANGE = /will-change\s*:\s*([^;]*)/;
|
||||
|
||||
const ANIMATION_STATE_SELECTOR = /\[data-(dragging|state|animating)|:hover|:active|Animating|Animated|Dragging/;
|
||||
|
||||
const NON_TEXT_WILL_CHANGE_ALLOWLIST = new Set([
|
||||
'features/channel/components/GifPicker.module.css .loadingSkeletonBlob',
|
||||
'features/channel/components/GuildMembersPage.module.css .progressBar',
|
||||
'features/channel/components/direct_message/DMChannelView.module.css .callParticipantAvatar',
|
||||
'features/messaging/components/modals/MediaModal.module.css .desktopViewerContent',
|
||||
'features/ui/components/form/FormSwitch.module.css .switchThumb',
|
||||
'features/ui/scroller/ScrollerTrack.module.css .thumb',
|
||||
'features/user/components/profile/UserProfileLoadingSkeleton.module.css .pulse',
|
||||
'features/voice/components/LiveRecordingWaveform.module.css .track',
|
||||
'features/voice/components/PiPOverlay.module.css .container',
|
||||
]);
|
||||
|
||||
function collectFiles(directory: string, extension: string, out: Array<string> = []): Array<string> {
|
||||
for (const entry of readdirSync(directory, {withFileTypes: true})) {
|
||||
const full = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectFiles(full, extension, out);
|
||||
} else if (entry.name.endsWith(extension)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rules(css: string): Array<{selector: string; declarations: string}> {
|
||||
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const out: Array<{selector: string; declarations: string}> = [];
|
||||
const chain: Array<string> = [];
|
||||
let buffer = '';
|
||||
for (const char of source) {
|
||||
if (char === '{') {
|
||||
chain.push(buffer.split(/[;}]/).pop()?.trim().replace(/\s+/g, ' ') ?? '');
|
||||
buffer = '';
|
||||
} else if (char === '}') {
|
||||
if (chain.length > 0) {
|
||||
out.push({selector: chain.join(' '), declarations: buffer});
|
||||
chain.pop();
|
||||
}
|
||||
buffer = '';
|
||||
} else {
|
||||
buffer += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function relative(file: string): string {
|
||||
return file.slice(APP_SRC.length);
|
||||
}
|
||||
|
||||
describe('text rendering', () => {
|
||||
const stylesheets = collectFiles(APP_SRC, '.css');
|
||||
|
||||
it('finds the authored stylesheets it is meant to guard', () => {
|
||||
expect(stylesheets.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('never overrides font smoothing, which would disable ClearType on Windows', () => {
|
||||
const offenders: Array<string> = [];
|
||||
for (const file of stylesheets) {
|
||||
const css = readFileSync(file, 'utf8');
|
||||
for (const property of FORBIDDEN_SMOOTHING_PROPERTIES) {
|
||||
if (css.includes(property)) {
|
||||
offenders.push(`${relative(file)}: ${property}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('only allows a layer-promoting will-change when it is animation-scoped or a reviewed non-text rule', () => {
|
||||
const offenders: Array<string> = [];
|
||||
for (const file of stylesheets) {
|
||||
for (const {selector, declarations} of rules(readFileSync(file, 'utf8'))) {
|
||||
const willChange = WILL_CHANGE.exec(declarations);
|
||||
if (willChange == null) continue;
|
||||
if (!LAYER_PROMOTING_WILL_CHANGE.test(willChange[1]!)) continue;
|
||||
if (ANIMATION_STATE_SELECTOR.test(selector)) continue;
|
||||
if (NON_TEXT_WILL_CHANGE_ALLOWLIST.has(`${relative(file)} ${selector}`)) continue;
|
||||
offenders.push(`${relative(file)} ${selector}: will-change:${willChange[1]!.trim()}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('never promotes a layer with an inline willChange style, which the stylesheet guard cannot see', () => {
|
||||
const offenders: Array<string> = [];
|
||||
for (const file of collectFiles(APP_SRC, '.tsx')) {
|
||||
if (readFileSync(file, 'utf8').includes('willChange')) {
|
||||
offenders.push(relative(file));
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('popout text rendering', () => {
|
||||
it('shadows popouts with box-shadow, since any filter disables subpixel antialiasing', () => {
|
||||
const css = readFileSync(join(APP_SRC, 'features/ui/popover/PopoverPopout.module.css'), 'utf8');
|
||||
expect(css).toContain('box-shadow: 0 0.5rem 1rem var(--popout-shadow);');
|
||||
expect(css).not.toMatch(/(^|[\s;])filter\s*:/m);
|
||||
expect(css).not.toContain('popoutStableText');
|
||||
});
|
||||
});
|
||||
|
||||
const FRACTIONAL_OPACITY = /(^|[;\s])opacity\s*:\s*(0?\.\d+)\s*(;|$)/;
|
||||
const TEXT_DECLARATION =
|
||||
/(^|[;\s])(color|font-size|font-weight|font-family|font-style|line-height|letter-spacing|text-transform|text-overflow|white-space)\s*:/;
|
||||
const TRANSIENT_STATE_SELECTOR =
|
||||
/:hover|:focus|:active|:disabled|::before|::after|\[data-|%|\bfrom\b|\bto\b|Animating|Animated|Dragging|Hover|Pressed|Selected/;
|
||||
|
||||
const PERSISTENT_TEXT_OPACITY_ALLOWLIST = new Set([
|
||||
'features/channel/components/ChannelDivider.module.css .unreadBadge',
|
||||
'features/theme/styles/AttachmentFile.module.css .statusBadge',
|
||||
'features/app/components/layout/sidebar_nav/GuildListDMItem.module.css .muted',
|
||||
'features/app/components/layout/NativeTitlebar.module.css .left',
|
||||
'features/app/components/layout/ChannelItem.module.css .channelItemMutedState',
|
||||
'features/app/components/layout/FavoritesChannelListContent.module.css .favoriteItemMuted',
|
||||
'features/channel/components/direct_message/DirectMessageList.module.css .dmItemMuted',
|
||||
'features/channel/components/direct_message/DirectMessageList.module.css .dmItemMobileMuted',
|
||||
'features/ui/action_menu/ContextMenu.module.css .item.disabled',
|
||||
'features/ui/action_menu/MenuItem.module.css .sliderItem.disabled',
|
||||
'features/app/components/dialogs/Modal.module.css .headerInner button',
|
||||
'features/channel/components/active_now/ActiveNowSidebar.module.css .emptyIcon',
|
||||
'features/channel/components/shared/MemberListUnavailableFallback.module.css .icon',
|
||||
'features/messaging/components/markdown/renderers/MessageJumpLink.module.css .divider',
|
||||
'features/search/components/search/SearchFilterChip.module.css .removeButton',
|
||||
'features/theme/styles/AttachmentGridItem.module.css .audioPlaceholder svg',
|
||||
'features/theme/styles/AttachmentMosaic.module.css .audioPlaceholder svg',
|
||||
'features/user/components/modals/UserProfileModal.module.css .emptyStateIcon',
|
||||
]);
|
||||
|
||||
describe('persistent text opacity', () => {
|
||||
it('dims text through colour, not through a layer opacity that would kill subpixel antialiasing', () => {
|
||||
const offenders: Array<string> = [];
|
||||
for (const file of collectFiles(APP_SRC, '.module.css')) {
|
||||
for (const {selector, declarations} of rules(readFileSync(file, 'utf8'))) {
|
||||
if (!FRACTIONAL_OPACITY.test(declarations)) continue;
|
||||
if (!TEXT_DECLARATION.test(declarations)) continue;
|
||||
if (TRANSIENT_STATE_SELECTOR.test(selector)) continue;
|
||||
const key = `${relative(file)} ${selector}`;
|
||||
if (PERSISTENT_TEXT_OPACITY_ALLOWLIST.has(key)) continue;
|
||||
offenders.push(key);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getActiveHolidaySlugs, millisecondsUntilNextLocalMidnight} from '@app/features/theme/utils/HolidayClassUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('getActiveHolidaySlugs', () => {
|
||||
it('fires christmas + christmastide on Dec 25', () => {
|
||||
const slugs = getActiveHolidaySlugs(new Date(2026, 11, 25));
|
||||
expect(slugs).toContain('christmas');
|
||||
expect(slugs).toContain('christmastide');
|
||||
});
|
||||
it('fires both boxing-day and christmastide on Dec 26', () => {
|
||||
const slugs = getActiveHolidaySlugs(new Date(2026, 11, 26));
|
||||
expect(slugs).toContain('boxing-day');
|
||||
expect(slugs).toContain('christmastide');
|
||||
expect(slugs).not.toContain('christmas');
|
||||
});
|
||||
it('co-fires india-independence-day and korea-liberation-day on Aug 15', () => {
|
||||
const slugs = getActiveHolidaySlugs(new Date(2026, 7, 15));
|
||||
expect(slugs).toContain('india-independence-day');
|
||||
expect(slugs).toContain('korea-liberation-day');
|
||||
});
|
||||
it('fires pride-month every day in June', () => {
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 5, 1))).toContain('pride-month');
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 5, 15))).toContain('pride-month');
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 5, 30))).toContain('pride-month');
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 4, 31))).not.toContain('pride-month');
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 6, 1))).not.toContain('pride-month');
|
||||
});
|
||||
it('co-fires stonewall-day with pride-month on Jun 28', () => {
|
||||
const slugs = getActiveHolidaySlugs(new Date(2026, 5, 28));
|
||||
expect(slugs).toContain('stonewall-day');
|
||||
expect(slugs).toContain('pride-month');
|
||||
});
|
||||
it('fires juneteenth on Jun 19', () => {
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 5, 19))).toContain('juneteenth');
|
||||
});
|
||||
it('fires back-to-the-future-day on Oct 21', () => {
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 9, 21))).toContain('back-to-the-future-day');
|
||||
});
|
||||
it('returns empty on a quiet day', () => {
|
||||
expect(getActiveHolidaySlugs(new Date(2026, 1, 17))).toEqual([]);
|
||||
});
|
||||
it('handles leap day without crashing', () => {
|
||||
expect(() => getActiveHolidaySlugs(new Date(2028, 1, 29))).not.toThrow();
|
||||
expect(getActiveHolidaySlugs(new Date(2028, 1, 29))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('millisecondsUntilNextLocalMidnight', () => {
|
||||
it('returns the gap to next local midnight', () => {
|
||||
const now = new Date(2026, 5, 15, 23, 59, 59, 0);
|
||||
const ms = millisecondsUntilNextLocalMidnight(now);
|
||||
expect(ms).toBeGreaterThanOrEqual(1000);
|
||||
expect(ms).toBeLessThanOrEqual(1000 + 999);
|
||||
});
|
||||
it('returns close to 24h at the start of a day', () => {
|
||||
const now = new Date(2026, 5, 15, 0, 0, 1, 0);
|
||||
const ms = millisecondsUntilNextLocalMidnight(now);
|
||||
expect(ms).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(ms).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user