release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
126
app/admin/__tests__/journal-editor-parts.test.ts
Normal file
126
app/admin/__tests__/journal-editor-parts.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { JournalBlock } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
|
||||
import { createBlock, moveBlock, isLegacyAssetRef } from '../components/journal/blockOps';
|
||||
import {
|
||||
clampSplit,
|
||||
splitFromPointer,
|
||||
splitAfterKey,
|
||||
parseStoredSplit,
|
||||
SPLIT_MIN,
|
||||
SPLIT_MAX,
|
||||
SPLIT_DEFAULT,
|
||||
} from '../components/journal/splitPane';
|
||||
|
||||
/**
|
||||
* The logic that came out of the journal editor when it was split up (#555).
|
||||
* Inside the 2000-line component none of it could be reached by a test.
|
||||
*/
|
||||
|
||||
const TYPES: JournalBlock['type'][] = [
|
||||
'heading',
|
||||
'paragraph',
|
||||
'quote',
|
||||
'photo',
|
||||
'photo-pair',
|
||||
'photo-grid',
|
||||
'facts',
|
||||
'map',
|
||||
'album',
|
||||
];
|
||||
|
||||
describe('createBlock', () => {
|
||||
it.each(TYPES)('creates a %s block', (type) => {
|
||||
expect(createBlock(type).type).toBe(type);
|
||||
});
|
||||
|
||||
it('hands out a fresh object each time', () => {
|
||||
const a = createBlock('facts');
|
||||
const b = createBlock('facts');
|
||||
expect(a).not.toBe(b);
|
||||
if (a.type === 'facts' && b.type === 'facts') expect(a.items).not.toBe(b.items);
|
||||
});
|
||||
|
||||
/**
|
||||
* A new block is saved as soon as the author types anywhere else; one that
|
||||
* does not survive the markdown round trip would vanish on the next load.
|
||||
*/
|
||||
it.each(TYPES.filter((t) => t !== 'map' && t !== 'album'))(
|
||||
'a new %s block survives a save and reload',
|
||||
(type) => {
|
||||
const md = serializeJournalMarkdown({
|
||||
frontmatter: { title: 'T' },
|
||||
blocks: [createBlock(type)],
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
expect(parseJournalMarkdown(md).blocks.map((b) => b.type)).toEqual([type]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('moveBlock', () => {
|
||||
const blocks = (['paragraph', 'heading', 'quote'] as const).map(createBlock);
|
||||
|
||||
it('moves a block up and down', () => {
|
||||
expect(moveBlock(blocks, 1, 'up')?.map((b) => b.type)).toEqual([
|
||||
'heading',
|
||||
'paragraph',
|
||||
'quote',
|
||||
]);
|
||||
expect(moveBlock(blocks, 1, 'down')?.map((b) => b.type)).toEqual([
|
||||
'paragraph',
|
||||
'quote',
|
||||
'heading',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses to move past either end', () => {
|
||||
expect(moveBlock(blocks, 0, 'up')).toBeNull();
|
||||
expect(moveBlock(blocks, 2, 'down')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the original list alone', () => {
|
||||
moveBlock(blocks, 1, 'up');
|
||||
expect(blocks.map((b) => b.type)).toEqual(['paragraph', 'heading', 'quote']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLegacyAssetRef', () => {
|
||||
it('flags positional references, not UUIDs or empty slots', () => {
|
||||
expect(isLegacyAssetRef('1')).toBe(true);
|
||||
expect(isLegacyAssetRef('')).toBe(false);
|
||||
expect(isLegacyAssetRef('0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('split pane', () => {
|
||||
it('clamps to its bounds', () => {
|
||||
expect(clampSplit(0)).toBe(SPLIT_MIN);
|
||||
expect(clampSplit(100)).toBe(SPLIT_MAX);
|
||||
expect(clampSplit(50)).toBe(50);
|
||||
});
|
||||
|
||||
it('turns a pointer position into a percentage of the view', () => {
|
||||
expect(splitFromPointer(600, 100, 1000)).toBe(50);
|
||||
expect(splitFromPointer(0, 100, 1000)).toBe(SPLIT_MIN);
|
||||
// A collapsed view has no meaningful position.
|
||||
expect(splitFromPointer(600, 100, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('nudges with the arrows, further with Shift, and resets with Home', () => {
|
||||
expect(splitAfterKey(50, 'ArrowLeft', false)).toBe(48);
|
||||
expect(splitAfterKey(50, 'ArrowRight', true)).toBe(60);
|
||||
expect(splitAfterKey(SPLIT_MIN, 'ArrowLeft', true)).toBe(SPLIT_MIN);
|
||||
expect(splitAfterKey(SPLIT_MAX, 'ArrowRight', false)).toBe(SPLIT_MAX);
|
||||
expect(splitAfterKey(30, 'Home', false)).toBe(SPLIT_DEFAULT);
|
||||
// Anything else is not the divider's key — Tab must still move focus.
|
||||
expect(splitAfterKey(50, 'Tab', false)).toBeNull();
|
||||
});
|
||||
|
||||
it('only restores a stored width inside the bounds', () => {
|
||||
expect(parseStoredSplit('40')).toBe(40);
|
||||
expect(parseStoredSplit(null)).toBeNull();
|
||||
expect(parseStoredSplit('90')).toBeNull();
|
||||
expect(parseStoredSplit('abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
157
app/admin/__tests__/useDraft.test.tsx
Normal file
157
app/admin/__tests__/useDraft.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act, render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { useDraft, readDraft } from '../components/useDraft';
|
||||
import DraftNotice from '../components/DraftNotice';
|
||||
|
||||
/**
|
||||
* #592: leaving the page builder or the journal editor inside the admin — a tab
|
||||
* link, back, Reload, Logout — unmounted it and threw the edits away. The draft
|
||||
* is what brings them back, so these tests walk the round trip an operator
|
||||
* makes: edit, leave (unmount), come back (a fresh hook), load.
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type Props = { value: string; dirty: boolean };
|
||||
|
||||
function mount(key = 'k', initial: Props = { value: '', dirty: false }) {
|
||||
return renderHook(({ value, dirty }: Props) => useDraft(key, value, dirty), {
|
||||
initialProps: initial,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDraft', () => {
|
||||
it('writes nothing before the server state has loaded', () => {
|
||||
// The editor mounts with an empty state; that must not become a draft.
|
||||
mount('k', { value: 'empty', dirty: true });
|
||||
expect(readDraft('k')).toBeNull();
|
||||
});
|
||||
|
||||
it('brings edits back after leaving and returning', () => {
|
||||
const first = mount();
|
||||
act(() => void first.result.current.load('server-v1'));
|
||||
first.rerender({ value: 'my edit', dirty: true });
|
||||
first.unmount();
|
||||
|
||||
const second = mount();
|
||||
let restored: string | null = null;
|
||||
act(() => {
|
||||
restored = second.result.current.load('server-v1');
|
||||
});
|
||||
expect(restored).toBe('my edit');
|
||||
expect(second.result.current.status).toBe('restored');
|
||||
});
|
||||
|
||||
it('holds a draft back when the server changed since', () => {
|
||||
const first = mount();
|
||||
act(() => void first.result.current.load('server-v1'));
|
||||
first.rerender({ value: 'my edit', dirty: true });
|
||||
first.unmount();
|
||||
|
||||
const second = mount();
|
||||
let restored: string | null = 'unset';
|
||||
act(() => {
|
||||
restored = second.result.current.load('server-v2');
|
||||
});
|
||||
// Applying it silently would overwrite the other save.
|
||||
expect(restored).toBeNull();
|
||||
expect(second.result.current.status).toBe('conflict');
|
||||
|
||||
let taken: string | null = null;
|
||||
act(() => {
|
||||
taken = second.result.current.takeConflicting();
|
||||
});
|
||||
expect(taken).toBe('my edit');
|
||||
expect(second.result.current.status).toBe('restored');
|
||||
});
|
||||
|
||||
it('forgets the draft once saved, and bases new edits on the saved state', () => {
|
||||
const first = mount();
|
||||
act(() => void first.result.current.load('server-v1'));
|
||||
first.rerender({ value: 'v2', dirty: true });
|
||||
act(() => first.result.current.saved('server-v2'));
|
||||
first.rerender({ value: 'v2', dirty: false });
|
||||
expect(readDraft('k')).toBeNull();
|
||||
|
||||
// Edits after the save must restore against the saved version, not the
|
||||
// one the editor first loaded.
|
||||
first.rerender({ value: 'v3 edit', dirty: true });
|
||||
first.unmount();
|
||||
const second = mount();
|
||||
let restored: string | null = null;
|
||||
act(() => {
|
||||
restored = second.result.current.load('server-v2');
|
||||
});
|
||||
expect(restored).toBe('v3 edit');
|
||||
});
|
||||
|
||||
it('drops the draft when the edits are undone by a discard', () => {
|
||||
const first = mount();
|
||||
act(() => void first.result.current.load('server-v1'));
|
||||
first.rerender({ value: 'my edit', dirty: true });
|
||||
act(() => first.result.current.discard());
|
||||
first.rerender({ value: 'server-v1', dirty: false });
|
||||
expect(readDraft('k')).toBeNull();
|
||||
expect(first.result.current.status).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps editors apart by key', () => {
|
||||
const a = mount('journal-a');
|
||||
act(() => void a.result.current.load('a'));
|
||||
a.rerender({ value: 'edit in a', dirty: true });
|
||||
|
||||
const b = mount('journal-b');
|
||||
let restored: string | null = 'unset';
|
||||
act(() => {
|
||||
restored = b.result.current.load('a');
|
||||
});
|
||||
expect(restored).toBeNull();
|
||||
});
|
||||
|
||||
it('degrades to no draft when storage is unavailable', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
const { result, rerender } = mount();
|
||||
let restored: string | null = 'unset';
|
||||
act(() => {
|
||||
restored = result.current.load('server-v1');
|
||||
});
|
||||
expect(restored).toBeNull();
|
||||
expect(() => rerender({ value: 'edit', dirty: true })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DraftNotice', () => {
|
||||
const handlers = () => ({ onDiscard: vi.fn(), onRestore: vi.fn(), onDismiss: vi.fn() });
|
||||
|
||||
it('says nothing without a draft', () => {
|
||||
const { container } = render(<DraftNotice status="none" subject="entry" {...handlers()} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('says a restored draft is still unsaved and offers to discard it', () => {
|
||||
const h = handlers();
|
||||
render(<DraftNotice status="restored" subject="entry" {...h} />);
|
||||
expect(screen.getByText(/were restored\. They are not saved yet/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByText('Discard changes'));
|
||||
expect(h.onDiscard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks before restoring over a newer save', () => {
|
||||
const h = handlers();
|
||||
render(<DraftNotice status="conflict" subject="page structure" {...h} />);
|
||||
fireEvent.click(screen.getByText('Restore my changes'));
|
||||
expect(h.onRestore).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText('Discard them'));
|
||||
expect(h.onDiscard).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user