mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(assistant): a book page card draws the page after as the server's trial made it
The Studio drew the page after a held edit or cover change by running the card's operations on its own book, which knows nothing of the journey's photos, words and figures, so a caption from a photo, a text from an entry or a map from the route came out differently or not at all. The card now carries the page the server's trial made with the full context, read with the book's schema and within the card's size, and the Studio draws it while its book stands at the version the trial ran on. A book that moved on, a card from before and a page too heavy to carry still run the operations on the book on screen, the book the change will run on. The receipt leaves the page out, since an applied change is drawn from the book itself.
This commit is contained in:
@@ -28,6 +28,7 @@ export default function AssistantSurface({
|
||||
onOpen,
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
}: AssistantSurfaceProps) {
|
||||
const runtime = useAssistantRuntime(tripId);
|
||||
// The phone is always compact; a window is what its width says.
|
||||
@@ -39,8 +40,9 @@ export default function AssistantSurface({
|
||||
onOpen,
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -27,6 +27,13 @@ export interface AssistantView {
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
/** Brings the map into view where the chat covers it (the phone), before the map moves. */
|
||||
onRevealMap?: () => void;
|
||||
/**
|
||||
* The stored book's version the book open in TREK Studio stands on, null
|
||||
* before it has one; left out outside the Studio. A book card draws the page
|
||||
* after as the server's trial made it only while the book is still at the
|
||||
* version the trial ran on.
|
||||
*/
|
||||
bookVersion?: number | null;
|
||||
}
|
||||
|
||||
export const AssistantViewContext = createContext<AssistantView | null>(null);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* FE-AI-BOOKPAGE-001 to FE-AI-BOOKPAGE-006
|
||||
* FE-AI-BOOKPAGE-001 to FE-AI-BOOKPAGE-007
|
||||
*
|
||||
* The photo book's change rows in the Studio's chat: the pages a change works
|
||||
* on named as the Studio names them, its facts as badges (new pages, where a
|
||||
* moved page lands, the layout, new photos, findings to check), the page
|
||||
* before and after drawn from the book open in the Studio (each page of a
|
||||
* delete alone), a thumbnail that shows its page, each finding named once, a
|
||||
* receipt that opens the Studio on the page, and a discarded change struck
|
||||
* through with no page drawn.
|
||||
* delete alone, the page after as the server made it when the card carries
|
||||
* it and the book is still at the trial's version), a thumbnail that shows
|
||||
* its page, each finding named once, a receipt that opens the Studio on the
|
||||
* page, and a discarded change struck through with no page drawn.
|
||||
*/
|
||||
import {
|
||||
type AssistantApplied,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
blankBook,
|
||||
blankSpread,
|
||||
type BookPageCard,
|
||||
bookTextElementSchema,
|
||||
} from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -222,4 +224,36 @@ describe('the book page card', () => {
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Show this page' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-007: a waiting edit draws the page after as the server made it, where the Studio alone could not run it, until the book moves on', async () => {
|
||||
useStudioStore.setState({ doc: book(), activeSpread: 0 });
|
||||
// A caption the book on screen cannot word: run in the Studio it comes to nothing, so only the page before shows.
|
||||
const caption = card({ ops: [{ op: 'caption', photoElementId: 'p-81' }] });
|
||||
const first = inView(<AssistantChangeCard items={[item('pending', caption, 'editBookPage')]} />);
|
||||
expect(await screen.findAllByRole('button', { name: 'Show this page' })).toHaveLength(1);
|
||||
expect(screen.queryByText('After')).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
// The page the server's trial made, its caption worded from the journey, is drawn after it.
|
||||
const words = bookTextElementSchema.parse({
|
||||
id: 't-cap',
|
||||
kind: 'text',
|
||||
frame: { x: 20, y: 150, w: 120, h: 8 },
|
||||
text: 'Tram 28 at dawn',
|
||||
});
|
||||
const made = { ...blankSpread('inner', () => 'sp-3'), elements: [words] };
|
||||
const drawn = item('pending', { ...caption, after: made }, 'editBookPage');
|
||||
const second = inView(<AssistantChangeCard items={[drawn]} />, { bookVersion: 4 });
|
||||
expect(await screen.findByText('After')).toBeInTheDocument();
|
||||
expect(screen.getByText('Before')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tram 28 at dawn')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Show this page' })).toHaveLength(2);
|
||||
second.unmount();
|
||||
|
||||
// The book moved on since the trial (another change applied, a save): its page is not what the change would leave.
|
||||
inView(<AssistantChangeCard items={[drawn]} />, { bookVersion: 5 });
|
||||
expect(await screen.findAllByRole('button', { name: 'Show this page' })).toHaveLength(1);
|
||||
expect(screen.queryByText('After')).toBeNull();
|
||||
expect(screen.queryByText('Tram 28 at dawn')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,18 @@ import { useStudioStore } from '../../../../store/studioStore';
|
||||
import { SpreadThumb } from '../../../Studio/SpreadThumb';
|
||||
import { useStudioPageHint } from '../../../Studio/useStudioPageHint';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantScope } from '../../assistantViewContext';
|
||||
import { useAssistantScope, useAssistantView } from '../../assistantViewContext';
|
||||
import { type BookPageView, pageViewOf } from './bookCardModel';
|
||||
|
||||
/**
|
||||
* The page of a book change drawn small, from the book open in the Studio:
|
||||
* while the change waits, the page as it is and as the change would leave it
|
||||
* (a delete draws each page it takes); once applied, the page as it is now.
|
||||
* Each is the editor's own view under a scale, so the card can never look
|
||||
* different from the page. Pressing one shows that page in the Studio.
|
||||
* Nothing where the Studio does not hold the page, outside the Studio or on
|
||||
* another book.
|
||||
* (the page the server's trial made, which the card carries, while the book
|
||||
* is at the version the trial ran on; a delete draws each page it takes);
|
||||
* once applied, the page as it is now. Each is the editor's own view under a
|
||||
* scale, so the card can never look different from the page. Pressing one
|
||||
* shows that page in the Studio. Nothing where the Studio does not hold the
|
||||
* page, outside the Studio or on another book.
|
||||
*/
|
||||
|
||||
/** A thumbnail's height in pixels; its width follows the page. */
|
||||
@@ -83,9 +84,10 @@ function PageViewThumbs({ view, page, onShow }: { view: BookPageView; page: Book
|
||||
|
||||
export default function BookPageThumbs({ card, item }: { card: BookPageCard; item: ChangeItem }) {
|
||||
const scope = useAssistantScope();
|
||||
const version = useAssistantView().bookVersion ?? null;
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const setActiveSpread = useStudioStore((s) => s.setActiveSpread);
|
||||
const views = useMemo(() => pageViewOf(card, doc, item.state), [card, doc, item.state]);
|
||||
const views = useMemo(() => pageViewOf(card, doc, item.state, version), [card, doc, item.state, version]);
|
||||
// The Studio's book is this card's only in this journey's chat; anywhere else it may be another or an old copy.
|
||||
const ownBook = scope.kind === 'journey' && scope.journeyId === card.journeyId;
|
||||
if (!ownBook || views.length === 0 || !doc) return null;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-014
|
||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
|
||||
*
|
||||
* The pure side of the book's page card: a page named as the Studio names
|
||||
* it, a layout and a finding by their words, the findings worth a look, the
|
||||
* spreads the card draws from the book on screen for each state of a change
|
||||
* (each page of a delete alone), and where an applied change opens the Studio.
|
||||
* (each page of a delete alone, the page after as the server made it when
|
||||
* the card carries it and the book is still at the trial's version), and
|
||||
* where an applied change opens the Studio.
|
||||
*/
|
||||
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -136,4 +138,59 @@ describe('bookCardModel', () => {
|
||||
expect(studioTarget(3, 'sp-3')).toEqual({ kind: 'page', path: '/journey/3/studio?page=sp-3' });
|
||||
expect(studioTarget(3, null)).toEqual({ kind: 'page', path: '/journey/3/studio' });
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-015: at the version of the trial a waiting edit or cover draws the page after the server made, never a run of its own; a card without it runs its operations', () => {
|
||||
const doc = book();
|
||||
const made = { ...blankSpread('inner', () => 'sp-3'), background: '#e6eef4' };
|
||||
// A caption the Studio's book cannot word is refused when run here; the server's page draws it all the same.
|
||||
const caption = card({ ops: [{ op: 'caption', photoElementId: 'p-81' }], after: made });
|
||||
expect(pageViewOf(caption, doc, 'pending', 4)).toMatchObject([{ index: 2, before: { id: 'sp-3' } }]);
|
||||
expect(pageViewOf(caption, doc, 'pending', 4)[0]?.after).toBe(made);
|
||||
// Operations the book on screen would take are not run either: the page is the server's, frames and all.
|
||||
expect(pageViewOf(card({ after: made }), doc, 'pending', 4)[0]?.after).toBe(made);
|
||||
expect(pageViewOf(card({ after: made, ops: [] }), doc, 'pending', 4)[0]?.after).toBe(made);
|
||||
const coverPage = { ...blankSpread('cover', () => 'sp-1'), background: '#f4efe6' };
|
||||
const cover = card({
|
||||
pages: [{ ...INNER, spreadId: 'sp-1', index: 0, role: 'cover', number: null }],
|
||||
ops: [],
|
||||
cover: { template: 'cover-full', photoId: null, title: 'Portugal', subtitle: null },
|
||||
after: coverPage,
|
||||
});
|
||||
expect(pageViewOf(cover, doc, 'pending', 4)[0]?.after).toBe(coverPage);
|
||||
|
||||
// Without the page (a card from before it, or a page too heavy to carry), or with another page, the operations run.
|
||||
const photos = (view: ReturnType<typeof pageViewOf>) =>
|
||||
view[0]?.after?.elements.filter((element) => element.kind === 'photo').length;
|
||||
expect(photos(pageViewOf(card(), doc, 'pending', 4))).toBe(2);
|
||||
expect(photos(pageViewOf(card({ after: { ...made, id: 'sp-4' } }), doc, 'pending', 4))).toBe(2);
|
||||
// Once applied, the page is the book's as it is now.
|
||||
expect(pageViewOf(card({ after: made }), doc, 'applied', 4)[0]?.after).toBe(doc.spreads[2]);
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-016: once the book moved on from the trial, or its version is not known, the change is run on the book as it is', () => {
|
||||
const doc = book();
|
||||
const made = { ...blankSpread('inner', () => 'sp-3'), background: '#e6eef4' };
|
||||
const photos = (view: ReturnType<typeof pageViewOf>) =>
|
||||
view[0]?.after?.elements.filter((element) => element.kind === 'photo').length;
|
||||
// Another change applied or a save since: the change will run on this book, so its operations are run on it.
|
||||
expect(photos(pageViewOf(card({ after: made }), doc, 'pending', 5))).toBe(2);
|
||||
// A caption this book cannot word then draws no page after, never the trial's page of an older book.
|
||||
const caption = card({ ops: [{ op: 'caption', photoElementId: 'p-81' }], after: made });
|
||||
expect(pageViewOf(caption, doc, 'pending', 5)).toMatchObject([{ before: { id: 'sp-3' }, after: null }]);
|
||||
// Nor does a card whose operations gave way to the page.
|
||||
expect(pageViewOf(card({ after: made, ops: [] }), doc, 'pending', 5)).toMatchObject([{ after: null }]);
|
||||
// A cover is set again on the cover as it is.
|
||||
const cover = card({
|
||||
pages: [{ ...INNER, spreadId: 'sp-1', index: 0, role: 'cover', number: null }],
|
||||
ops: [],
|
||||
cover: { template: 'cover-full', photoId: null, title: 'Portugal', subtitle: null },
|
||||
after: { ...blankSpread('cover', () => 'sp-1'), background: '#f4efe6' },
|
||||
});
|
||||
const [covered] = pageViewOf(cover, doc, 'pending', 5);
|
||||
expect(covered?.after?.id).toBe('sp-1');
|
||||
expect(covered?.after).not.toBe(cover.after);
|
||||
// Where the Studio does not say its version, or the card names none, the operations run too.
|
||||
expect(photos(pageViewOf(card({ after: made }), doc, 'pending'))).toBe(2);
|
||||
expect(photos(pageViewOf(card({ after: made, baseVersion: null }), doc, 'pending', null))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,13 @@ import type { ChangeState } from '../../assistantParts';
|
||||
/**
|
||||
* The pure side of the book's page card: how a page is named, what a layout
|
||||
* and a finding are called, and which spreads the card draws. The page before
|
||||
* and after a change comes from the Studio's own book with the shared engine,
|
||||
* the one the server tried the change with, so the card shows what the change
|
||||
* does to the page as it is on screen.
|
||||
* a change comes from the Studio's own book; the page after is the one the
|
||||
* server's trial of the change made with the journey's photos, words and
|
||||
* figures, which the card carries, so it shows the page as the server would
|
||||
* write it, while the Studio's book is at the version the trial ran on. A
|
||||
* card without that page, or a book that moved on since, runs the card's
|
||||
* operations on the Studio's book with the shared engine, the one the server
|
||||
* tried the change with: the change will run on that book.
|
||||
*/
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
@@ -118,8 +122,18 @@ function coverArgs(cover: BookCardCover): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(cover).filter(([, value]) => value !== null));
|
||||
}
|
||||
|
||||
/** The page as the change would leave it, run on the Studio's book; null when the card carries nothing to run. */
|
||||
function afterOf(card: BookPageCard, doc: BookDocument, spreadId: string): BookSpread | null {
|
||||
/**
|
||||
* The page as the change would leave it: the one the server's trial made,
|
||||
* when the card carries it for this page and the Studio's book is still at
|
||||
* the version the trial ran on; else run on the Studio's book without the
|
||||
* journey's photos, words and figures a binding or a travel element reads.
|
||||
* A book that moved on (another change applied, a save by hand or by someone
|
||||
* else) is the book the change will run on, so the trial's page would show
|
||||
* the page as it no longer is. Null when the card carries nothing to run.
|
||||
*/
|
||||
function afterOf(card: BookPageCard, doc: BookDocument, spreadId: string, version: number | null): BookSpread | null {
|
||||
const atTrial = card.baseVersion !== null && card.baseVersion === version;
|
||||
if (atTrial && card.after?.id === spreadId) return card.after;
|
||||
let made: ReturnType<typeof applyBookOps> | null = null;
|
||||
if (card.cover) made = setBookCover(doc, coverArgs(card.cover));
|
||||
else if (card.ops.length > 0) made = applyBookOps(doc, { spreadId, ops: card.ops });
|
||||
@@ -140,9 +154,15 @@ function spreadAt(doc: BookDocument, spreadId: string): { index: number; current
|
||||
* (a move shows its page alone, a delete each page it takes alone); once
|
||||
* applied, the page as it is now. Nothing for new pages not made yet, for a
|
||||
* change that did not run, or where the book on screen does not hold the
|
||||
* page (another book, or none open).
|
||||
* page (another book, or none open). `version` is the stored book's version
|
||||
* the book on screen stands on, null where the Studio does not say.
|
||||
*/
|
||||
export function pageViewOf(card: BookPageCard, doc: BookDocument | null, state: ChangeState): BookPageView[] {
|
||||
export function pageViewOf(
|
||||
card: BookPageCard,
|
||||
doc: BookDocument | null,
|
||||
state: ChangeState,
|
||||
version: number | null = null
|
||||
): BookPageView[] {
|
||||
if (!doc || GONE.has(state)) return [];
|
||||
if (card.change === 'willDelete' && state !== 'applied') {
|
||||
return card.pages.flatMap((page) => {
|
||||
@@ -155,5 +175,5 @@ export function pageViewOf(card: BookPageCard, doc: BookDocument | null, state:
|
||||
if (!spreadId || !found) return [];
|
||||
if (state === 'applied') return [{ index: found.index, before: null, after: found.current }];
|
||||
if (card.change === 'new') return [];
|
||||
return [{ index: found.index, before: found.current, after: afterOf(card, doc, spreadId) }];
|
||||
return [{ index: found.index, before: found.current, after: afterOf(card, doc, spreadId, version) }];
|
||||
}
|
||||
|
||||
@@ -133,7 +133,16 @@ export function StudioPageStage({ assistant }: { assistant: StudioAssistant }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudioAssistantPanel({ assistant, title }: { assistant: StudioAssistant; title: string }) {
|
||||
export default function StudioAssistantPanel({
|
||||
assistant,
|
||||
title,
|
||||
bookVersion,
|
||||
}: {
|
||||
assistant: StudioAssistant;
|
||||
title: string;
|
||||
/** The stored book's version the open book stands on, for the book's cards in the chat. */
|
||||
bookVersion?: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const thread = useAssistantThread(assistant.chatKey);
|
||||
@@ -174,6 +183,7 @@ export default function StudioAssistantPanel({ assistant, title }: { assistant:
|
||||
variant="window"
|
||||
density="compact"
|
||||
onOpen={onOpen}
|
||||
bookVersion={bookVersion}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
@@ -198,8 +208,16 @@ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistan
|
||||
}
|
||||
|
||||
/** The chat column at the end of the Studio's body, while it is open, named after the journey. */
|
||||
export function StudioAssistantColumn({ assistant, title }: { assistant: StudioAssistant; title?: string }) {
|
||||
export function StudioAssistantColumn({
|
||||
assistant,
|
||||
title,
|
||||
bookVersion,
|
||||
}: {
|
||||
assistant: StudioAssistant;
|
||||
title?: string;
|
||||
bookVersion?: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!assistant.open) return null;
|
||||
return <StudioAssistantPanel assistant={assistant} title={title || t('journey.title')} />;
|
||||
return <StudioAssistantPanel assistant={assistant} title={title || t('journey.title')} bookVersion={bookVersion} />;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function StudioShell() {
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
/>
|
||||
<StudioAssistantColumn assistant={assistant} title={s.journey?.title} />
|
||||
<StudioAssistantColumn assistant={assistant} title={s.journey?.title} bookVersion={s.bookVersion} />
|
||||
</div>
|
||||
<StudioPageStage assistant={assistant} />
|
||||
|
||||
|
||||
@@ -777,6 +777,8 @@ export function useJourneyStudio() {
|
||||
saveNow: book.saveNow,
|
||||
/** Writes everything local and says whether it landed: before an assistant turn or apply. */
|
||||
flushBook: book.flush,
|
||||
/** The stored book's version the open book stands on, null before it has one: the chat's book cards read it. */
|
||||
bookVersion: book.record?.version ?? null,
|
||||
acceptTheirs: book.acceptTheirs,
|
||||
keepMine: book.keepMine,
|
||||
spread,
|
||||
|
||||
Reference in New Issue
Block a user