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); } });