2b524439bf
The running section title and page-number footer used text-neutral-content, which is a light color in dark mode. A light-mode PDF stays white under a dark theme (invertImgColorInDark defaults to false), so the light text sat on the white page and became unreadable. Blend the header/footer text against whatever is behind it using mix-blend-mode: difference with a fixed white/75 anchor, so it inverts to dark on a light page and stays light on a dark margin. white/75 matches the former neutral-content brightness over the dark theme, so reflowable books look unchanged. E-ink keeps its plain base-content text; StatusInfo and the sticky progress bar manage their own colors and are left untouched. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { cleanup, render } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import ProgressBar from '@/app/reader/components/ProgressBar';
|
|
import { DEFAULT_VIEW_CONFIG } from '@/services/constants';
|
|
import type { BookProgress, ViewSettings } from '@/types/book';
|
|
import type { TOCItem } from '@/libs/document';
|
|
|
|
const saveViewSettings = vi.fn();
|
|
|
|
let currentViewSettings: ViewSettings;
|
|
let currentProgress: BookProgress | null;
|
|
let currentBookData: {
|
|
isFixedLayout: boolean;
|
|
bookDoc?: { metadata?: Record<string, unknown>; toc?: TOCItem[] };
|
|
} | null;
|
|
let currentRenderer: { page: number; pages: number };
|
|
let currentSectionFractions: number[] = [];
|
|
let currentTocHrefIndex: Record<string, number> = {};
|
|
|
|
vi.mock('@/hooks/useTranslation', () => ({
|
|
useTranslation: () => (s: string, values?: Record<string, unknown>) =>
|
|
s
|
|
.replace('{{count}}', String(values?.['count'] ?? '{{count}}'))
|
|
.replace('{{number}}', String(values?.['number'] ?? '{{number}}'))
|
|
.replace('{{time}}', String(values?.['time'] ?? '{{time}}')),
|
|
}));
|
|
|
|
vi.mock('@/context/EnvContext', () => ({
|
|
useEnv: () => ({ envConfig: {}, appService: { isMobile: false, hasSafeAreaInset: false } }),
|
|
}));
|
|
|
|
// Production code uses per-field selectors; mock must apply them so each
|
|
// `useReaderStore((s) => s.method)` call returns the method, not the whole
|
|
// state object.
|
|
vi.mock('@/store/readerStore', () => {
|
|
const state = {
|
|
getProgress: () => currentProgress,
|
|
getViewSettings: () => currentViewSettings,
|
|
getView: () => ({
|
|
renderer: currentRenderer,
|
|
getSectionFractions: () => currentSectionFractions,
|
|
resolveNavigation: (href: string) =>
|
|
href in currentTocHrefIndex ? { index: currentTocHrefIndex[href]! } : null,
|
|
}),
|
|
};
|
|
return {
|
|
useReaderStore: <R,>(selector?: (s: typeof state) => R) => (selector ? selector(state) : state),
|
|
};
|
|
});
|
|
|
|
// ProgressBar now subscribes to progress via readerProgressStore so the
|
|
// footer can re-render on page turns without dragging in the whole
|
|
// readerStore. Tests must forward their mock state here too.
|
|
vi.mock('@/store/readerProgressStore', () => ({
|
|
useBookProgress: () => currentProgress,
|
|
getBookProgress: () => currentProgress,
|
|
}));
|
|
|
|
vi.mock('@/store/bookDataStore', () => {
|
|
const state = { getBookData: () => currentBookData };
|
|
return {
|
|
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
|
|
selector ? selector(state) : state,
|
|
};
|
|
});
|
|
|
|
vi.mock('@/helpers/settings', () => ({
|
|
saveViewSettings: (...args: unknown[]) => saveViewSettings(...args),
|
|
}));
|
|
|
|
vi.mock('@/utils/event', () => ({
|
|
eventDispatcher: { dispatchSync: () => false },
|
|
}));
|
|
|
|
vi.mock('@/app/reader/components/StatusInfo.tsx', () => ({
|
|
default: () => null,
|
|
}));
|
|
|
|
const baseSettings: ViewSettings = {
|
|
...DEFAULT_VIEW_CONFIG,
|
|
} as ViewSettings;
|
|
|
|
const renderProgressBar = () =>
|
|
render(
|
|
<ProgressBar
|
|
bookKey='book-1'
|
|
horizontalGap={0}
|
|
contentInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
|
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
|
/>,
|
|
);
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
saveViewSettings.mockClear();
|
|
currentProgress = null;
|
|
currentBookData = { isFixedLayout: false };
|
|
currentRenderer = { page: 0, pages: 0 };
|
|
currentSectionFractions = [];
|
|
currentTocHrefIndex = {};
|
|
});
|
|
|
|
const makeProgress = (current: number, total: number): BookProgress =>
|
|
({
|
|
section: { current, total },
|
|
pageinfo: { current, total },
|
|
timeinfo: { section: 0, total: 0 },
|
|
}) as BookProgress;
|
|
|
|
describe('ProgressBar — tap-to-toggle disabled reverts hidden footer', () => {
|
|
it("resets progressInfoMode to 'all' when the user disables tapToToggleFooter while mode was 'none'", () => {
|
|
// Simulate a user who tapped the footer to dismiss it (mode='none')
|
|
// while tapToToggleFooter was on. Now they have it switched off.
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
tapToToggleFooter: false,
|
|
progressInfoMode: 'none',
|
|
} as ViewSettings;
|
|
|
|
renderProgressBar();
|
|
|
|
// The persisted progressInfoMode should be reset to the default
|
|
// ('all') so the footer reverts to its default visibility.
|
|
const persistCalls = saveViewSettings.mock.calls.filter(
|
|
(args) => args[2] === 'progressInfoMode',
|
|
);
|
|
expect(persistCalls.length).toBeGreaterThanOrEqual(1);
|
|
const lastCall = persistCalls[persistCalls.length - 1]!;
|
|
expect(lastCall[3]).toBe('all');
|
|
});
|
|
|
|
it("does not overwrite mode when tapToToggleFooter is on (user's cycled state stays)", () => {
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
tapToToggleFooter: true,
|
|
progressInfoMode: 'none',
|
|
} as ViewSettings;
|
|
|
|
renderProgressBar();
|
|
|
|
// initial save mirrors the existing mode; importantly we never see
|
|
// a save with 'all' overriding the user's tap-cycled choice.
|
|
const persistCalls = saveViewSettings.mock.calls.filter(
|
|
(args) => args[2] === 'progressInfoMode',
|
|
);
|
|
expect(persistCalls.every((args) => args[3] === 'none')).toBe(true);
|
|
});
|
|
|
|
it("leaves mode untouched when tapToToggleFooter is off but mode is already 'all'", () => {
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
tapToToggleFooter: false,
|
|
progressInfoMode: 'all',
|
|
} as ViewSettings;
|
|
|
|
renderProgressBar();
|
|
|
|
const persistCalls = saveViewSettings.mock.calls.filter(
|
|
(args) => args[2] === 'progressInfoMode',
|
|
);
|
|
// Either no save or a save matching the existing 'all' value — never
|
|
// a transition through some intermediate state.
|
|
expect(persistCalls.every((args) => args[3] === 'all')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('ProgressBar — fixed-layout remaining pages', () => {
|
|
it('says "in book" with section-derived count for fixed-layout books', () => {
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
showRemainingPages: true,
|
|
showRemainingTime: false,
|
|
progressInfoMode: 'all',
|
|
} as ViewSettings;
|
|
currentProgress = makeProgress(2, 5);
|
|
currentBookData = { isFixedLayout: true, bookDoc: { metadata: {} } };
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
expect(container.querySelector('.progressinfo')?.getAttribute('aria-label')).toContain(
|
|
'3 pages left in book',
|
|
);
|
|
});
|
|
|
|
it('says "in chapter" for reflowable books', () => {
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
showRemainingPages: true,
|
|
showRemainingTime: false,
|
|
progressInfoMode: 'all',
|
|
} as ViewSettings;
|
|
currentProgress = makeProgress(2, 5);
|
|
currentBookData = { isFixedLayout: false };
|
|
currentRenderer = { page: 1, pages: 4 };
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
expect(container.querySelector('.progressinfo')?.getAttribute('aria-label')).toContain(
|
|
'pages left in chapter',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ProgressBar — decorative footer is not focusable', () => {
|
|
it('does not make the progress info container focusable (no stray focus ring)', () => {
|
|
// The footer info is a decorative role="presentation" element. A negative
|
|
// tabindex made it focusable, so long-pressing it on Android focused the
|
|
// div and the WebView painted its default focus ring as a persistent line
|
|
// across the bottom of the page (issue #4397). A decorative element must
|
|
// not be focusable so it can never receive a focus ring.
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
progressInfoMode: 'all',
|
|
} as ViewSettings;
|
|
currentProgress = makeProgress(2, 5);
|
|
currentBookData = { isFixedLayout: false };
|
|
currentRenderer = { page: 1, pages: 4 };
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
const progressInfo = container.querySelector('.progressinfo');
|
|
expect(progressInfo).not.toBeNull();
|
|
expect(progressInfo!.hasAttribute('tabindex')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ProgressBar — sticky progress bar', () => {
|
|
const tocItem = (href: string): TOCItem => ({ id: 0, label: href, href, index: 0 }) as TOCItem;
|
|
|
|
const enableStickyBar = (overrides?: Partial<ViewSettings>) => {
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
showStickyProgressBar: true,
|
|
progressInfoMode: 'all',
|
|
...overrides,
|
|
} as ViewSettings;
|
|
// fraction (0.5) deliberately differs from the page fraction
|
|
// ((2+1)/5 = 0.6) so the test proves the fill uses progress.fraction.
|
|
currentProgress = { ...makeProgress(2, 5), fraction: 0.5 } as BookProgress;
|
|
currentBookData = {
|
|
isFixedLayout: false,
|
|
bookDoc: {
|
|
toc: [
|
|
tocItem('ch1.xhtml'),
|
|
tocItem('ch2.xhtml'),
|
|
tocItem('ch3.xhtml'),
|
|
tocItem('ch4.xhtml'),
|
|
],
|
|
},
|
|
};
|
|
// 5 sections; chapter starts [0.2, 0.4, 0.6, 0.8]; first & last dropped -> 2 ticks.
|
|
currentSectionFractions = [0, 0.2, 0.4, 0.6, 0.8, 1];
|
|
currentTocHrefIndex = { 'ch1.xhtml': 1, 'ch2.xhtml': 2, 'ch3.xhtml': 3, 'ch4.xhtml': 4 };
|
|
currentRenderer = { page: 1, pages: 4 };
|
|
};
|
|
|
|
it('renders the sticky bar with chapter ticks and a fill from progress.fraction', () => {
|
|
enableStickyBar();
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
const bar = container.querySelector('.sticky-progress-bar');
|
|
expect(bar).not.toBeNull();
|
|
expect(bar!.querySelectorAll('.sticky-progress-tick').length).toBe(2);
|
|
const fill = bar!.querySelector('.sticky-progress-fill') as HTMLElement;
|
|
expect(fill.style.width).toBe('50%');
|
|
});
|
|
|
|
it('does not render the sticky bar when the setting is off', () => {
|
|
enableStickyBar({ showStickyProgressBar: false });
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
expect(container.querySelector('.sticky-progress-bar')).toBeNull();
|
|
});
|
|
|
|
it('does not render the sticky bar in vertical writing mode', () => {
|
|
enableStickyBar({ vertical: true });
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
expect(container.querySelector('.sticky-progress-bar')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ProgressBar — contrast against the page (#4901)', () => {
|
|
// A light-mode PDF under a dark theme keeps its white page while the footer
|
|
// progress text stayed themed for the dark UI (neutral-content, light) and
|
|
// became unreadable over the white page. Blending the text spans against the
|
|
// real backdrop keeps the page number and remaining-pages text legible on
|
|
// any background. StatusInfo (clock/battery) is intentionally left alone — it
|
|
// manages its own blend against the battery glyph.
|
|
it('blends the progress and remaining text against the background in non-eink mode', () => {
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
isEink: false,
|
|
showRemainingPages: true,
|
|
showRemainingTime: false,
|
|
progressInfoMode: 'all',
|
|
} as ViewSettings;
|
|
currentProgress = makeProgress(2, 5);
|
|
currentBookData = { isFixedLayout: false };
|
|
currentRenderer = { page: 1, pages: 4 };
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
const progress = container.querySelector('.progress-info') as HTMLElement;
|
|
const remaining = container.querySelector('.remaining-info') as HTMLElement;
|
|
expect(progress.classList.contains('mix-blend-difference')).toBe(true);
|
|
expect(progress.classList.contains('text-white/75')).toBe(true);
|
|
expect(remaining.classList.contains('mix-blend-difference')).toBe(true);
|
|
expect(remaining.classList.contains('text-white/75')).toBe(true);
|
|
});
|
|
|
|
it('does not blend in eink mode', () => {
|
|
currentViewSettings = {
|
|
...baseSettings,
|
|
isEink: true,
|
|
showRemainingPages: true,
|
|
showRemainingTime: false,
|
|
progressInfoMode: 'all',
|
|
} as ViewSettings;
|
|
currentProgress = makeProgress(2, 5);
|
|
currentBookData = { isFixedLayout: false };
|
|
currentRenderer = { page: 1, pages: 4 };
|
|
|
|
const { container } = renderProgressBar();
|
|
|
|
const progress = container.querySelector('.progress-info') as HTMLElement;
|
|
expect(progress.classList.contains('mix-blend-difference')).toBe(false);
|
|
});
|
|
});
|