Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions frontend/src/components/localize/editor/LocalizeObjectEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,14 @@ export interface LocalizeObjectEditorProps {
/**
* Commit the winning model box on every frame of this object that has
* none. Never overwrites a frame the annotator already decided.
*
* `onAccepted` fires once the write has landed, and the editor passes its
* own close in: accepting the remainder settles every frame, so there is
* nothing left to do here. Waiting for the write rather than closing on the
* gesture is what keeps a failed accept on screen, where its error toast is
* still about the object in front of you.
*/
onAcceptRemaining: () => void;
onAcceptRemaining: (onAccepted?: () => void) => void;
/** Hand this object's classification back to the classify cockpit. */
onReclassify: () => void;
onClose: () => void;
Expand Down Expand Up @@ -514,6 +520,10 @@ export function LocalizeObjectEditor({
const isClosingRef = useRef(false);
const requestClose = useCallback(() => {
if (isClosingRef.current) return;
// The accept handler hands this to the page, which fires it whenever the
// write lands — possibly after the annotator has already left by another
// door. Closing something that is gone would navigate them back to it.
if (!mountedRef.current) return;
const root = rootRef.current;
if (!root || typeof root.animate !== 'function') {
onClose();
Expand Down Expand Up @@ -554,6 +564,16 @@ export function LocalizeObjectEditor({
animation.addEventListener('cancel', done);
}, [onClose, frameCellRect, peeked, detection]);

// What the accept hands the page. The page fires it whenever the write
// lands, and nothing stops the annotator arrowing on meanwhile — so it must
// resolve to the CURRENT `requestClose`, whose target cell is the frame
// they are on now. The frozen one would shrink into a frame they already
// left. Same ref trick `clearRef` uses to keep the keyboard handler from
// re-binding.
const requestCloseRef = useRef(requestClose);
requestCloseRef.current = requestClose;
const requestLatestClose = useCallback(() => requestCloseRef.current(), []);

// --- Keyboard -----------------------------------------------------------

useEffect(() => {
Expand Down Expand Up @@ -587,7 +607,7 @@ export function LocalizeObjectEditor({
)
return;
if (!isAccepting) {
onAcceptRemaining();
onAcceptRemaining(requestLatestClose);
setAcceptOpen(false);
}
} else {
Expand Down Expand Up @@ -659,6 +679,7 @@ export function LocalizeObjectEditor({
shortcutsOpen,
resetStageZoom,
requestClose,
requestLatestClose,
editable,
isAccepting,
onAcceptRemaining,
Expand Down Expand Up @@ -764,7 +785,7 @@ export function LocalizeObjectEditor({
gapCount={gapCount}
isAccepting={isAccepting}
onConfirm={() => {
onAcceptRemaining();
onAcceptRemaining(requestLatestClose);
setAcceptOpen(false);
}}
onCancel={() => setAcceptOpen(false)}
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/LocalizeAlertPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2297,7 +2297,13 @@ export default function LocalizeAlertPage({ mode }: LocalizeAlertPageProps = {})
onCommit={handleEditorCommit}
onCommitGapFrame={handleEditorCommitGapFrame}
onUnmaterialize={handleEditorUnmaterialize}
onAcceptRemaining={() => quickAcceptLane.mutate(modalContext.laneId)}
// The editor asks to be closed once the boxes are actually written,
// and only then: a failed accept keeps it open, with its own error
// toast still about the object on screen. The rail's copy of the
// same action passes no callback — there is no editor to close.
onAcceptRemaining={onAccepted =>
quickAcceptLane.mutate(modalContext.laneId, { onSuccess: onAccepted })
}
onReclassify={() => handleReclassify(modalContext.laneId)}
onNavigateToDetection={navigateModalTo}
onClose={closeModal}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,56 @@ describe('LocalizeObjectEditor accept remaining', () => {
expect(onCommit).not.toHaveBeenCalled();
});

it('leaves the editor once the accepted boxes are written', () => {
// Accepting the remainder settles every frame of the object, so there is
// nothing left to do here — the editor hands the page a callback and the
// page fires it when the write lands.
const onAcceptRemaining = vi.fn();
const onClose = vi.fn();
renderEditor({ onAcceptRemaining, onClose });
fireEvent.click(screen.getByTestId('editor-accept-remaining'));

fireEvent.keyDown(window, { key: 'Enter' });

expect(onClose).not.toHaveBeenCalled();
onAcceptRemaining.mock.calls[0][0]();
expect(onClose).toHaveBeenCalled();
});

it('leaves the editor when confirmed by click too', () => {
const onAcceptRemaining = vi.fn();
const onClose = vi.fn();
renderEditor({ onAcceptRemaining, onClose });
fireEvent.click(screen.getByTestId('editor-accept-remaining'));

fireEvent.click(screen.getByTestId('accept-remaining-confirm'));

onAcceptRemaining.mock.calls[0][0]();
expect(onClose).toHaveBeenCalled();
});

// Whether a FAILED write leaves the editor open is the page's half of this
// contract — it decides when to fire the callback — and is covered where it
// lives, in LocalizeAlertPage's "keeps the editor open when the write
// fails". An editor-level version could only re-assert that it does not
// close on the keypress, which the test above already pins.

it('does not navigate when the write lands after the editor is gone', () => {
// Accept, then leave before it returns — browser Back, say, which keeps
// the cockpit page (and its mutation) mounted. The late callback must not
// drag the annotator back to wherever the editor had been.
const onAcceptRemaining = vi.fn();
const onClose = vi.fn();
const { unmount } = renderEditor({ onAcceptRemaining, onClose });
fireEvent.click(screen.getByTestId('editor-accept-remaining'));
fireEvent.keyDown(window, { key: 'Enter' });

unmount();
onAcceptRemaining.mock.calls[0][0]();

expect(onClose).not.toHaveBeenCalled();
});

it('warns about frames no model found smoke on, without blocking', () => {
// One frame has candidates, the other has none at all.
renderEditor({
Expand Down Expand Up @@ -1488,6 +1538,28 @@ describe('open/close transition', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});

it('shrinks into the frame it closes from, not the one the accept started on', () => {
// The accept's close is fired by the page whenever the write lands, and
// nothing stops the annotator arrowing on meanwhile. A close frozen at
// gesture time would fly into the cell of a frame they already left —
// possibly one scrolled out of view.
const { animate } = makeAnimateMock();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Element.prototype as any).animate = animate;
const onAcceptRemaining = vi.fn();
const frameCellRect = vi.fn(() => ({ left: 5, top: 6, width: 100, height: 60 }));
const { rerender } = renderEditor({ onAcceptRemaining, frameCellRect });

fireEvent.click(screen.getByTestId('editor-accept-remaining'));
fireEvent.keyDown(window, { key: 'Enter' });

// Write in flight; the annotator steps on to the next frame.
rerender(editorWith({ onAcceptRemaining, frameCellRect, detection: lastDetection }));
onAcceptRemaining.mock.calls[0][0]();

expect(frameCellRect).toHaveBeenLastCalledWith(lastDetection.recorded_at);
});

it('closes immediately when element.animate is unavailable', () => {
const onClose = vi.fn();
renderEditor({ onClose, frameCellRect: () => ({ left: 0, top: 0, width: 1, height: 1 }) });
Expand Down
42 changes: 42 additions & 0 deletions frontend/tests/pages/LocalizeAlertPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ vi.mock('@/components/localize/editor', () => ({
onCommit: (detection: Detection, items: unknown[]) => void;
onCommitGapFrame?: (recordedAt: string, items: unknown[]) => void;
onUnmaterialize?: (detection: Detection) => void;
onAcceptRemaining: (onAccepted?: () => void) => void;
objectOverlays?: Array<{ color: string; label: string; boxes: unknown[] }>;
}) => (
<div data-testid="image-modal">
Expand Down Expand Up @@ -166,6 +167,13 @@ vi.mock('@/components/localize/editor', () => ({
<button type="button" onClick={props.onClose}>
Mock Close
</button>
{/* The real editor's accept hands its own close in, for the page to
fire once the write lands — so the stand-in does the same. Whether
that callback runs on success only is the page's business, and this
button is what lets a test hold it to it. */}
<button type="button" onClick={() => props.onAcceptRemaining(props.onClose)}>
Mock Accept Remaining
</button>
</div>
),
}));
Expand Down Expand Up @@ -2478,6 +2486,40 @@ describe('LocalizeAlertPage', () => {
return await screen.findByTestId('accept-remaining-popover');
};

// The same accept, reached from inside the editor. What the page owes the
// editor there is a close — but only once the boxes are actually written,
// which is the half no editor-level test can see.
describe('from the editor', () => {
const editorWrapper = makeWrapper('/localize/101/object/102/1002');

it('closes the editor once the boxes are written', async () => {
await renderAndSettle(<LocalizeAlertPage />, { wrapper: editorWrapper });
expect(screen.getByTestId('image-modal')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Mock Accept Remaining' }));

await waitFor(() => {
expect(screen.queryByTestId('image-modal')).not.toBeInTheDocument();
});
// Back to the object's own page, object still selected.
expect(screen.getByTestId('location')).toHaveTextContent(
/^\/localize\/101\/object\/102$/
);
});

it('keeps the editor open when the write fails, with the failure in view', async () => {
vi.mocked(apiClient.bulkUpsertDetectionAnnotations).mockRejectedValue(new Error('boom'));
await renderAndSettle(<LocalizeAlertPage />, { wrapper: editorWrapper });

fireEvent.click(screen.getByRole('button', { name: 'Mock Accept Remaining' }));

expect(await screen.findByText(/failed to accept boxes/i)).toBeInTheDocument();
// Closing here would carry the annotator away from work that did not
// happen, and bury the toast on a page that is no longer about it.
expect(screen.getByTestId('image-modal')).toBeInTheDocument();
});
});

it('opens the popover instead of accepting immediately, previewing the active lane', async () => {
await renderAndSettle(<LocalizeAlertPage />, { wrapper });

Expand Down
Loading