Preferences in Settings and a home page the user chooses
Build and Push Docker Image / build-and-push (push) Failing after 20s
Build and Push Docker Image / build-and-push (push) Failing after 20s
Settings gets a Preferences section: theme, default Bible, the Bible new reading plans start in, verse layout, commentary, text size, reading width, audio narrator (the last three stay per device; text size and narrator are now remembered). A Home page setting (My Studies, Bible Reader or Reading Plans) decides where the app opens and where every Home button goes; already being there means My Studies. Back arrows are labelled Home. Tests cover every page leading back. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
760c1e568b
commit
8c4f54e033
15 files changed
+401
-23
No files matched your search
+215
-2
@@ -509,6 +509,17 @@ describe('Admin page', () => {
|
||||
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
test('the Admin page leads back to the dashboard', async () => {
|
||||
stubAdminApi(admin);
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await openAccountMenu(u);
|
||||
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
|
||||
await screen.findByText('student@example.com');
|
||||
await u.click(screen.getByRole('button', { name: /^back$/i }));
|
||||
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('non-admins get no Admin button', async () => {
|
||||
stubAdminApi({ ...admin, isAdmin: false });
|
||||
render(<App />);
|
||||
@@ -593,7 +604,7 @@ describe('Home page', () => {
|
||||
await renderApp();
|
||||
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
|
||||
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /back to studies/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /back to home/i }));
|
||||
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -619,7 +630,7 @@ describe('Home page', () => {
|
||||
expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation
|
||||
expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true);
|
||||
// Leaving the reader ends the plan context: the reader's own translation applies again
|
||||
await user.click(screen.getByRole('button', { name: /back to studies/i }));
|
||||
await user.click(screen.getByRole('button', { name: /back to home/i }));
|
||||
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -1128,6 +1139,77 @@ describe('Preference sync', () => {
|
||||
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
|
||||
});
|
||||
|
||||
async function openSettings(u) {
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||
return screen.findByRole('region', { name: 'Preferences' });
|
||||
}
|
||||
|
||||
test('Settings shows your preferences, and choosing a default Bible saves it and syncs it', async () => {
|
||||
const server = stubPrefsServer({ translation: { v: 'BSB', at: 10 } });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
|
||||
server.puts.length = 0;
|
||||
const prefs = within(await openSettings(u));
|
||||
expect(prefs.getByLabelText('Default Bible')).toHaveValue('BSB');
|
||||
await u.selectOptions(prefs.getByLabelText('Default Bible'), 'ESV');
|
||||
expect(localStorage.getItem('reader-translation')).toBe('ESV');
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||
expect(server.puts[0].translation.v).toBe('ESV');
|
||||
});
|
||||
|
||||
test('theme, verse layout, commentary and new-plan Bible can all be set from Settings', async () => {
|
||||
stubPrefsServer();
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
const prefs = within(await openSettings(u));
|
||||
await u.click(within(prefs.getByRole('group', { name: 'Theme' })).getByRole('button', { name: 'Dark' }));
|
||||
await u.click(within(prefs.getByRole('group', { name: 'Verse layout' })).getByRole('button', { name: 'One per line' }));
|
||||
await u.selectOptions(prefs.getByLabelText('Commentary'), 'tyndale');
|
||||
await u.selectOptions(prefs.getByLabelText('New reading plans'), 'NLT');
|
||||
expect(localStorage.getItem('reader-theme')).toBe('dark');
|
||||
expect(localStorage.getItem('reader-layout')).toBe('verses');
|
||||
expect(localStorage.getItem('reader-commentary')).toBe('tyndale');
|
||||
expect(localStorage.getItem('plan-translation')).toBe('NLT');
|
||||
});
|
||||
|
||||
test('text size, reading width and narrator are remembered on this device (not synced)', async () => {
|
||||
const server = stubPrefsServer();
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
|
||||
server.puts.length = 0;
|
||||
const prefs = within(await openSettings(u));
|
||||
await u.click(within(prefs.getByRole('group', { name: 'Text size' })).getByRole('button', { name: 'XL' }));
|
||||
await u.click(within(prefs.getByRole('group', { name: 'Reading width' })).getByRole('button', { name: 'Two columns' }));
|
||||
await u.selectOptions(prefs.getByLabelText('Audio narrator'), 'hays');
|
||||
expect(localStorage.getItem('reader-font-size')).toBe('1.25');
|
||||
expect(localStorage.getItem('reader-wide')).toBe('1');
|
||||
expect(localStorage.getItem('audio-narrator')).toBe('hays');
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
expect(server.puts).toHaveLength(0); // per-device settings stay off the account
|
||||
});
|
||||
|
||||
test('Settings leads back to the dashboard', async () => {
|
||||
stubPrefsServer();
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await openSettings(u);
|
||||
await u.click(screen.getByRole('button', { name: /bible study home/i }));
|
||||
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a new reading plan starts in the Bible chosen for new plans', async () => {
|
||||
localStorage.setItem('plan-translation', 'CSB');
|
||||
stubPrefsServer({ planTranslation: { v: 'CSB', at: 10 } });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await u.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
await u.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
|
||||
expect(screen.getByRole('combobox')).toHaveValue('CSB');
|
||||
});
|
||||
|
||||
test('signing out forgets the sync timestamps', async () => {
|
||||
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
|
||||
const u = userEvent.setup();
|
||||
@@ -1138,3 +1220,134 @@ describe('Preference sync', () => {
|
||||
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every page has a way back to the dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Back to the dashboard', () => {
|
||||
const home = /back to home|bible study home/i;
|
||||
const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
|
||||
test.each([
|
||||
['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }],
|
||||
['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }],
|
||||
['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }],
|
||||
])('%s', async (_name, open) => {
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await open(u);
|
||||
await u.click(screen.getByRole('button', { name: home }));
|
||||
await dashboard();
|
||||
});
|
||||
|
||||
test.each(['Outline', 'Study', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
|
||||
const u = await loadChapter();
|
||||
addChunk('Paul, a servant', 'In hope of eternal');
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') }));
|
||||
await screen.findByRole('navigation', { name: /study stages/i });
|
||||
await u.click(screen.getByRole('button', { name: home }));
|
||||
await dashboard();
|
||||
});
|
||||
|
||||
test('the Pronunciation guide', async () => {
|
||||
const u = await loadChapter();
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: /^script$/i }));
|
||||
await u.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
|
||||
await screen.findByRole('heading', { name: /pronunciation guide/i });
|
||||
await u.click(screen.getByRole('button', { name: home }));
|
||||
await dashboard();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The home page the user chooses
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Chosen home page', () => {
|
||||
const homeBtn = /back to home|bible study home/i;
|
||||
|
||||
test('the app opens on the chosen page', async () => {
|
||||
localStorage.setItem('start-page', 'reader');
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('My Studies')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('by default the app opens on My Studies', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => {
|
||||
localStorage.setItem('start-page', 'reader');
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByText(/Genesis 1 · BSB/);
|
||||
// already on the chosen page: Home means My Studies
|
||||
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
// from another page, Home is the chosen page
|
||||
await u.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
await screen.findByText(/browse plans/i);
|
||||
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => {
|
||||
localStorage.setItem('start-page', 'plans');
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByText(/browse plans/i); // opened on Reading Plans
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies
|
||||
await screen.findByText('My Studies');
|
||||
await u.click(screen.getByRole('button', { name: /import session list/i }));
|
||||
await screen.findByRole('heading', { name: /import session list/i });
|
||||
await u.click(screen.getByRole('button', { name: /bible study home/i }));
|
||||
expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again
|
||||
await u.click(screen.getByRole('button', { name: /import session list/i }));
|
||||
await u.click(screen.getByRole('button', { name: /^studies$/i }));
|
||||
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('leaving a study with a different home page closes the study', async () => {
|
||||
localStorage.setItem('start-page', 'plans');
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByText(/browse plans/i);
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies
|
||||
await u.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||
expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Settings changes it, and it is saved for next time', async () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
const fallback = buildFetchMock();
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return json(200, user);
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||
if (route === 'GET /api/prefs') return json(200, { prefs: {} });
|
||||
if (route === 'PUT /api/prefs') return json(200, { prefs: {} });
|
||||
return json(404, { error: 'not mocked' });
|
||||
}));
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
|
||||
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
|
||||
expect(localStorage.getItem('start-page')).toBe('plans');
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page
|
||||
expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user