722 lines
28 KiB
TypeScript
722 lines
28 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
vi.mock('@/utils/misc', async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
getOSPlatform: vi.fn(() => 'macos' as const),
|
|
};
|
|
});
|
|
|
|
import { getStyles, ThemeCode } from '@/utils/style';
|
|
import { ViewSettings } from '@/types/book';
|
|
import {
|
|
DEFAULT_BOOK_FONT,
|
|
DEFAULT_BOOK_LAYOUT,
|
|
DEFAULT_BOOK_LANGUAGE,
|
|
DEFAULT_BOOK_STYLE,
|
|
DEFAULT_VIEW_CONFIG,
|
|
DEFAULT_TTS_CONFIG,
|
|
DEFAULT_TRANSLATOR_CONFIG,
|
|
DEFAULT_ANNOTATOR_CONFIG,
|
|
DEFAULT_SCREEN_CONFIG,
|
|
} from '@/services/constants';
|
|
|
|
/** Build a full ViewSettings from all DEFAULT_* constants, with overrides. */
|
|
function makeViewSettings(overrides: Partial<ViewSettings> = {}): ViewSettings {
|
|
return {
|
|
...DEFAULT_BOOK_FONT,
|
|
...DEFAULT_BOOK_LAYOUT,
|
|
...DEFAULT_BOOK_LANGUAGE,
|
|
...DEFAULT_BOOK_STYLE,
|
|
...DEFAULT_VIEW_CONFIG,
|
|
...DEFAULT_TTS_CONFIG,
|
|
...DEFAULT_TRANSLATOR_CONFIG,
|
|
...DEFAULT_ANNOTATOR_CONFIG,
|
|
...DEFAULT_SCREEN_CONFIG,
|
|
...overrides,
|
|
} as ViewSettings;
|
|
}
|
|
|
|
/** Build a ThemeCode for testing. */
|
|
function makeThemeCode(overrides: Partial<ThemeCode> = {}): ThemeCode {
|
|
return {
|
|
bg: '#ffffff',
|
|
fg: '#000000',
|
|
primary: '#3366cc',
|
|
isDarkMode: false,
|
|
palette: {
|
|
'base-100': '#ffffff',
|
|
'base-200': '#f0f0f0',
|
|
'base-300': '#e0e0e0',
|
|
'base-content': '#000000',
|
|
neutral: '#808080',
|
|
'neutral-content': '#ffffff',
|
|
primary: '#3366cc',
|
|
secondary: '#6699cc',
|
|
accent: '#33cc99',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getFontStyles branches
|
|
// ---------------------------------------------------------------------------
|
|
describe('getFontStyles branches (via getStyles)', () => {
|
|
const theme = makeThemeCode();
|
|
|
|
it('uses --serif variable when defaultFont is "Serif"', () => {
|
|
const vs = makeViewSettings({ defaultFont: 'Serif' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('font-family: var(--serif)');
|
|
});
|
|
|
|
it('uses --sans-serif variable when defaultFont is "Sans-serif"', () => {
|
|
const vs = makeViewSettings({ defaultFont: 'Sans-serif' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('font-family: var(--sans-serif)');
|
|
});
|
|
|
|
it('adds !important when overrideFont is true', () => {
|
|
const vs = makeViewSettings({ overrideFont: true, defaultFont: 'Serif' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('font-family: var(--serif) !important');
|
|
// The body block should also have the font-family !important
|
|
expect(css).toContain('font-family: revert !important');
|
|
});
|
|
|
|
it('does not add !important when overrideFont is false', () => {
|
|
const vs = makeViewSettings({ overrideFont: false, defaultFont: 'Serif' });
|
|
const css = getStyles(vs, theme);
|
|
// The html rule should NOT have !important after the var
|
|
expect(css).toMatch(/font-family: var\(--serif\)\s*[^!]/);
|
|
// And body block should not have font-family at all
|
|
expect(css).not.toContain('font-family: revert !important');
|
|
});
|
|
|
|
it('sets font-size according to defaultFontSize', () => {
|
|
const vs = makeViewSettings({ defaultFontSize: 20 });
|
|
const css = getStyles(vs, theme);
|
|
// On macos, fontScale is 1, zoomLevel defaults to 100 so zoomScale is 1
|
|
expect(css).toContain('font-size: 20px !important');
|
|
});
|
|
|
|
it('sets minimum font-size via --min-font-size', () => {
|
|
const vs = makeViewSettings({ minimumFontSize: 10 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--min-font-size: 10px');
|
|
});
|
|
|
|
it('sets font-weight', () => {
|
|
const vs = makeViewSettings({ fontWeight: 700 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--font-weight: 700');
|
|
expect(css).toContain('font-weight: 700');
|
|
});
|
|
|
|
it('includes CJK font in serif font list when defaultCJKFont differs from serifFont', () => {
|
|
const vs = makeViewSettings({
|
|
serifFont: 'Bitter',
|
|
defaultCJKFont: 'Source Han Serif CN',
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('"Source Han Serif CN"');
|
|
});
|
|
|
|
it('does not duplicate CJK font in serif list when it equals serifFont', () => {
|
|
const vs = makeViewSettings({
|
|
serifFont: 'LXGW WenKai GB Screen',
|
|
defaultCJKFont: 'LXGW WenKai GB Screen',
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
// The font should appear only once in the --serif declaration
|
|
const match = css.match(/--serif:([^;]+);/);
|
|
expect(match).not.toBeNull();
|
|
const serifDecl = match![1]!;
|
|
const occurrences = serifDecl.split('"LXGW WenKai GB Screen"').length - 1;
|
|
expect(occurrences).toBe(1);
|
|
});
|
|
|
|
it('includes CJK font in sans-serif list when defaultCJKFont differs from sansSerifFont', () => {
|
|
const vs = makeViewSettings({
|
|
sansSerifFont: 'Roboto',
|
|
defaultCJKFont: 'Noto Sans SC',
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
const match = css.match(/--sans-serif:([^;]+);/);
|
|
expect(match).not.toBeNull();
|
|
expect(match![1]).toContain('"Noto Sans SC"');
|
|
});
|
|
|
|
it('does not duplicate CJK font in sans-serif list when it equals sansSerifFont', () => {
|
|
const vs = makeViewSettings({
|
|
sansSerifFont: 'Noto Sans SC',
|
|
defaultCJKFont: 'Noto Sans SC',
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
const match = css.match(/--sans-serif:([^;]+);/);
|
|
expect(match).not.toBeNull();
|
|
const sansDecl = match![1]!;
|
|
const occurrences = sansDecl.split('"Noto Sans SC"').length - 1;
|
|
expect(occurrences).toBe(1);
|
|
});
|
|
|
|
it('puts monospace font first in the monospace list', () => {
|
|
const vs = makeViewSettings({ monospaceFont: 'Fira Code' });
|
|
const css = getStyles(vs, theme);
|
|
const match = css.match(/--monospace:\s*([^;]+);/);
|
|
expect(match).not.toBeNull();
|
|
expect(match![1]!.trimStart().startsWith('"Fira Code"')).toBe(true);
|
|
});
|
|
|
|
it('applies zoomLevel scaling to font size', () => {
|
|
const vs = makeViewSettings({ defaultFontSize: 16, zoomLevel: 200 });
|
|
const css = getStyles(vs, theme);
|
|
// 16 * 1 (fontScale on macos) * (200/100) = 32
|
|
expect(css).toContain('font-size: 32px !important');
|
|
});
|
|
|
|
it('includes font[size] rules for all sizes 1-7', () => {
|
|
const vs = makeViewSettings({ defaultFontSize: 16, minimumFontSize: 8 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('font[size="1"]');
|
|
expect(css).toContain('font[size="7"]');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getLayoutStyles branches
|
|
// ---------------------------------------------------------------------------
|
|
describe('getLayoutStyles branches (via getStyles)', () => {
|
|
const theme = makeThemeCode();
|
|
|
|
it('sets text-align to justify when fullJustification is true', () => {
|
|
const vs = makeViewSettings({ fullJustification: true });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--default-text-align: justify');
|
|
});
|
|
|
|
it('sets text-align to start when fullJustification is false', () => {
|
|
const vs = makeViewSettings({ fullJustification: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--default-text-align: start');
|
|
});
|
|
|
|
it('sets margin CSS variables from viewSettings', () => {
|
|
const vs = makeViewSettings({
|
|
marginTopPx: 50,
|
|
marginRightPx: 30,
|
|
marginBottomPx: 40,
|
|
marginLeftPx: 20,
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--margin-top: 50px');
|
|
expect(css).toContain('--margin-right: 30px');
|
|
expect(css).toContain('--margin-bottom: 40px');
|
|
expect(css).toContain('--margin-left: 20px');
|
|
});
|
|
|
|
it('sets hyphens to auto when hyphenation is true', () => {
|
|
const vs = makeViewSettings({ hyphenation: true });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('hyphens: auto');
|
|
expect(css).toContain('-webkit-hyphens: auto');
|
|
});
|
|
|
|
it('sets hyphens to manual when hyphenation is false', () => {
|
|
const vs = makeViewSettings({ hyphenation: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('hyphens: manual');
|
|
expect(css).toContain('-webkit-hyphens: manual');
|
|
});
|
|
|
|
it('adds !important to layout properties when overrideLayout is true', () => {
|
|
const vs = makeViewSettings({ overrideLayout: true, lineHeight: 1.6 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('line-height: 1.6 !important');
|
|
});
|
|
|
|
it('does not add !important to layout properties when overrideLayout is false', () => {
|
|
const vs = makeViewSettings({ overrideLayout: false, lineHeight: 1.6 });
|
|
const css = getStyles(vs, theme);
|
|
// Should have line-height: 1.6 without !important after it
|
|
expect(css).toMatch(/line-height: 1\.6\s*[^!]/);
|
|
});
|
|
|
|
it('sets word-spacing and letter-spacing', () => {
|
|
const vs = makeViewSettings({ wordSpacing: 2, letterSpacing: 1 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('word-spacing: 2px');
|
|
expect(css).toContain('letter-spacing: 1px');
|
|
});
|
|
|
|
it('sets text-indent', () => {
|
|
const vs = makeViewSettings({ textIndent: 2 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('text-indent: 2em');
|
|
});
|
|
|
|
it('sets paragraph margins vertically (margin-left/right) when vertical is true', () => {
|
|
const vs = makeViewSettings({ vertical: true, paragraphMargin: 1.0 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('margin-left: 1em');
|
|
expect(css).toContain('margin-right: 1em');
|
|
expect(css).toContain('margin-top: unset');
|
|
expect(css).toContain('margin-bottom: unset');
|
|
});
|
|
|
|
it('sets paragraph margins horizontally (margin-top/bottom) when vertical is false', () => {
|
|
const vs = makeViewSettings({ vertical: false, paragraphMargin: 0.8 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('margin-top: 0.8em');
|
|
expect(css).toContain('margin-bottom: 0.8em');
|
|
expect(css).toContain('margin-left: unset');
|
|
expect(css).toContain('margin-right: unset');
|
|
});
|
|
|
|
it('sets div margins with !important when vertical and overrideLayout are both true', () => {
|
|
const vs = makeViewSettings({
|
|
vertical: true,
|
|
overrideLayout: true,
|
|
paragraphMargin: 0.5,
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('margin-left: 0.5em !important');
|
|
expect(css).toContain('margin-right: 0.5em !important');
|
|
});
|
|
|
|
it('sets div margins with !important when vertical is false and overrideLayout is true', () => {
|
|
const vs = makeViewSettings({
|
|
vertical: false,
|
|
overrideLayout: true,
|
|
paragraphMargin: 0.5,
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('margin-top: 0.5em !important');
|
|
expect(css).toContain('margin-bottom: 0.5em !important');
|
|
});
|
|
|
|
it('does not set div margins when overrideLayout is false', () => {
|
|
const vs = makeViewSettings({
|
|
vertical: false,
|
|
overrideLayout: false,
|
|
paragraphMargin: 0.5,
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
// The div block should not have paragraph margin !important
|
|
// (the template literals produce empty strings when conditions are false)
|
|
expect(css).not.toMatch(/div\s*\{[^}]*margin-top:\s*0\.5em\s*!important/);
|
|
});
|
|
|
|
it('applies writing-mode when not auto', () => {
|
|
const vs = makeViewSettings({ writingMode: 'vertical-rl' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('writing-mode: vertical-rl !important');
|
|
});
|
|
|
|
it('does not set writing-mode when it is auto', () => {
|
|
const vs = makeViewSettings({ writingMode: 'auto' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).not.toContain('writing-mode: auto');
|
|
expect(css).not.toContain('writing-mode: vertical');
|
|
expect(css).not.toContain('writing-mode: horizontal');
|
|
});
|
|
|
|
it('applies justify override for aligned-left when justify and overrideLayout are true', () => {
|
|
const vs = makeViewSettings({ fullJustification: true, overrideLayout: true });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('text-align: justify !important');
|
|
});
|
|
|
|
it('applies initial override for aligned-justify when justify is false and overrideLayout is true', () => {
|
|
const vs = makeViewSettings({ fullJustification: false, overrideLayout: true });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('text-align: initial !important');
|
|
});
|
|
|
|
it('applies img.pi vertical transforms when vertical is true', () => {
|
|
const vs = makeViewSettings({ vertical: true, lineHeight: 1.5 });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('transform: rotate(90deg)');
|
|
expect(css).toContain('transform-origin: center');
|
|
expect(css).toContain('height: 2em');
|
|
expect(css).toContain('width: 1.5em');
|
|
});
|
|
|
|
it('does not apply img.pi vertical transforms when vertical is false', () => {
|
|
const vs = makeViewSettings({ vertical: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).not.toContain('transform: rotate(90deg)');
|
|
});
|
|
|
|
it('sets img.has-text-siblings width when vertical', () => {
|
|
const vs = makeViewSettings({ vertical: true });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toMatch(/img\.has-text-siblings\s*\{[^}]*width:\s*1em/);
|
|
});
|
|
|
|
it('sets img.has-text-siblings height when not vertical', () => {
|
|
const vs = makeViewSettings({ vertical: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toMatch(/img\.has-text-siblings\s*\{[^}]*height:\s*1em/);
|
|
});
|
|
|
|
it('includes zoom: 1 when zoomLevel is 100 (passed as 1.0)', () => {
|
|
const vs = makeViewSettings({ zoomLevel: 100 });
|
|
const css = getStyles(vs, theme);
|
|
// getStyles passes 1.0 as zoomLevel to getLayoutStyles
|
|
expect(css).toContain('zoom: 1');
|
|
});
|
|
|
|
it('omits only paragraph-related layout rules when useBookLayout is true', () => {
|
|
const vs = makeViewSettings({
|
|
useBookLayout: true,
|
|
lineHeight: 1.7,
|
|
wordSpacing: 3,
|
|
letterSpacing: 2,
|
|
textIndent: 2,
|
|
paragraphMargin: 1.25,
|
|
fullJustification: true,
|
|
hyphenation: true,
|
|
marginTopPx: 50,
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
// paragraph-specific tokens driven by the Paragraph section must be absent
|
|
expect(css).not.toContain('--default-text-align:');
|
|
expect(css).not.toContain('line-height: 1.7');
|
|
expect(css).not.toContain('word-spacing: 3px');
|
|
expect(css).not.toContain('letter-spacing: 2px');
|
|
expect(css).not.toContain('text-indent: 2em');
|
|
expect(css).not.toContain('hyphens: auto');
|
|
expect(css).not.toContain('-webkit-hyphens: auto');
|
|
// non-paragraph layout rules must still be emitted
|
|
expect(css).toContain('@namespace epub');
|
|
expect(css).toContain('--margin-top: 50px');
|
|
expect(css).toContain('--margin-right:');
|
|
expect(css).toContain('--margin-bottom:');
|
|
expect(css).toContain('--margin-left:');
|
|
// font/color/translation sections must still be present
|
|
expect(css).toContain('--serif:');
|
|
expect(css).toContain('--theme-bg-color');
|
|
expect(css).toContain('.translation-source');
|
|
});
|
|
|
|
it('includes paragraph layout rules when useBookLayout is false', () => {
|
|
const vs = makeViewSettings({
|
|
useBookLayout: false,
|
|
lineHeight: 1.7,
|
|
wordSpacing: 3,
|
|
letterSpacing: 2,
|
|
textIndent: 2,
|
|
fullJustification: true,
|
|
hyphenation: true,
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--default-text-align: justify');
|
|
expect(css).toContain('line-height: 1.7');
|
|
expect(css).toContain('word-spacing: 3px');
|
|
expect(css).toContain('letter-spacing: 2px');
|
|
expect(css).toContain('text-indent: 2em');
|
|
expect(css).toContain('hyphens: auto');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getColorStyles branches
|
|
// ---------------------------------------------------------------------------
|
|
describe('getColorStyles branches (via getStyles)', () => {
|
|
it('sets color-scheme to light when isDarkMode is false', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('color-scheme: light');
|
|
});
|
|
|
|
it('sets color-scheme to dark when isDarkMode is true', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('color-scheme: dark');
|
|
});
|
|
|
|
it('sets theme CSS variables', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ bg: '#fafafa', fg: '#111111', primary: '#0055cc' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--theme-bg-color: #fafafa');
|
|
expect(css).toContain('--theme-fg-color: #111111');
|
|
expect(css).toContain('--theme-primary-color: #0055cc');
|
|
});
|
|
|
|
it('forces background-color and color on elements when overrideColor is true', () => {
|
|
const vs = makeViewSettings({ overrideColor: true });
|
|
const theme = makeThemeCode({ bg: '#fff', fg: '#000' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('background-color: #fff !important');
|
|
expect(css).toContain('color: #000 !important');
|
|
expect(css).toContain('border-color: #000 !important');
|
|
});
|
|
|
|
it('does not force color overrides when overrideColor is false', () => {
|
|
const vs = makeViewSettings({ overrideColor: false });
|
|
const theme = makeThemeCode({ bg: '#fff', fg: '#000' });
|
|
const css = getStyles(vs, theme);
|
|
// Should not have !important color override on section/div/p etc
|
|
expect(css).not.toMatch(/section,.*\{[^}]*color: #000 !important/);
|
|
});
|
|
|
|
it('applies invert filter on images when isDarkMode and invertImgColorInDark are true', () => {
|
|
const vs = makeViewSettings({ invertImgColorInDark: true });
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('filter: invert(100%)');
|
|
});
|
|
|
|
it('does not apply invert filter when isDarkMode is false', () => {
|
|
const vs = makeViewSettings({ invertImgColorInDark: true });
|
|
const theme = makeThemeCode({ isDarkMode: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).not.toContain('filter: invert(100%)');
|
|
});
|
|
|
|
it('does not apply invert filter when invertImgColorInDark is false', () => {
|
|
const vs = makeViewSettings({ invertImgColorInDark: false });
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).not.toContain('filter: invert(100%)');
|
|
});
|
|
|
|
it('applies mix-blend-mode multiply on img when not dark and overrideColor is true', () => {
|
|
const vs = makeViewSettings({ overrideColor: true });
|
|
const theme = makeThemeCode({ isDarkMode: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('mix-blend-mode: multiply');
|
|
});
|
|
|
|
it('does not apply mix-blend-mode multiply when overrideColor is false and not dark', () => {
|
|
const vs = makeViewSettings({ overrideColor: false });
|
|
const theme = makeThemeCode({ isDarkMode: false });
|
|
const css = getStyles(vs, theme);
|
|
// mix-blend-mode: multiply on img should not appear; there's one for hr.background-img and
|
|
// has-text-siblings (which is always present), but not in the img block
|
|
expect(css).not.toMatch(/^\s*img\s*\{[^}]*mix-blend-mode: multiply/m);
|
|
});
|
|
|
|
it('sets bg-texture-id CSS variable', () => {
|
|
const vs = makeViewSettings({ backgroundTextureId: 'paper' });
|
|
const theme = makeThemeCode();
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--bg-texture-id: paper');
|
|
});
|
|
|
|
it('sets bg-texture-id to none', () => {
|
|
const vs = makeViewSettings({ backgroundTextureId: 'none' });
|
|
const theme = makeThemeCode();
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('--bg-texture-id: none');
|
|
});
|
|
|
|
it('includes eink selection styles when isEink is true', () => {
|
|
const vs = makeViewSettings({ isEink: true });
|
|
const theme = makeThemeCode();
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('::selection');
|
|
expect(css).toContain('::-moz-selection');
|
|
expect(css).toContain('background: var(--theme-fg-color)');
|
|
});
|
|
|
|
it('does not include eink selection styles when isEink is false', () => {
|
|
const vs = makeViewSettings({ isEink: false });
|
|
const theme = makeThemeCode();
|
|
const css = getStyles(vs, theme);
|
|
expect(css).not.toContain('::selection');
|
|
expect(css).not.toContain('::-moz-selection');
|
|
});
|
|
|
|
it('sets text-decoration to underline for links when isEink is true', () => {
|
|
const vs = makeViewSettings({ isEink: true });
|
|
const theme = makeThemeCode();
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('text-decoration: underline');
|
|
});
|
|
|
|
it('sets text-decoration to none for links when isEink is false', () => {
|
|
const vs = makeViewSettings({ isEink: false });
|
|
const theme = makeThemeCode();
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('text-decoration: none');
|
|
});
|
|
|
|
it('forces eink background on body when isEink is true', () => {
|
|
const vs = makeViewSettings({ isEink: true });
|
|
const theme = makeThemeCode({ bg: '#ffffff' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('background-color: #ffffff !important');
|
|
});
|
|
|
|
it('sets body.pbg background in dark mode', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toMatch(/body\.pbg\s*\{[^}]*background-color:\s*#1a1a1a\s*!important/);
|
|
});
|
|
|
|
it('does not set body.pbg background in light mode', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: false });
|
|
const css = getStyles(vs, theme);
|
|
// body.pbg block should be empty or absent
|
|
expect(css).not.toMatch(/body\.pbg\s*\{[^}]*background-color:[^}]*!important/);
|
|
});
|
|
|
|
it('applies dark mode link color lightblue when overrideColor is false', () => {
|
|
const vs = makeViewSettings({ overrideColor: false });
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('color: lightblue');
|
|
});
|
|
|
|
it('applies primary color on links when overrideColor is true', () => {
|
|
const vs = makeViewSettings({ overrideColor: true });
|
|
const theme = makeThemeCode({
|
|
isDarkMode: true,
|
|
bg: '#1a1a1a',
|
|
fg: '#e0e0e0',
|
|
primary: '#ff6600',
|
|
});
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('color: #ff6600 !important');
|
|
});
|
|
|
|
it('does not apply lightblue link color in light mode', () => {
|
|
const vs = makeViewSettings({ overrideColor: false });
|
|
const theme = makeThemeCode({ isDarkMode: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).not.toContain('color: lightblue');
|
|
});
|
|
|
|
it('applies dark code styles when isDarkMode is true', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('body.theme-dark code');
|
|
expect(css).toContain('color: #e0e0e0cc');
|
|
expect(css).toContain('color-mix(in srgb, #1a1a1a 90%, #000)');
|
|
});
|
|
|
|
it('applies dark blockquote background in dark mode', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('color-mix(in srgb, #1a1a1a 80%, #000)');
|
|
});
|
|
|
|
it('applies dark table override when isDarkMode and overrideColor are both true', () => {
|
|
const vs = makeViewSettings({ overrideColor: true });
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
// blockquote, table * rule should have background with color-mix
|
|
expect(css).toMatch(
|
|
/blockquote,\s*table\s*\*\s*\{[^}]*background:\s*color-mix\(in srgb,\s*#1a1a1a\s*80%,\s*#000\)/,
|
|
);
|
|
});
|
|
|
|
it('makes svg/img backgrounds transparent when overrideColor is true', () => {
|
|
const vs = makeViewSettings({ overrideColor: true });
|
|
const theme = makeThemeCode();
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toMatch(/svg,\s*img\s*\{[^}]*background-color:\s*transparent\s*!important/);
|
|
});
|
|
|
|
it('applies screen blend mode for inline images in dark mode', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('mix-blend-mode: screen');
|
|
});
|
|
|
|
it('applies multiply blend mode for inline images in light mode', () => {
|
|
const vs = makeViewSettings();
|
|
const theme = makeThemeCode({ isDarkMode: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('mix-blend-mode: multiply');
|
|
});
|
|
|
|
it('sets inline image parent background when overrideColor is true', () => {
|
|
const vs = makeViewSettings({ overrideColor: true });
|
|
const theme = makeThemeCode({ bg: '#fafafa' });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toMatch(
|
|
/\*:has\(>\s*img\.has-text-siblings\):not\(body\)\s*\{[^}]*background-color:\s*#fafafa/,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getTranslationStyles branches
|
|
// ---------------------------------------------------------------------------
|
|
describe('getTranslationStyles branches (via getStyles)', () => {
|
|
const theme = makeThemeCode();
|
|
|
|
it('adds margin to translation-target-block when showTranslateSource is true', () => {
|
|
const vs = makeViewSettings({ showTranslateSource: true });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('margin: 0.5em 0 !important');
|
|
});
|
|
|
|
it('does not add margin to translation-target-block when showTranslateSource is false', () => {
|
|
const vs = makeViewSettings({ showTranslateSource: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).not.toContain('margin: 0.5em 0 !important');
|
|
});
|
|
|
|
it('always includes translation-source and translation-target classes', () => {
|
|
const vs = makeViewSettings({ showTranslateSource: false });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain('.translation-source');
|
|
expect(css).toContain('.translation-target');
|
|
expect(css).toContain('.translation-target.hidden');
|
|
expect(css).toContain('.translation-target-block');
|
|
expect(css).toContain('.translation-target-toc');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getStyles integration: userStylesheet appended
|
|
// ---------------------------------------------------------------------------
|
|
describe('getStyles integration', () => {
|
|
const theme = makeThemeCode();
|
|
|
|
it('appends userStylesheet content at the end', () => {
|
|
const customCSS = 'body { color: red !important; }';
|
|
const vs = makeViewSettings({ userStylesheet: customCSS });
|
|
const css = getStyles(vs, theme);
|
|
expect(css).toContain(customCSS);
|
|
// Should be at the end
|
|
expect(css.indexOf(customCSS)).toBe(css.length - customCSS.length);
|
|
});
|
|
|
|
it('concatenates all style sections', () => {
|
|
const vs = makeViewSettings();
|
|
const css = getStyles(vs, theme);
|
|
// layout styles
|
|
expect(css).toContain('@namespace epub');
|
|
// font styles
|
|
expect(css).toContain('--serif:');
|
|
// color styles
|
|
expect(css).toContain('--theme-bg-color');
|
|
// translation styles
|
|
expect(css).toContain('.translation-source');
|
|
});
|
|
|
|
it('uses default themeCode (via getThemeCode) when none is provided', () => {
|
|
const vs = makeViewSettings();
|
|
// Should not throw even without a themeCode, though getThemeCode uses
|
|
// localStorage which is mocked by jsdom as empty
|
|
const css = getStyles(vs);
|
|
expect(css).toBeTruthy();
|
|
expect(css).toContain('--theme-bg-color');
|
|
});
|
|
});
|