From 26bb1d0c56935426d2d5543762486cf118262d5d Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 5 Jul 2026 10:30:21 -0400 Subject: [PATCH] fix: refresh the slideshow in place after an index update, and never leave index progress stuck on idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an index update finished (semantic-map reindex button or Album Management), the swiper and grid kept their stale image count and slides — new images were unreachable without a page reload, and the Album Manager path additionally yanked the user back to slide 0 and cleared any active search. Both completion paths now dispatch albumChanged with a new changeType "refresh" carrying the fresh filename_count, meaning "same album, image set changed in place": - slide-state.js keeps the current position and search results, clamping against the new total (indices only shift when images were removed; out-of-range search hits are dropped, search mode exits only if none survive) - the swiper and grid rebuild through their existing listeners; the grid bumps its thumbnail cache-breaker - umap.js ignores the refresh (the map reloads via albumIndexUpdated, and re-initializing would force the window fullscreen — that quirk on the Album Manager path is gone too); the albumIndexUpdated listener reloads immediately only while the window is visible, otherwise it just marks the data stale for the next open - bookmarks and the back-stack keep their in-memory state instead of treating the refresh as an album switch Also fixes the "No operation in progress (0/0)" stuck status when updating an InvokeAI board album: the run only registered itself with the progress tracker after the board fetch and the existing-index load, and set_error dropped errors when no tracker entry existed — so a failure before scanning started (e.g. InvokeAI unreachable on the first update after a server restart) left the card polling "idle" forever with the real error only in the server log. The update endpoint now registers the operation before scheduling the background task (also closing a double-start race), the board fetch and npz load report progress steps, and set_error creates the entry it needs. Co-Authored-By: Claude Fable 5 --- photomap/backend/embeddings.py | 7 +- photomap/backend/progress.py | 21 +++- photomap/backend/routers/index.py | 12 ++ .../static/javascript/album-manager.js | 12 +- .../frontend/static/javascript/back-stack.js | 25 ++++ .../frontend/static/javascript/bookmarks.js | 7 ++ .../frontend/static/javascript/grid-view.js | 5 +- .../frontend/static/javascript/slide-state.js | 20 ++++ .../static/javascript/umap-reindex.js | 40 ++++++- photomap/frontend/static/javascript/umap.js | 20 +++- tests/backend/test_index.py | 32 +++++ tests/backend/test_invokeai_board_index.py | 24 ++++ tests/backend/test_progress.py | 15 +++ tests/frontend/album-manager-progress.test.js | 56 ++++++++- tests/frontend/slide-state-refresh.test.js | 111 ++++++++++++++++++ tests/frontend/umap-reindex.test.js | 15 ++- 16 files changed, 398 insertions(+), 24 deletions(-) create mode 100644 tests/frontend/slide-state-refresh.test.js diff --git a/photomap/backend/embeddings.py b/photomap/backend/embeddings.py index 6b62c7ee..2124d3e0 100644 --- a/photomap/backend/embeddings.py +++ b/photomap/backend/embeddings.py @@ -1497,9 +1497,12 @@ async def update_index_async( ) try: - existing = self._load_existing_index_arrays() - + # Register before loading: reading a large existing .npz can take + # seconds, and progress polls during it must not report "idle". progress_tracker.start_operation(album_key, 0, "scanning") + progress_tracker.update_progress(album_key, 0, "Loading existing index...") + + existing = self._load_existing_index_arrays() def traversal_callback(count, message): # Update the total as we discover more files. diff --git a/photomap/backend/progress.py b/photomap/backend/progress.py index 3da4ebc2..38171f0f 100644 --- a/photomap/backend/progress.py +++ b/photomap/backend/progress.py @@ -173,10 +173,23 @@ def begin_indexing(self, album_key: str, total_images: int) -> None: def set_error(self, album_key: str, error_message: str): """Set error status for an album.""" with self._lock: - if album_key in self._progress: - progress = self._progress[album_key] - progress.status = IndexStatus.ERROR - progress.error_message = error_message + progress = self._progress.get(album_key) + if progress is None: + # A failure can hit before the run's first start_operation + # (e.g. the InvokeAI board fetch). Create the entry rather + # than dropping the error, or the frontend polls "idle" + # forever and the failure is invisible outside the log. + progress = ProgressInfo( + album_key=album_key, + status=IndexStatus.ERROR, + current_step="", + images_processed=0, + total_images=0, + start_time=time.time(), + ) + self._progress[album_key] = progress + progress.status = IndexStatus.ERROR + progress.error_message = error_message def set_completion_warning(self, album_key: str, message: str | None) -> None: """Record (or clear) a non-fatal notice to attach when the run completes. diff --git a/photomap/backend/routers/index.py b/photomap/backend/routers/index.py index a2353f09..923bb38e 100644 --- a/photomap/backend/routers/index.py +++ b/photomap/backend/routers/index.py @@ -94,6 +94,15 @@ async def update_index_async( ) album_config = validate_album_exists(album_key) + # Register the run before the background task is even scheduled, so + # the very first progress poll sees a live operation instead of + # "idle": board resolution and loading a large existing index both + # happen before the first in-task start_operation, and a poller that + # reads "idle" during that window paints "No operation in progress". + # It also guarantees set_error() has an entry to land on if the task + # dies before scanning starts (e.g. InvokeAI unreachable). + progress_tracker.start_operation(album_key, 0, "scanning") + progress_tracker.update_progress(album_key, 0, "Preparing index update...") background_tasks.add_task( _update_index_background_async, album_key, album_config ) @@ -551,6 +560,9 @@ async def _update_index_background_async(album_key: str, album_config): """Background task for updating index with async support.""" try: if getattr(album_config, "source_type", "directory") == "invokeai_board": + progress_tracker.update_progress( + album_key, 0, "Fetching board contents from InvokeAI..." + ) try: image_paths, missing = await _resolve_board_album_files(album_config) except HTTPException as e: diff --git a/photomap/frontend/static/javascript/album-manager.js b/photomap/frontend/static/javascript/album-manager.js index 8ba63850..d85a01ff 100644 --- a/photomap/frontend/static/javascript/album-manager.js +++ b/photomap/frontend/static/javascript/album-manager.js @@ -1823,11 +1823,6 @@ export class AlbumManager { await loadAvailableAlbums(); this.showIndexingCompletedUI(cardElement); - if (albumKey === state.album) { - console.log(`Refreshing slideshow for completed indexing of current album: ${albumKey}`); - this.single_swiper.resetAllSlides(); - } - // After a delay set the status if (cardElement) { setTimeout(async () => { @@ -1877,11 +1872,18 @@ export class AlbumManager { } } if (albumKey === state.album) { + // Reload the semantic map (or mark it stale if it's hidden). + window.dispatchEvent(new CustomEvent("albumIndexUpdated", { detail: { albumKey } })); + // Rebuild the slideshow/grid against the new image count; the + // "refresh" type keeps the current position and any active search + // instead of resetting to the first slide (see slide-state.js). const album = await getIndexMetadata(albumKey); window.dispatchEvent( new CustomEvent("albumChanged", { detail: { + album: albumKey, totalImages: album ? album.filename_count : 0, + changeType: "refresh", }, }) ); diff --git a/photomap/frontend/static/javascript/back-stack.js b/photomap/frontend/static/javascript/back-stack.js index 2569235f..5c682197 100644 --- a/photomap/frontend/static/javascript/back-stack.js +++ b/photomap/frontend/static/javascript/back-stack.js @@ -221,6 +221,31 @@ class BackStack { this._notifyChanged(); return; } + if (detail && detail.changeType === "refresh") { + // Same album re-indexed in place. Index updates append, so existing + // entries stay valid unless images were removed — drop only the ones + // that now point past the end rather than wiping the whole stack. + const total = typeof detail.totalImages === "number" ? detail.totalImages : Infinity; + let newCursor = this._cursor; + const kept = []; + for (let i = 0; i < this._entries.length; i++) { + if (this._entries[i].globalIndex >= total) { + if (i <= this._cursor) { + newCursor--; + } + continue; + } + kept.push(this._entries[i]); + } + if (kept.length !== this._entries.length) { + this._entries = kept; + this._cursor = kept.length === 0 ? -1 : Math.max(0, Math.min(newCursor, kept.length - 1)); + this._historyStates = [this._historyStates[0]]; + this._currentHistoryIdx = 0; + this._notifyChanged(); + } + return; + } // Album switch / move / index change: cross-album back doesn't make sense. // The next slideChanged is recorded as a plain step (NOT a jump), so the // album load itself doesn't create a browser-history entry. If we did diff --git a/photomap/frontend/static/javascript/bookmarks.js b/photomap/frontend/static/javascript/bookmarks.js index 87a39e58..db4d2b43 100644 --- a/photomap/frontend/static/javascript/bookmarks.js +++ b/photomap/frontend/static/javascript/bookmarks.js @@ -97,6 +97,13 @@ class BookmarkManager { this.updateAllBookmarkIcons(); return; } + if (detail.changeType === "refresh") { + // Same album re-indexed in place: the bookmark set is unchanged, and + // resetting isShowingBookmarks/previousSearchResults would drop the + // user's bookmark-browsing context mid-session. + this.updateAllBookmarkIcons(); + return; + } this.loadBookmarks(); this.isShowingBookmarks = false; this.previousSearchResults = null; diff --git a/photomap/frontend/static/javascript/grid-view.js b/photomap/frontend/static/javascript/grid-view.js index dc957324..912fc237 100644 --- a/photomap/frontend/static/javascript/grid-view.js +++ b/photomap/frontend/static/javascript/grid-view.js @@ -201,8 +201,9 @@ class GridViewManager { }); this.addEventListener(window, "albumChanged", async (e) => { - // Update cache breaker to force thumbnail reload, especially for deletions - if (e.detail?.changeType === "deletion") { + // Update cache breaker to force thumbnail reload whenever the image + // set changed underneath existing indices (deletion, in-place reindex) + if (e.detail?.changeType === "deletion" || e.detail?.changeType === "refresh") { this.cacheBreaker = Date.now(); } await this.resetAllSlides(); diff --git a/photomap/frontend/static/javascript/slide-state.js b/photomap/frontend/static/javascript/slide-state.js index 9b0d12c8..3a5cc613 100644 --- a/photomap/frontend/static/javascript/slide-state.js +++ b/photomap/frontend/static/javascript/slide-state.js @@ -247,6 +247,26 @@ class SlideStateManager { } handleAlbumChanged(detail) { + // A "refresh" is the same album whose index was rebuilt in place (e.g. + // the semantic map's reindex button): keep the current position and any + // active search instead of jumping back to the first slide. Index + // updates append new images, so existing global indices only shift when + // images were removed — clamp against the new total for that case. + if (detail.changeType === "refresh") { + this.totalAlbumImages = detail.totalImages; + if (this.isSearchMode) { + this.searchResults = this.searchResults.filter((r) => r.index < detail.totalImages); + if (this.searchResults.length > 0) { + this.currentSearchIndex = Math.min(this.currentSearchIndex, this.searchResults.length - 1); + this.currentGlobalIndex = this.searchResults[this.currentSearchIndex].index; + return; + } + this.exitSearchMode(); + } + this.currentGlobalIndex = detail.totalImages > 0 ? Math.min(this.currentGlobalIndex, detail.totalImages - 1) : 0; + return; + } + // For deletions, try to preserve position by calculating how many images before current were deleted if (detail.changeType === "deletion" && detail.deletedIndices && !this.isSearchMode) { const deletedIndices = detail.deletedIndices; diff --git a/photomap/frontend/static/javascript/umap-reindex.js b/photomap/frontend/static/javascript/umap-reindex.js index d9bfa8d3..25ca7c84 100644 --- a/photomap/frontend/static/javascript/umap-reindex.js +++ b/photomap/frontend/static/javascript/umap-reindex.js @@ -6,9 +6,10 @@ // Album Manager's status colours), and whose hover title carries the live // status text. Clicking the ring does nothing — cancellation stays in the // Album Manager. On completion an "albumIndexUpdated" event is dispatched -// so the map can reload itself. +// so the map can reload itself, and an albumChanged/"refresh" event so the +// slideshow picks up the new image set without losing its place. -import { updateIndex } from "./index.js"; +import { getIndexMetadata, updateIndex } from "./index.js"; import { state } from "./state.js"; import { fetchJson } from "./utils.js"; @@ -118,6 +119,7 @@ function beginProgress(albumKey, initialProgress = null) { stopPolling(); if (progress.status === "completed") { window.dispatchEvent(new CustomEvent("albumIndexUpdated", { detail: { albumKey } })); + await refreshAlbumImageData(albumKey); } else if (progress.status === "error") { const { btn } = elements(); if (btn) { @@ -134,6 +136,32 @@ function beginProgress(albumKey, initialProgress = null) { }, reindexConfig.pollInterval); } +// The index the swiper and grid are browsing changed underneath them: +// fetch the fresh image count and fire an albumChanged with +// changeType "refresh", which rebuilds the slides while slideState keeps +// the current position and any active search results. +async function refreshAlbumImageData(albumKey) { + if (albumKey !== state.album) { + return; + } + let metadata = null; + try { + metadata = await getIndexMetadata(albumKey); + } catch (error) { + console.error(`Failed to refresh index metadata for album ${albumKey}:`, error); + return; + } + window.dispatchEvent( + new CustomEvent("albumChanged", { + detail: { + album: albumKey, + totalImages: metadata?.filename_count || 0, + changeType: "refresh", + }, + }) + ); +} + export async function startUmapReindex() { const albumKey = state.album; if (!albumKey || pollTimer) { @@ -204,7 +232,11 @@ export function initUmapReindexButton() { startUmapReindex(); }); - window.addEventListener("albumChanged", () => { - checkUmapReindexOngoing(); + window.addEventListener("albumChanged", (e) => { + // "refresh" is dispatched by this module when a run completes — the + // ring is already down and the album hasn't changed, so nothing to do. + if (e.detail?.changeType !== "refresh") { + checkUmapReindexOngoing(); + } }); } diff --git a/photomap/frontend/static/javascript/umap.js b/photomap/frontend/static/javascript/umap.js index dde8ccca..b0d5438d 100644 --- a/photomap/frontend/static/javascript/umap.js +++ b/photomap/frontend/static/javascript/umap.js @@ -2030,15 +2030,27 @@ document.addEventListener("DOMContentLoaded", () => { initializeUmapWindow(); }); -// The titlebar reindex button finished an update of the current album: the -// map on screen is stale, so reload it. +// An index update of the current album finished (titlebar reindex button or +// Album Management): the map data is stale. Reload right away if the window +// is showing; otherwise the dataChanged flag makes toggleUmapWindow's show +// path refetch, without rendering Plotly into a hidden container here. window.addEventListener("albumIndexUpdated", (e) => { if (e.detail?.albumKey === state.album) { state.dataChanged = true; - fetchUmapData(); + const umapWindow = document.getElementById("umapFloatingWindow"); + if (umapWindow?.style.display === "block") { + fetchUmapData(); + } + } +}); +window.addEventListener("albumChanged", (e) => { + // A "refresh" is the same album re-indexed in place: the map reload is + // handled by the albumIndexUpdated listener above, and re-initializing + // here would refetch a second time and force the window fullscreen. + if (e.detail?.changeType !== "refresh") { + initializeUmapWindow(); } }); -window.addEventListener("albumChanged", initializeUmapWindow); // ======================================================== // Toggle Curation Mode (Grey out all points) diff --git a/tests/backend/test_index.py b/tests/backend/test_index.py index 59b81206..cc70daea 100644 --- a/tests/backend/test_index.py +++ b/tests/backend/test_index.py @@ -741,3 +741,35 @@ def test_index_metadata_reflects_last_update_operation(client, new_album): assert meta2["filename_count"] == meta1["filename_count"] assert meta2["last_modified"] > meta1["last_modified"] + + +def test_update_endpoint_registers_progress_before_task_runs( + client, new_album, monkeypatch +): + """The update endpoint must create the progress entry itself: the first + poll otherwise races the background task (board resolution and loading a + big existing .npz happen before the task's own start_operation) and + paints "No operation in progress" — and if the task dies inside that + window, the run looks idle forever.""" + from photomap.backend.progress import progress_tracker + from photomap.backend.routers import index as index_router + + key = new_album["key"] + progress_tracker.remove_progress(key) + + async def frozen_task(album_key, album_config): + return None # never reaches its own start_operation + + monkeypatch.setattr(index_router, "_update_index_background_async", frozen_task) + + try: + response = client.post("/update_index_async", json={"album_key": key}) + assert response.status_code == 202 + + progress = client.get(f"/index_progress/{key}").json() + assert progress["status"] == "scanning" + assert progress["current_step"] == "Preparing index update..." + finally: + # The frozen task never completes; drop the entry so the shared + # tracker can't 409 later tests that reuse this album key. + progress_tracker.remove_progress(key) diff --git a/tests/backend/test_invokeai_board_index.py b/tests/backend/test_invokeai_board_index.py index 1b6a50c3..358fc2dd 100644 --- a/tests/backend/test_invokeai_board_index.py +++ b/tests/backend/test_invokeai_board_index.py @@ -262,3 +262,27 @@ def test_describe_image_source_keeps_logs_compact(): description = describe_image_source(many) assert description == f"3666 explicit paths under {Path('/root/outputs/images')}" assert len(description) < 120 + + +def test_first_update_failure_surfaces_error_without_prior_run( + client, board_album, monkeypatch +): + """A failure before scanning starts (InvokeAI unreachable) must produce a + visible ERROR even when the tracker has no entry from an earlier run — + e.g. the first update after a server restart. set_error used to drop the + message then, leaving the UI stuck on "No operation in progress".""" + from photomap.backend.progress import progress_tracker + + _build_index(client) + progress_tracker.remove_progress(ALBUM_KEY) # simulate a fresh server + + async def broken_fetch(base_url, board_ids, username, password): + raise HTTPException(status_code=502, detail="connection refused") + + monkeypatch.setattr(invokeai_client, "fetch_board_image_names", broken_fetch) + + response = client.post("/update_index_async", json={"album_key": ALBUM_KEY}) + assert response.status_code == 202 + progress = _poll_until(client, ALBUM_KEY, {"completed", "error"}, timeout=10) + assert progress["status"] == "error" + assert "InvokeAI" in progress["error_message"] diff --git a/tests/backend/test_progress.py b/tests/backend/test_progress.py index 8920d554..41e88ec1 100644 --- a/tests/backend/test_progress.py +++ b/tests/backend/test_progress.py @@ -136,3 +136,18 @@ def test_set_completion_warning_none_clears_pending(): tracker.complete_operation("alb") assert tracker.get_progress("alb").warning_message is None + + +def test_set_error_creates_entry_when_none_exists(): + """Failures before a run's first start_operation (e.g. the InvokeAI board + fetch) must surface as a visible ERROR entry, not vanish — a dropped + error leaves the frontend polling "No operation in progress" forever.""" + tracker = ProgressTracker() + + tracker.set_error("alb", "boom") + + progress = tracker.get_progress("alb") + assert progress is not None + assert progress.status is IndexStatus.ERROR + assert progress.error_message == "boom" + assert tracker.is_running("alb") is False diff --git a/tests/frontend/album-manager-progress.test.js b/tests/frontend/album-manager-progress.test.js index 153d2ea6..5078f9e5 100644 --- a/tests/frontend/album-manager-progress.test.js +++ b/tests/frontend/album-manager-progress.test.js @@ -1,13 +1,14 @@ /** * Tests for AlbumManager.updateProgressStatus, focused on the model-download - * phase added so first-install encoder downloads are surfaced on the album card. + * phase added so first-install encoder downloads are surfaced on the album card, + * and for handleIndexingCompletion's in-place refresh events. * * album-manager.js pulls in a large sibling graph (index.js -> umap.js, etc.) * whose modules touch the DOM at import time, so we mock the direct imports and * dynamically load the module under test — the same pattern used by * seek-search-rebuild.test.js. */ -import { beforeAll, describe, expect, jest, test } from "@jest/globals"; +import { afterEach, beforeAll, beforeEach, describe, expect, jest, test } from "@jest/globals"; const M = "../../photomap/frontend/static/javascript"; @@ -37,6 +38,9 @@ jest.unstable_mockModule(`${M}/utils.js`, () => ({ showSpinner: jest.fn(), })); +const { getIndexMetadata } = await import(`${M}/index.js`); +const { state } = await import(`${M}/state.js`); + let AlbumManager; beforeAll(async () => { @@ -162,3 +166,51 @@ describe("AlbumManager persistent index warning note", () => { expect(status.querySelector(".index-status-warning")).toBeNull(); }); }); + +describe("AlbumManager handleIndexingCompletion refresh events", () => { + // Minimal instance: no card element, not in setup mode. + function makeManager() { + return { + showIndexingCompletedUI: jest.fn(), + isSetupMode: false, + autoIndexingAlbums: new Set(["alb"]), + }; + } + + let albumChangedEvents; + let indexUpdatedEvents; + const onAlbumChanged = (e) => albumChangedEvents.push(e.detail); + const onIndexUpdated = (e) => indexUpdatedEvents.push(e.detail); + + beforeEach(() => { + albumChangedEvents = []; + indexUpdatedEvents = []; + getIndexMetadata.mockReset(); + getIndexMetadata.mockResolvedValue({ filename_count: 7 }); + window.addEventListener("albumChanged", onAlbumChanged); + window.addEventListener("albumIndexUpdated", onIndexUpdated); + }); + + afterEach(() => { + window.removeEventListener("albumChanged", onAlbumChanged); + window.removeEventListener("albumIndexUpdated", onIndexUpdated); + }); + + test("current album: fires albumIndexUpdated plus an in-place albumChanged refresh", async () => { + state.album = "alb"; + + await AlbumManager.prototype.handleIndexingCompletion.call(makeManager(), "alb", null); + + expect(indexUpdatedEvents).toEqual([{ albumKey: "alb" }]); + expect(albumChangedEvents).toEqual([{ album: "alb", totalImages: 7, changeType: "refresh" }]); + }); + + test("other album: no refresh events for the one being browsed", async () => { + state.album = "alb"; + + await AlbumManager.prototype.handleIndexingCompletion.call(makeManager(), "other", null); + + expect(indexUpdatedEvents).toEqual([]); + expect(albumChangedEvents).toEqual([]); + }); +}); diff --git a/tests/frontend/slide-state-refresh.test.js b/tests/frontend/slide-state-refresh.test.js new file mode 100644 index 00000000..b33a741c --- /dev/null +++ b/tests/frontend/slide-state-refresh.test.js @@ -0,0 +1,111 @@ +/** + * Tests for slideState's handling of albumChanged with changeType "refresh" + * (dispatched after an in-place index update, e.g. the semantic map's + * reindex button): position and search results must survive the refresh, + * clamped against the new image count. + */ +import { beforeEach, describe, expect, jest, test } from "@jest/globals"; + +const M = "../../photomap/frontend/static/javascript"; + +jest.unstable_mockModule(`${M}/state.js`, () => ({ + state: { album: "alb" }, +})); + +const { slideState } = await import(`${M}/slide-state.js`); + +function dispatchAlbumChanged(detail) { + window.dispatchEvent(new CustomEvent("albumChanged", { detail })); +} + +beforeEach(() => { + slideState.exitSearchMode(); + slideState.currentGlobalIndex = 0; + slideState.currentSearchIndex = 0; + slideState.totalAlbumImages = 0; +}); + +describe("albumChanged changeType refresh", () => { + test("keeps the current position and updates the total", () => { + slideState.totalAlbumImages = 100; + slideState.currentGlobalIndex = 57; + + dispatchAlbumChanged({ album: "alb", totalImages: 120, changeType: "refresh" }); + + expect(slideState.totalAlbumImages).toBe(120); + expect(slideState.currentGlobalIndex).toBe(57); + expect(slideState.isSearchMode).toBe(false); + }); + + test("clamps the position when the album shrank below it", () => { + slideState.totalAlbumImages = 100; + slideState.currentGlobalIndex = 95; + + dispatchAlbumChanged({ album: "alb", totalImages: 60, changeType: "refresh" }); + + expect(slideState.currentGlobalIndex).toBe(59); + + dispatchAlbumChanged({ album: "alb", totalImages: 0, changeType: "refresh" }); + expect(slideState.currentGlobalIndex).toBe(0); + }); + + test("preserves search results and position within them", () => { + slideState.totalAlbumImages = 100; + slideState.enterSearchMode( + [ + { index: 3, score: 0.9 }, + { index: 40, score: 0.8 }, + { index: 77, score: 0.7 }, + ], + 1 + ); + + dispatchAlbumChanged({ album: "alb", totalImages: 130, changeType: "refresh" }); + + expect(slideState.isSearchMode).toBe(true); + expect(slideState.searchResults).toHaveLength(3); + expect(slideState.currentSearchIndex).toBe(1); + expect(slideState.currentGlobalIndex).toBe(40); + }); + + test("drops search results that point past the new end", () => { + slideState.totalAlbumImages = 100; + slideState.enterSearchMode( + [ + { index: 3, score: 0.9 }, + { index: 40, score: 0.8 }, + { index: 77, score: 0.7 }, + ], + 2 + ); + + dispatchAlbumChanged({ album: "alb", totalImages: 50, changeType: "refresh" }); + + expect(slideState.isSearchMode).toBe(true); + expect(slideState.searchResults.map((r) => r.index)).toEqual([3, 40]); + expect(slideState.currentSearchIndex).toBe(1); + expect(slideState.currentGlobalIndex).toBe(40); + }); + + test("exits search mode when no result survives the refresh", () => { + slideState.totalAlbumImages = 100; + slideState.currentGlobalIndex = 80; + slideState.enterSearchMode([{ index: 80, score: 0.9 }], 0); + + dispatchAlbumChanged({ album: "alb", totalImages: 20, changeType: "refresh" }); + + expect(slideState.isSearchMode).toBe(false); + expect(slideState.searchResults).toEqual([]); + expect(slideState.currentGlobalIndex).toBe(19); + }); + + test("a plain albumChanged (album switch) still resets to the beginning", () => { + slideState.totalAlbumImages = 100; + slideState.currentGlobalIndex = 57; + + dispatchAlbumChanged({ album: "other", totalImages: 30 }); + + expect(slideState.currentGlobalIndex).toBe(0); + expect(slideState.totalAlbumImages).toBe(30); + }); +}); diff --git a/tests/frontend/umap-reindex.test.js b/tests/frontend/umap-reindex.test.js index 7e395cac..579631aa 100644 --- a/tests/frontend/umap-reindex.test.js +++ b/tests/frontend/umap-reindex.test.js @@ -10,6 +10,7 @@ const M = "../../photomap/frontend/static/javascript"; jest.unstable_mockModule(`${M}/index.js`, () => ({ updateIndex: jest.fn(), + getIndexMetadata: jest.fn(), })); jest.unstable_mockModule(`${M}/state.js`, () => ({ state: { album: "alb" }, @@ -18,7 +19,7 @@ jest.unstable_mockModule(`${M}/utils.js`, () => ({ fetchJson: jest.fn(), })); -const { updateIndex } = await import(`${M}/index.js`); +const { updateIndex, getIndexMetadata } = await import(`${M}/index.js`); const { state } = await import(`${M}/state.js`); const { fetchJson } = await import(`${M}/utils.js`); const { reindexConfig, startUmapReindex, checkUmapReindexOngoing } = await import(`${M}/umap-reindex.js`); @@ -49,6 +50,8 @@ beforeEach(async () => { buildDom(); updateIndex.mockReset(); fetchJson.mockReset(); + getIndexMetadata.mockReset(); + getIndexMetadata.mockResolvedValue({ filename_count: 100 }); state.album = "alb"; // Drain any poller left over from a previous test. fetchJson.mockResolvedValue({ status: "completed" }); @@ -64,9 +67,13 @@ describe("startUmapReindex", () => { ]; fetchJson.mockImplementation(() => Promise.resolve(statuses.shift() ?? { status: "completed" })); updateIndex.mockResolvedValue({ success: true }); + getIndexMetadata.mockResolvedValue({ filename_count: 42 }); const events = []; + const albumChangedEvents = []; const onUpdated = (e) => events.push(e.detail.albumKey); + const onAlbumChanged = (e) => albumChangedEvents.push(e.detail); window.addEventListener("albumIndexUpdated", onUpdated); + window.addEventListener("albumChanged", onAlbumChanged); try { await startUmapReindex(); expect(updateIndex).toHaveBeenCalledWith("alb"); @@ -76,8 +83,14 @@ describe("startUmapReindex", () => { await waitForButtonRestore(); expect(events).toEqual(["alb"]); expect(document.getElementById("umapReindexProgress").style.display).toBe("none"); + + // The slideshow/grid refresh: an in-place albumChanged carrying the + // fresh image count, so slides rebuild without losing position. + await flush(); + expect(albumChangedEvents).toEqual([{ album: "alb", totalImages: 42, changeType: "refresh" }]); } finally { window.removeEventListener("albumIndexUpdated", onUpdated); + window.removeEventListener("albumChanged", onAlbumChanged); } });