// the tsconfig only auto-includes jest types; pull in node's for fs/path /// import * as fs from 'fs'; import * as path from 'path'; import Ajv from 'ajv'; import { contrastRatio, relativeLuminance } from '../contrast'; import { applyDynamicStyle, asDividerContrast, buildDynamicCss, CONTRAST_TARGETS, DEFAULT_PALETTE, DividerContrast, IPalette, mergePalette } from '../dynamicStyle'; const COLOUR_IDS = ['rose', 'peach', 'lemon', 'mint', 'sky', 'lavender']; const DARK_SELECTOR = "[data-jp-theme-light='false']"; /** The declarations inside a variable block, matched by its selector. */ function variableBlock(css: string, selector: string): string { // require ' {' right after the selector so divider-rule prefixes don't match const match = css.match( new RegExp(selector.replace(/[[\]']/g, '\\$&') + ' \\{([\\s\\S]*?)\\}') ); expect(match).not.toBeNull(); return (match as RegExpMatchArray)[1]; } /** The divider grey for a colour from its border-right rule, per theme. */ function dividerGreyFor(css: string, id: string, dark: boolean): string { const tab = `\\.lm-TabBar-tab\\.jp-colourful-tab-${id}:not\\(\\.lm-mod-current\\)`; const prefix = dark ? DARK_SELECTOR.replace(/[[\]']/g, '\\$&') + ' ' : ''; const match = css.match( new RegExp( `^${prefix}${tab}:has\\(\\+ ${tab}\\) \\{ border-right-color: (#[0-9a-fA-F]{6})`, 'm' ) ); expect(match).not.toBeNull(); return (match as RegExpMatchArray)[1]; } describe('buildDynamicCss', () => { const css = buildDynamicCss(DEFAULT_PALETTE, true); it('redefines all 12 variables per theme, dark scoped to the theme attribute', () => { const light = variableBlock(css, ':root'); const dark = variableBlock(css, DARK_SELECTOR); expect(light.match(/--jp-colourful-tab-/g)).toHaveLength(12); expect(dark.match(/--jp-colourful-tab-/g)).toHaveLength(12); for (const id of COLOUR_IDS) { const entry = DEFAULT_PALETTE.light[id]; expect(light).toContain(`--jp-colourful-tab-${id}: ${entry.inactive};`); expect(light).toContain( `--jp-colourful-tab-${id}-active: ${entry.active};` ); const darkEntry = DEFAULT_PALETTE.dark[id]; expect(dark).toContain( `--jp-colourful-tab-${id}: ${darkEntry.inactive};` ); expect(dark).toContain( `--jp-colourful-tab-${id}-active: ${darkEntry.active};` ); } }); it('emits both seam rules per colour in both themes, inactive tabs only', () => { // between two inactive tabs the previous tab's border-right renders the // seam, but both sides get the grey so the outcome does not hinge on // paint order; the active tab is excluded on both sides (DEF-7) - its // distinct -active shade already sets it apart const tab = '.lm-TabBar-tab.jp-colourful-tab-rose:not(.lm-mod-current)'; const grey = dividerGreyFor(css, 'rose', false); const darkGrey = dividerGreyFor(css, 'rose', true); expect(css).toContain( `${tab}:has(+ ${tab}) { border-right-color: ${grey} !important; }` ); expect(css).toContain( `\n${tab} + ${tab} { border-left-color: ${grey} !important; }` ); expect(css).toContain( `${DARK_SELECTOR} ${tab}:has(+ ${tab}) { border-right-color: ${darkGrey} !important; }` ); expect(css).toContain( `${DARK_SELECTOR} ${tab} + ${tab} { border-left-color: ${darkGrey} !important; }` ); }); it('never recolours a seam that touches the active tab (DEF-7)', () => { // every divider selector must guard BOTH neighbours - a border rule whose // selector lacks the :not(.lm-mod-current) guard on either side would put // the grey against the active tab const borderRules = css .split('\n') .filter(line => line.includes('border-')); expect(borderRules.length).toBeGreaterThan(0); for (const rule of borderRules) { const selector = rule.slice(0, rule.indexOf('{')); const tabs = selector.match(/\.lm-TabBar-tab/g) ?? []; const guards = selector.match(/:not\(\.lm-mod-current\)/g) ?? []; expect(guards.length).toEqual(tabs.length); expect(tabs.length).toEqual(2); } }); it('omits divider rules when dynamicDivider is off, keeping the variables', () => { const plain = buildDynamicCss(DEFAULT_PALETTE, false); expect(plain).not.toContain('border-right-color'); expect(plain).not.toContain('border-left-color'); expect(plain.match(/--jp-colourful-tab-/g)).toHaveLength(24); }); it('greys the seam darker than the tab in light theme, brighter in dark', () => { for (const id of COLOUR_IDS) { const lightGrey = dividerGreyFor(css, id, false); const darkGrey = dividerGreyFor(css, id, true); expect(relativeLuminance(lightGrey)).toBeLessThan( relativeLuminance(DEFAULT_PALETTE.light[id].inactive) ); expect(relativeLuminance(darkGrey)).toBeGreaterThan( relativeLuminance(DEFAULT_PALETTE.dark[id].inactive) ); } }); }); describe('divider contrast magnitudes', () => { const magnitudes = Object.keys(CONTRAST_TARGETS) as DividerContrast[]; it('pins the target values - the deliberately subtle post-1.1.14 ladder', () => { // the magnitude tests below read CONTRAST_TARGETS themselves, so without // this pin a retuned value would pass the whole suite while shifting // every user's default visuals. The ladder was retuned one notch subtler // by user decision after live use: high carries the original 4.0 expect(CONTRAST_TARGETS).toEqual({ low: 2.0, medium: 3.0, high: 4.0 }); }); it('defaults to medium', () => { expect(buildDynamicCss(DEFAULT_PALETTE, true)).toEqual( buildDynamicCss(DEFAULT_PALETTE, true, 'medium') ); }); it('meets each magnitude target against the inactive shade in both themes', () => { // the inactive shade is the only one a divider can touch: pairs // involving the active tab keep the theme's default seam (DEF-7) for (const magnitude of magnitudes) { const css = buildDynamicCss(DEFAULT_PALETTE, true, magnitude); const target = CONTRAST_TARGETS[magnitude]; for (const id of COLOUR_IDS) { for (const dark of [false, true]) { const grey = dividerGreyFor(css, id, dark); const entry = DEFAULT_PALETTE[dark ? 'dark' : 'light'][id]; expect(contrastRatio(grey, entry.inactive)).toBeGreaterThanOrEqual( target ); } } } }); it('steps the grey strictly further from the tabs as the magnitude rises', () => { // strict on every ADJACENT pair for every colour: an equal grey would // make a magnitude option a visible no-op in the settings UI. Iterating // pairs (not hardcoded indexes) keeps a future magnitude covered too for (const id of COLOUR_IDS) { const greys = magnitudes.map(magnitude => { const css = buildDynamicCss(DEFAULT_PALETTE, true, magnitude); return { light: relativeLuminance(dividerGreyFor(css, id, false)), dark: relativeLuminance(dividerGreyFor(css, id, true)) }; }); for (let i = 1; i < greys.length; i++) { // light theme goes darker with magnitude, dark theme brighter expect(greys[i].light).toBeLessThan(greys[i - 1].light); expect(greys[i].dark).toBeGreaterThan(greys[i - 1].dark); } } }); it('clamps to the direction extreme when a user palette makes high unreachable', () => { // documented degrade in BOTH directions: a bright dark-theme shade makes // 6:1 impossible brighter-ward (clamp #ffffff), a near-black light-theme // shade makes it impossible darker-ward (clamp #000000) - the divider // rides the clamp rather than silently dropping the rule const palette = mergePalette({ light: { rose: { inactive: '#303030', active: '#282828' } }, dark: { rose: { inactive: '#c8c8c8', active: '#d0d0d0' } } }); const css = buildDynamicCss(palette, true, 'high'); expect(dividerGreyFor(css, 'rose', false)).toEqual('#000000'); expect(dividerGreyFor(css, 'rose', true)).toEqual('#ffffff'); }); it('asDividerContrast guards raw settings values', () => { expect(asDividerContrast('low')).toEqual('low'); expect(asDividerContrast('medium')).toEqual('medium'); expect(asDividerContrast('high')).toEqual('high'); expect(asDividerContrast(undefined)).toEqual('medium'); expect(asDividerContrast('HIGH')).toEqual('medium'); expect(asDividerContrast(42)).toEqual('medium'); // membership is derived from CONTRAST_TARGETS - prototype-chain keys of // the object must not slip through the `in`-style check expect(asDividerContrast('toString')).toEqual('medium'); }); }); describe('mergePalette', () => { it('returns the defaults for undefined input', () => { expect(mergePalette(undefined)).toEqual(DEFAULT_PALETTE); }); it('applies a partial valid override to exactly that field', () => { const merged = mergePalette({ light: { rose: { inactive: '#123456' } } }); expect(merged.light.rose.inactive).toEqual('#123456'); expect(merged.light.rose.active).toEqual(DEFAULT_PALETTE.light.rose.active); const untouched: IPalette = { light: { ...merged.light, rose: DEFAULT_PALETTE.light.rose }, dark: merged.dark }; expect(untouched).toEqual(DEFAULT_PALETTE); }); it('rejects invalid values field-wise and ignores unknown colour keys', () => { const merged = mergePalette({ light: { rose: { inactive: 'red', active: '#12345' }, peach: { inactive: 42, active: null }, crimson: { inactive: '#123456', active: '#123456' } } }); expect(merged).toEqual(DEFAULT_PALETTE); }); it('never throws on malformed input', () => { expect(mergePalette(null)).toEqual(DEFAULT_PALETTE); expect(mergePalette([])).toEqual(DEFAULT_PALETTE); expect(mergePalette('x')).toEqual(DEFAULT_PALETTE); expect(mergePalette(42)).toEqual(DEFAULT_PALETTE); expect(mergePalette({ light: 7, dark: [] })).toEqual(DEFAULT_PALETTE); }); }); describe('applyDynamicStyle', () => { it('upserts a single style element appended to head', () => { applyDynamicStyle(':root { --a: 1; }'); const style = document.getElementById('jp-colourful-tab-dynamic-style'); expect(style).not.toBeNull(); expect(style?.parentElement).toBe(document.head); expect(style?.textContent).toEqual(':root { --a: 1; }'); applyDynamicStyle(':root { --a: 2; }'); expect( document.querySelectorAll('#jp-colourful-tab-dynamic-style') ).toHaveLength(1); expect(style?.textContent).toEqual(':root { --a: 2; }'); }); }); // Anti-drift: the canonical palette lives in three places - the schema // defaults, DEFAULT_PALETTE, and the style/base.css fallback variables - // and the colour ids are enumerated a fourth time in the schema's own // definitions (themePalette, additionalProperties: false). These tests pin // all copies together. describe('default palette copies', () => { const schema = JSON.parse( fs.readFileSync(path.resolve(__dirname, '../../schema/plugin.json'), 'utf8') ); it('schema/plugin.json palette default equals DEFAULT_PALETTE', () => { expect(schema.properties.palette.default).toEqual(DEFAULT_PALETTE); }); it('schema defaults compose cleanly under the registry validator', () => { // mirror the setting registry's composer (ajv with useDefaults, strict // off): composing even empty user settings fills in the schema defaults // and validates them against the schema's own definitions. A colour id // drifting in definitions.themePalette alone would fail here with an // additionalProperties error - the same failure that would otherwise // reject settingRegistry.load() and silently disable the whole feature. const composer = new Ajv({ useDefaults: true, strict: false }); const validate = composer.compile(schema); const composite: Record = {}; expect(validate(composite)).toBe(true); expect(validate.errors).toBeNull(); expect(composite.palette).toEqual(DEFAULT_PALETTE); expect(composite.dynamicDivider).toBe(true); expect(composite.dividerContrast).toEqual('medium'); }); it('schema dividerContrast enum and default match CONTRAST_TARGETS', () => { expect(schema.properties.dividerContrast.enum).toEqual( Object.keys(CONTRAST_TARGETS) ); expect(schema.properties.dividerContrast.default).toEqual('medium'); // the guard's junk fallback must be the same value as the schema default, // so malformed settings degrade to the product default, not a third value expect(asDividerContrast(undefined)).toEqual( schema.properties.dividerContrast.default ); }); it('schema dividerContrast description ratios match CONTRAST_TARGETS', () => { // the description states the ratios in prose ("low targets 3:1 ...") - // a retuned target with a stale description would be legacy fiction on // the settings UI, so each magnitude's number is pinned to the constant const description: string = schema.properties.dividerContrast.description; for (const [magnitude, target] of Object.entries(CONTRAST_TARGETS)) { // escape the dot so a future fractional target stays an exact match const ratio = String(target).replace('.', '\\.'); expect(description).toMatch(new RegExp(`${magnitude}\\D*${ratio}:1`)); } }); it('tolerates unknown top-level user keys but rejects malformed palettes', () => { // the registry validates raw user data against this schema before // composing - a root additionalProperties: false would reject the whole // file (discarding a valid palette) over any stray key, e.g. one written // by a newer extension version before a downgrade const composer = new Ajv({ useDefaults: true, strict: false }); const validate = composer.compile(schema); expect( validate({ futureSetting: true, palette: { light: { rose: { inactive: '#123456' } } } }) ).toBe(true); expect( validate({ palette: { light: { crimson: { inactive: '#123456' } } } }) ).toBe(false); }); it('style/base.css variables equal DEFAULT_PALETTE', () => { const baseCss = fs.readFileSync( path.resolve(__dirname, '../../style/base.css'), 'utf8' ); const sections = baseCss.split(DARK_SELECTOR); expect(sections).toHaveLength(2); const themes: Array<['light' | 'dark', string]> = [ ['light', sections[0]], ['dark', sections[1]] ]; for (const [theme, section] of themes) { const pattern = /--jp-colourful-tab-([a-z]+)(-active)?:\s*(#[0-9a-fA-F]{6})/g; const declarations: RegExpExecArray[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(section)) !== null) { declarations.push(match); } expect(declarations).toHaveLength(12); for (const [, id, active, hex] of declarations) { const field = active ? 'active' : 'inactive'; expect(`${theme}.${id}.${field}: ${hex.toLowerCase()}`).toEqual( `${theme}.${id}.${field}: ${DEFAULT_PALETTE[theme][id][field]}` ); } } }); });