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
8 changes: 6 additions & 2 deletions photomap/frontend/static/javascript/seek-slider.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,18 @@ class SeekSlider {
const isBookmarked = globalIndex !== undefined ? bookmarkManager.isBookmarked(globalIndex) : false;
this.scoreDisplayObj.setBookmarkStatus(globalIndex, isBookmarked);

// showCluster/showSearchScore display index+1, so pass the 0-based
// targetIndex (matching metadata-drawer's release-time path). Passing
// targetIndex+1 here double-incremented the badge during the drag, so the
// live position read one too high until the thumb was released.
if (state.searchResults[targetIndex]?.cluster !== undefined) {
const cluster = state.searchResults[targetIndex]?.cluster;
const color = state.searchResults[targetIndex]?.color;
this.scoreDisplayObj.showCluster(cluster, color, targetIndex + 1, state.searchResults.length);
this.scoreDisplayObj.showCluster(cluster, color, targetIndex, state.searchResults.length);
} else {
this.scoreDisplayObj.showSearchScore(
state.searchResults[targetIndex]?.score,
targetIndex + 1,
targetIndex,
state.searchResults.length
);
}
Expand Down
46 changes: 30 additions & 16 deletions photomap/frontend/static/javascript/swiper.js
Original file line number Diff line number Diff line change
Expand Up @@ -743,26 +743,40 @@ class SwiperManager {
try {
this.swiper.removeAllSlides();

let origin = -2;
const slides_to_add = 5;
if (globalIndex + origin < 0) {
origin = 0;
}

const swiperContainer = document.getElementById("singleSwiper");
swiperContainer.style.visibility = "hidden";

for (let i = origin; i < slides_to_add; i++) {
if (searchIndex + i >= totalCount) {
break;
}
if (globalIndex + i < 0) {
continue;
}
if (globalIndex + i >= slideState.totalAlbumImages) {
break;
// Load a small window of slides centred on the target so the user can
// immediately swipe a couple of slides in either direction. In
// search/cluster mode the neighbours are the adjacent *search results*,
// which are NOT contiguous in global-album index, so each neighbour's
// global index must be resolved through searchToGlobal. Stepping
// globalIndex and searchIndex together (the old behaviour) loaded
// album-adjacent images and tagged the prepended slides with bogus
// search indices (including negatives), which corrupted the position
// badge — seeking back to cluster image #1 could show "3", and swiping
// left showed "0" then "-1".
const SLIDES_BEFORE = 2;
const SLIDES_AFTER = 2;

for (let i = -SLIDES_BEFORE; i <= SLIDES_AFTER; i++) {
if (isSearchMode) {
const neighborSearch = searchIndex + i;
if (neighborSearch < 0 || neighborSearch >= totalCount) {
continue;
}
const neighborGlobal = slideState.searchToGlobal(neighborSearch);
if (neighborGlobal === null) {
continue;
}
await this.addSlideByIndex(neighborGlobal, neighborSearch, false, false);
} else {
const neighborGlobal = globalIndex + i;
if (neighborGlobal < 0 || neighborGlobal >= slideState.totalAlbumImages) {
continue;
}
await this.addSlideByIndex(neighborGlobal, null, false, false);
}
await this.addSlideByIndex(globalIndex + i, searchIndex + i, false, false);
}

slideEls = this.swiper.slides;
Expand Down
201 changes: 201 additions & 0 deletions tests/frontend/seek-search-rebuild.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Regression tests for seekToSlideIndex rebuilding the buffer in search/cluster
// mode. Search results are NOT contiguous in global-album index, so the rebuild
// must resolve each neighbour through searchToGlobal rather than stepping
// globalIndex and searchIndex together. The old code did the latter, which:
// - loaded album-adjacent images instead of the adjacent cluster images, and
// - tagged the slides it prepended with bogus search indices (-1, -2),
// so seeking back to cluster image #1 could land on a mistagged slide (the
// position badge showed "3"), and swiping left showed "0" then "-1".
import { beforeEach, describe, expect, it, jest } from "@jest/globals";

jest.unstable_mockModule("../../photomap/frontend/static/javascript/album-manager.js", () => ({
albumManager: {
fetchAvailableAlbums: jest.fn(() => Promise.resolve([])),
setSwiperManager: jest.fn(),
},
checkAlbumIndex: jest.fn(),
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/index.js", () => ({
getIndexMetadata: jest.fn(() => Promise.resolve({ filename_count: 0 })),
deleteImage: jest.fn(() => Promise.resolve()),
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/control-panel.js", () => ({
initializeControlPanel: jest.fn(),
toggleFullscreen: jest.fn(),
showDeleteConfirmModal: jest.fn(() => Promise.resolve(true)),
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/bookmarks.js", () => ({
addBookmarkIconToSlide: jest.fn(),
toggleCurrentBookmark: jest.fn(),
updateAllBookmarkIcons: jest.fn(),
bookmarkManager: {
loadBookmarks: jest.fn(),
updateBookmarkButton: jest.fn(),
},
}));

const mockState = {
single_swiper: null,
mode: "chronological",
currentDelay: 5,
highWaterMark: 50,
swiper: null,
};

jest.unstable_mockModule("../../photomap/frontend/static/javascript/state.js", () => ({
state: mockState,
saveSettingsToLocalStorage: jest.fn(),
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/slideshow.js", () => ({
slideShowRunning: jest.fn(() => false),
updateSlideshowButtonIcon: jest.fn(),
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/umap.js", () => ({
updateCurrentImageMarker: jest.fn(),
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/metadata-drawer.js", () => ({
updateMetadataOverlay: jest.fn(),
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/events.js", () => ({
toggleGridSwiperView: jest.fn(),
}));

const mockFetchImageByIndex = jest.fn();
jest.unstable_mockModule("../../photomap/frontend/static/javascript/search.js", () => ({
fetchImageByIndex: mockFetchImageByIndex,
}));

// A non-contiguous cluster: search index N maps to a scattered global index.
const CLUSTER = [{ index: 100 }, { index: 250 }, { index: 370 }, { index: 420 }, { index: 555 }];

const mockSlideState = {
currentGlobalIndex: 100,
currentSearchIndex: 0,
isSearchMode: true,
totalAlbumImages: 1000,
searchResults: CLUSTER,
updateFromExternal: jest.fn(),
searchToGlobal: jest.fn((idx) => mockSlideState.searchResults[idx]?.index ?? null),
getCurrentSlide: jest.fn(() => ({
globalIndex: mockSlideState.currentGlobalIndex,
searchIndex: mockSlideState.currentSearchIndex,
totalCount: mockSlideState.searchResults.length,
isSearchMode: true,
})),
getCurrentIndex: jest.fn(() => mockSlideState.currentSearchIndex),
resolveOffset: jest.fn(() => ({ globalIndex: null, searchIndex: null })),
};

jest.unstable_mockModule("../../photomap/frontend/static/javascript/slide-state.js", () => ({
slideState: mockSlideState,
getCurrentSlideIndex: jest.fn(() => [
mockSlideState.currentGlobalIndex,
mockSlideState.searchResults.length,
mockSlideState.currentSearchIndex,
]),
}));

describe("seekToSlideIndex rebuild in search/cluster mode", () => {
let mockSwiper;

beforeEach(async () => {
jest.clearAllMocks();

mockSlideState.searchResults = CLUSTER;
mockSlideState.isSearchMode = true;
mockSlideState.totalAlbumImages = 1000;
mockSlideState.searchToGlobal = jest.fn((idx) => mockSlideState.searchResults[idx]?.index ?? null);

mockSwiper = {
slides: [],
activeIndex: 0,
autoplay: { running: false, stop: jest.fn(), start: jest.fn() },
allowSlideNext: true,
allowSlidePrev: true,
appendSlide: jest.fn((slide) => mockSwiper.slides.push(slide)),
prependSlide: jest.fn((slide) => mockSwiper.slides.unshift(slide)),
removeAllSlides: jest.fn(() => {
mockSwiper.slides = [];
}),
slideTo: jest.fn((idx) => {
mockSwiper.activeIndex = idx;
}),
on: jest.fn(),
};
global.Swiper = jest.fn(() => mockSwiper);

// fetchImageByIndex echoes the requested global index back as data.index,
// so the slide's dataset.globalIndex reflects the real image fetched.
mockFetchImageByIndex.mockImplementation((index) =>
Promise.resolve({
index,
filename: `image${index}.jpg`,
image_url: `/images/${index}.jpg`,
filepath: `/path/to/image${index}.jpg`,
total: 1000,
})
);

document.body.innerHTML = `
<div id="singleSwiperContainer">
<div id="singleSwiper" class="swiper"><div class="swiper-wrapper"></div></div>
</div>
`;
});

it("loads the adjacent cluster images and never tags slides with out-of-range search indices", async () => {
const { initializeSingleSwiper } = await import("../../photomap/frontend/static/javascript/swiper.js");
const manager = await initializeSingleSwiper();

// The buffer holds the tail of the cluster (we ran the slideshow to the end);
// cluster image #1 (global 100) is NOT loaded, forcing the rebuild path.
mockSwiper.slides = [makeSlide(420, 3), makeSlide(555, 4)];

// Seek back to cluster image #1 (search index 0, global 100).
await manager.seekToSlideIndex({
detail: { globalIndex: 100, searchIndex: 0, isSearchMode: true, totalCount: CLUSTER.length },
});

// Every slide must carry a valid search index and a global index that
// actually corresponds to that search position in the cluster.
for (const slide of mockSwiper.slides) {
const searchIndex = parseInt(slide.dataset.searchIndex, 10);
const globalIndex = parseInt(slide.dataset.globalIndex, 10);
expect(searchIndex).toBeGreaterThanOrEqual(0);
expect(searchIndex).toBeLessThan(CLUSTER.length);
expect(globalIndex).toBe(CLUSTER[searchIndex].index);
}
});

it("lands on cluster image #1 with the correct position badge after seeking", async () => {
const { initializeSingleSwiper } = await import("../../photomap/frontend/static/javascript/swiper.js");
const manager = await initializeSingleSwiper();

mockSwiper.slides = [makeSlide(420, 3), makeSlide(555, 4)];

await manager.seekToSlideIndex({
detail: { globalIndex: 100, searchIndex: 0, isSearchMode: true, totalCount: CLUSTER.length },
});

const landed = mockSwiper.slides[mockSwiper.activeIndex];
expect(parseInt(landed.dataset.globalIndex, 10)).toBe(100);
// searchIndex 0 -> badge shows "1/5", not "3".
expect(parseInt(landed.dataset.searchIndex, 10)).toBe(0);
});
});

function makeSlide(globalIndex, searchIndex) {
const slide = document.createElement("div");
slide.className = "swiper-slide";
slide.dataset.globalIndex = globalIndex;
slide.dataset.searchIndex = searchIndex;
slide.innerHTML = `<img src="/images/${globalIndex}.jpg" alt="image${globalIndex}" />`;
return slide;
}
98 changes: 98 additions & 0 deletions tests/frontend/seek-slider-live-index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Regression test for the live position badge while dragging the seek slider.
// showCluster/showSearchScore render `index + 1`, so onSliderInput must pass the
// 0-based slider index. It previously passed targetIndex + 1, double-incrementing
// the badge so the live drag read one too high until the thumb was released.
import { beforeEach, describe, expect, it, jest } from "@jest/globals";

const mockScoreDisplay = {
showCluster: jest.fn(),
showSearchScore: jest.fn(),
showIndex: jest.fn(),
setBookmarkStatus: jest.fn(),
};

jest.unstable_mockModule("../../photomap/frontend/static/javascript/score-display.js", () => ({
scoreDisplay: mockScoreDisplay,
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/back-stack.js", () => ({
backStack: { markNextAsJump: jest.fn() },
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/bookmarks.js", () => ({
bookmarkManager: { isBookmarked: jest.fn(() => false) },
}));

const mockSlideState = {
getCurrentIndex: jest.fn(() => 0),
navigateToIndex: jest.fn(),
isSearchMode: true,
};

jest.unstable_mockModule("../../photomap/frontend/static/javascript/slide-state.js", () => ({
slideState: mockSlideState,
getCurrentSlideIndex: jest.fn(() => [0, 5, 0]),
}));

const mockState = { searchResults: [], searchType: "cluster", album: "test" };
jest.unstable_mockModule("../../photomap/frontend/static/javascript/state.js", () => ({
state: mockState,
}));

jest.unstable_mockModule("../../photomap/frontend/static/javascript/utils.js", () => ({
debounce: (fn) => fn,
}));

describe("seek-slider live drag index", () => {
let seekSlider;

beforeEach(async () => {
jest.clearAllMocks();
document.body.innerHTML = `
<div id="sliderWithTicksContainer"></div>
<input type="range" id="slideSeekSlider" min="1" max="5" />
<div id="sliderInfoPanel"></div>
`;
mockState.searchResults = [];
mockState.searchType = "cluster";

({ seekSlider } = await import("../../photomap/frontend/static/javascript/seek-slider.js"));
seekSlider.slider = document.getElementById("slideSeekSlider");
seekSlider.infoPanel = document.getElementById("sliderInfoPanel");
seekSlider.sliderContainer = document.getElementById("sliderWithTicksContainer");
seekSlider.scoreDisplayObj = mockScoreDisplay;
// Avoid scheduling real timers during the test.
seekSlider.resetFadeOutTimer = jest.fn();
seekSlider.updateHoverStripProgress = jest.fn();
});

it("shows the 0-based index for a cluster result while dragging (no +1 inflation)", async () => {
// A 5-image cluster; thumb on slot 3 (slider value 3 -> targetIndex 2).
mockState.searchResults = [
{ index: 100, cluster: 2, color: "#abc" },
{ index: 250, cluster: 2, color: "#abc" },
{ index: 370, cluster: 2, color: "#abc" },
{ index: 420, cluster: 2, color: "#abc" },
{ index: 555, cluster: 2, color: "#abc" },
];
seekSlider.slider.value = 3;

await seekSlider.onSliderInput({});

expect(mockScoreDisplay.showCluster).toHaveBeenCalledWith(2, "#abc", 2, 5);
});

it("shows the 0-based index for a scored search result while dragging", async () => {
mockState.searchType = "text";
mockState.searchResults = [
{ index: 100, score: 0.9 },
{ index: 250, score: 0.8 },
{ index: 370, score: 0.7 },
];
seekSlider.slider.value = 2; // targetIndex 1

await seekSlider.onSliderInput({});

expect(mockScoreDisplay.showSearchScore).toHaveBeenCalledWith(0.8, 1, 3);
});
});
Loading