Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study - New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports); the chunk stage is now "Chunks" - Home cards: passages shown as ranges, tags capped with "+N more" Reader - Side-by-side translations and a Compare tab per verse - Typeface, line spacing, verse-number settings; text and column scale with the screen - Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix - Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help Flow and safety - Toasts with Undo replace most confirm()/alert() - Back/Forward move between pages - Server refuses saves over a newer copy (409); keep-mine / use-other banner; opening a study no longer re-stamps it - Latest chapter request wins when flipping quickly Offline and data - Save books, plan days or a whole BSB/NET for offline - Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late - Settings: download/restore a full backup Accessibility - Sheets trap focus and close on Escape, skip link, focus ring, reduced motion, theme contrast raised to 4.5:1 (with a test) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1edbd364ea
commit
d13f55faf7
65 files changed
+2947
-208
No files matched your search
@@ -0,0 +1,237 @@
|
||||
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
|
||||
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||
import App from './App';
|
||||
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||
import { resetSyncBases } from './utils/syncBase.js';
|
||||
|
||||
const chapterData = {
|
||||
chapter: { content: [
|
||||
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
|
||||
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
|
||||
] },
|
||||
};
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
||||
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||
const calls = [];
|
||||
const state = { putStatus };
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||
calls.push({ route, url, opts });
|
||||
if (url.includes('bible.helloao.org')) return json(200, chapterData);
|
||||
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
|
||||
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route.startsWith('PUT /api/projects/')) {
|
||||
return state.putStatus === 409
|
||||
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
||||
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
|
||||
}
|
||||
return json(404, { error: 'not mocked' });
|
||||
}));
|
||||
return { calls, state };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetLexiconCaches();
|
||||
resetSyncBases();
|
||||
switchStorageUser(null);
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||
|
||||
async function renderApp() {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
|
||||
const project = {
|
||||
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
|
||||
chapters: [{
|
||||
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
|
||||
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
|
||||
}],
|
||||
};
|
||||
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
|
||||
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
|
||||
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
|
||||
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||
}
|
||||
|
||||
describe('Back button', () => {
|
||||
test('moves back through the pages you visited instead of leaving the app', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||
window.history.back();
|
||||
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||
window.history.forward();
|
||||
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
|
||||
|
||||
describe('Search palette (⌘K)', () => {
|
||||
test('a typed passage goes straight to the Bible reader', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await user.type(await paletteBox(), 'john 3:16');
|
||||
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('finds a note you wrote and opens that study', async () => {
|
||||
seedProject({ observation: 'Grace abounds to all.' });
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await user.type(await paletteBox(), 'abounds');
|
||||
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
||||
await user.click(hit);
|
||||
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Escape closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await paletteBox();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('? lists the keyboard shortcuts', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('?');
|
||||
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo', () => {
|
||||
test('deleting a project, then Undo, keeps its notes', async () => {
|
||||
seedProject();
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
|
||||
await user.click(screen.getByRole('button', { name: /undo/i }));
|
||||
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync conflict', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||
|
||||
async function startStudy(u) {
|
||||
await renderApp();
|
||||
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
}
|
||||
|
||||
test('a save the server refuses shows the choice instead of overwriting', async () => {
|
||||
const server = stubServer({ user, putStatus: 409 });
|
||||
const u = userEvent.setup();
|
||||
await startStudy(u);
|
||||
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
|
||||
expect(banner).toHaveTextContent(/changed on another device/i);
|
||||
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
|
||||
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
|
||||
|
||||
// "Keep this version" sends the save again, this time as an overwrite
|
||||
server.state.putStatus = 200;
|
||||
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
|
||||
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
|
||||
}, 15000);
|
||||
|
||||
test('opening a study without editing it does not touch the server', async () => {
|
||||
seedProject();
|
||||
const server = stubServer({ user });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
|
||||
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
|
||||
await u.click(await screen.findByRole('button', { name: /read bible/i }));
|
||||
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outline stage', () => {
|
||||
async function openOutline() {
|
||||
// local-only mode (the account server is unreachable), with chapter text available
|
||||
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
// one chunk covering verses 1-2
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await screen.findByRole('heading', { name: 'Outline' });
|
||||
return user;
|
||||
}
|
||||
|
||||
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
|
||||
const user = await openOutline();
|
||||
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
|
||||
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
const first = await screen.findByRole('textbox', { name: 'Point I.' });
|
||||
await user.type(first, 'Creation{Enter}');
|
||||
const second = await screen.findByRole('textbox', { name: 'Point II.' });
|
||||
await user.type(second, 'Light{Tab}');
|
||||
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
|
||||
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
|
||||
});
|
||||
|
||||
test('deleting a point can be undone', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
|
||||
await user.click(screen.getByRole('button', { name: /delete i\./i }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('button', { name: /undo/i }));
|
||||
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Study cards on Home', () => {
|
||||
test('a long run of chapters and many tags stay short', async () => {
|
||||
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
||||
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
|
||||
await renderApp();
|
||||
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
|
||||
expect(screen.getByText('+3 more')).toBeInTheDocument();
|
||||
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user