diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8bb19ef..5c0115c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -340,6 +340,7 @@ export function App() { if (!element || !visible || isPanelDocked(panel, panels.width)) return [] const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return + if (event.target instanceof HTMLElement && event.target.closest('.forge-history-rename')) return const modal = [...document.querySelectorAll('[role="dialog"]')] .find((candidate) => candidate !== element && !candidate.contains(element) && candidate.getClientRects().length > 0) if (modal) return diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index 3c047ac..ebb175f 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -1226,7 +1226,15 @@ test('uses direct OME for new conversions and keeps direct results readable', as await waitFor(() => expect(screen.getByText('Viewer connected').parentElement) .toHaveTextContent('Next conversion ยท Direct OME-TIFF')) - expect(screen.getByRole('button', { name: 'Convert to direct OME-TIFF' })).toBeVisible() + await waitFor(() => expect(screen.getByRole('button', { name: /Open and approve result/ }) + .closest('li')).toHaveAttribute('aria-current', 'step')) + await waitFor(() => expect(screen.getByRole('button', { name: /Open and approve result/ })) + .toHaveAttribute('aria-expanded', 'true')) + const convertStep = screen.getByRole('button', { name: /Create direct OME-TIFF/ }) + fireEvent.click(convertStep) + await waitFor(() => expect(screen.getByRole('button', { name: /Create direct OME-TIFF/ })) + .toHaveAttribute('aria-expanded', 'true')) + expect(await screen.findByRole('button', { name: 'Convert to direct OME-TIFF' })).toBeVisible() const result = await screen.findByRole('region', { name: 'Converted slide result' }) expect(within(result).getByText('Direct OME-TIFF')).toBeVisible() expect(within(result).getByText('104.9 MB')).toBeVisible() @@ -1597,6 +1605,7 @@ test('keeps the original viewer visible while the upload package is still buildi }) test('views, renames, downloads, and deletes saved conversions from History', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 }) const dataset: api.Dataset = { id: 'history-slide', displayName: 'History slide.vsi', @@ -1672,6 +1681,7 @@ test('views, renames, downloads, and deletes saved conversions from History', as vi.mocked(api.deleteArtifact).mockResolvedValue(dataset) render() + fireEvent.click(await screen.findByRole('button', { name: 'Open slide inspector' })) const mainViewer = await screen.findByTestId('forge-osd') expect(mainViewer).toHaveAttribute( @@ -1705,6 +1715,11 @@ test('views, renames, downloads, and deletes saved conversions from History', as '/api/datasets/history-slide/artifacts/history-older/derivative/slide.dzi', ) + fireEvent.click(within(olderCard).getByRole('button', { name: 'Rename' })) + fireEvent.keyDown(within(olderCard).getByRole('textbox', { name: 'Conversion name' }), { key: 'Escape' }) + expect(within(olderCard).queryByRole('textbox', { name: 'Conversion name' })).not.toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'History' })).toBeVisible() + fireEvent.click(within(olderCard).getByRole('button', { name: 'Rename' })) fireEvent.change(within(olderCard).getByRole('textbox', { name: 'Conversion name' }), { target: { value: 'Review region' }, diff --git a/src/main/java/org/pathlab/forge/viewer/SqliteViewerSyncStore.java b/src/main/java/org/pathlab/forge/viewer/SqliteViewerSyncStore.java index 9f0926f..14bf317 100644 --- a/src/main/java/org/pathlab/forge/viewer/SqliteViewerSyncStore.java +++ b/src/main/java/org/pathlab/forge/viewer/SqliteViewerSyncStore.java @@ -117,7 +117,17 @@ ON CONFLICT(slide_id) DO UPDATE SET display_name=excluded.display_name, metadata_revision=excluded.metadata_revision,folder_revision=excluded.folder_revision, thumbnail_url=excluded.thumbnail_url,tile_url=excluded.tile_url, remote_updated_at=excluded.remote_updated_at,content_bytes=excluded.content_bytes, - content_sha256=excluded.content_sha256,metadata_json=excluded.metadata_json + content_sha256=excluded.content_sha256,metadata_json=excluded.metadata_json, + download_state=CASE WHEN download_state='READY' AND ( + content_revision != excluded.content_revision + OR (excluded.content_bytes > 0 AND download_bytes != excluded.content_bytes) + OR (excluded.content_sha256 != '' AND lower(download_sha256) != lower(excluded.content_sha256)) + ) THEN 'FAILED' ELSE download_state END, + download_detail=CASE WHEN download_state='READY' AND ( + content_revision != excluded.content_revision + OR (excluded.content_bytes > 0 AND download_bytes != excluded.content_bytes) + OR (excluded.content_sha256 != '' AND lower(download_sha256) != lower(excluded.content_sha256)) + ) THEN 'Viewer content changed; keep a current offline copy' ELSE download_detail END """)) { statement.setString(1, slide.id()); statement.setString(2, slide.displayName()); diff --git a/src/main/java/org/pathlab/forge/viewer/ViewerSyncService.java b/src/main/java/org/pathlab/forge/viewer/ViewerSyncService.java index a08b632..ef413ea 100644 --- a/src/main/java/org/pathlab/forge/viewer/ViewerSyncService.java +++ b/src/main/java/org/pathlab/forge/viewer/ViewerSyncService.java @@ -429,7 +429,9 @@ static Instant parseViewerInstant(String value) throws IOException { } private void ensureSpace(long bytes) throws IOException { - var required = bytes + Math.max(1, bytes / 10); + long required; + try { required = Math.addExact(bytes, Math.max(1, bytes / 10)); } + catch (ArithmeticException error) { throw new IOException("Viewer slide is too large for offline storage", error); } if (Files.getFileStore(offlineRoot).getUsableSpace() < required) { throw new IOException("Not enough disk space to keep this slide offline"); } diff --git a/src/test/java/org/pathlab/forge/viewer/ViewerRecoveryTest.java b/src/test/java/org/pathlab/forge/viewer/ViewerRecoveryTest.java index 9831dcc..28a62a4 100644 --- a/src/test/java/org/pathlab/forge/viewer/ViewerRecoveryTest.java +++ b/src/test/java/org/pathlab/forge/viewer/ViewerRecoveryTest.java @@ -29,6 +29,8 @@ private static final class Client implements ViewerAuthorizedClient { volatile String key = A; byte[] content = CONTENT; InputStream stream; + long headBytes = CONTENT.length; + String headSha = ""; IOException patchFailure; String range = ""; int requests; @@ -38,7 +40,7 @@ private static final class Client implements ViewerAuthorizedClient { requests++; if (method.equals("PATCH")) throw patchFailure; try { - if (method.equals("HEAD")) return response(200,Map.of("content-length",List.of(String.valueOf(CONTENT.length)),"x-pathlab-sha256",List.of(sha(CONTENT))),new ByteArrayInputStream(new byte[0])); + if (method.equals("HEAD")) return response(200,Map.of("content-length",List.of(String.valueOf(headBytes)),"x-pathlab-sha256",List.of(headSha.isBlank() ? sha(CONTENT) : headSha)),new ByteArrayInputStream(new byte[0])); } catch (Exception error) { throw new IOException(error); } if (path.contains("/content")) { gets++; @@ -114,6 +116,35 @@ private static final class Client implements ViewerAuthorizedClient { } } + @Test void changedRemoteContentCannotReuseAReadyOfflineCopy() throws Exception { + var client = new Client(); + try (var store = new SqliteViewerSyncStore(temp.resolve("changed.db")); var service = new ViewerSyncService(client,store,temp.resolve("offline"))) { + store.upsertRemote(slide()); + var retained = service.keepOffline("s1"); + var replacement = "replacement OME content".getBytes(StandardCharsets.UTF_8); + store.upsertRemote(new ViewerRemoteSlide("s1", "Original", "", "ready_private", 2, 0, 3, 4, + "/thumb", "/tiles", replacement.length, sha(replacement), Map.of(), Instant.parse("2026-08-12T00:01:00Z"))); + + assertTrue(Files.exists(retained)); + assertTrue(service.offlineFile("s1").isEmpty()); + assertEquals("FAILED", store.find("s1").orElseThrow().downloadState()); + assertEquals("Viewer content changed; keep a current offline copy", store.find("s1").orElseThrow().downloadDetail()); + } + } + + @Test void overflowingOfflineSpaceReservationFailsBeforeBodyDownload() throws Exception { + var client = new Client(); + client.headBytes = Long.MAX_VALUE; + client.headSha = sha(CONTENT); + try (var store = new SqliteViewerSyncStore(temp.resolve("overflow.db")); var service = new ViewerSyncService(client,store,temp.resolve("offline"))) { + store.upsertRemote(new ViewerRemoteSlide("s1", "Original", "", "ready_private", 1, 0, 3, 4, + "/thumb", "/tiles", Long.MAX_VALUE, sha(CONTENT), Map.of(), Instant.parse("2026-08-12T00:00:00Z"))); + + assertThrows(IOException.class, () -> service.keepOffline("s1")); + assertEquals(0, client.gets); + } + } + @Test void resumedContentRequiresExactContentRangeBeforeAppending() throws Exception { var client = new Client(); client.range = "bytes 0-18/19"; try (var store = new SqliteViewerSyncStore(temp.resolve("range.db")); var service = new ViewerSyncService(client,store,temp.resolve("offline"))) {