diff --git a/docs/user-guide/semantic-map.md b/docs/user-guide/semantic-map.md
index 831742ad..42c4c8e6 100644
--- a/docs/user-guide/semantic-map.md
+++ b/docs/user-guide/semantic-map.md
@@ -88,13 +88,15 @@ With "Show hover thumbnails" turned on and "Show landmarks" turned off:
## Showing images, videos, or both
-If the album contains videos as well as photos, the **Show** radio buttons choose
-which of them to show: **Both**, **Images only**, or **Videos only**.
+If the album contains videos as well as photos, the **Show** icon in the Search
+panel chooses which of them to show: **Images**, **Videos**, or **Both**. The icon
+changes to a camera, a clapper, or the two together to indicate the current choice.
+It is greyed out for an album that holds only one kind.
Videos are placed by the same semantic embedding as photos — computed from a
still captured shortly after the video starts — so a clip lands next to the
pictures it looks like.
-The choice applies everywhere, not just to the map. With **Videos only**
+The choice applies everywhere, not just to the map. With **Videos**
selected, the slideshow and the grid view step through the album's videos
alone, shuffle mode draws only from them, the seek slider covers just them,
and text and image searches return videos only (up to the album's maximum
diff --git a/photomap/frontend/static/css/control-and-search-panels.css b/photomap/frontend/static/css/control-and-search-panels.css
index 76030b8a..ead22a3e 100644
--- a/photomap/frontend/static/css/control-and-search-panels.css
+++ b/photomap/frontend/static/css/control-and-search-panels.css
@@ -147,6 +147,48 @@
border-color: #fff;
}
+/* ===== "Show" menu (images / videos / both) ===== */
+
+#showMediaMenu {
+ position: fixed;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ background: rgba(30, 30, 30, 0.95);
+ border: 1px solid #444;
+ padding: 4px;
+ border-radius: 6px;
+ z-index: 10000;
+}
+
+.show-media-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 12px 4px 6px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ font-size: 0.95em;
+ cursor: pointer;
+}
+
+.show-media-item svg {
+ width: 24px;
+ height: 24px;
+ display: block;
+}
+
+.show-media-item:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.show-media-item[aria-current="true"] {
+ background: rgba(255, 255, 255, 0.22);
+ font-weight: bold;
+}
+
/* Drag-over highlight for search panel */
#searchPanel.drag-over {
border: 2px dashed #00ff99;
@@ -221,6 +263,9 @@
#controlPanel {
position: static !important;
width: 100vw;
+ /* Without this the side padding is added to 100vw, pushing the centred
+ icon row off the right-hand edge. */
+ box-sizing: border-box;
margin: 0;
border-radius: 0;
box-shadow: none;
@@ -266,12 +311,23 @@
}
}
-/* The Search panel carries six icons since Clear became always-visible and
- About moved across. At the phone layout's 48px that needs 328px of row,
- which a 320px viewport cannot give — and html/body are overflow:hidden, so
- the excess is clipped off both ends rather than scrolled. Step the buttons
- back down on the narrowest screens: 6x40 + 5x4 = 260px fits with room over. */
-@media (max-width: 380px) {
+/* The two panels share the bottom edge until they stack at 600px, and with
+ seven icons in the Search panel they would run into each other on a tablet
+ in portrait (768-820px). The room comes out of the gaps between icons. Below
+ about 745px they still overlap, by less than they would otherwise. */
+@media (min-width: 601px) and (max-width: 860px) {
+ .control-icons-row,
+ .search-icons-row {
+ gap: 1px;
+ }
+}
+
+/* Labels make some icons wider than their buttons. At the phone layout's 48px
+ the Search panel's seven icons need 399px of row and the Control panel 426px,
+ plus padding, which most phones cannot give — and html/body are
+ overflow:hidden, so the excess is clipped off both ends rather than scrolled.
+ Step the buttons back down on the narrower screens. */
+@media (max-width: 429px) {
#controlPanel button,
#searchPanel button {
width: 40px;
@@ -291,3 +347,13 @@
gap: 0.25em;
}
}
+
+/* Under about 360px even the 40px rows need more than the width on offer. This
+ fits the Search row down to 320px; at 320px the Control row's outer labels
+ still lose about 6px a side. */
+@media (max-width: 357px) {
+ .control-icons-row,
+ .search-icons-row {
+ gap: 0;
+ }
+}
diff --git a/photomap/frontend/static/css/search.css b/photomap/frontend/static/css/search.css
index 6a2906eb..6d119acb 100644
--- a/photomap/frontend/static/css/search.css
+++ b/photomap/frontend/static/css/search.css
@@ -208,12 +208,15 @@
nothing to clear. updateSearchCheckmarks() owns the disabled attribute.
Both rules outrank the #searchPanel button / :hover opacities. */
#clearSearchBtn:disabled,
-#clearSearchBtn:disabled:hover {
+#clearSearchBtn:disabled:hover,
+#showMediaBtn:disabled,
+#showMediaBtn:disabled:hover {
opacity: 0.25;
cursor: default;
}
-#clearSearchBtn:disabled + .button-label {
+#clearSearchBtn:disabled + .button-label,
+#showMediaBtn:disabled + .button-label {
opacity: 0.25;
}
diff --git a/photomap/frontend/static/css/umap-floating-window.css b/photomap/frontend/static/css/umap-floating-window.css
index cfb8071b..c2b3a695 100644
--- a/photomap/frontend/static/css/umap-floating-window.css
+++ b/photomap/frontend/static/css/umap-floating-window.css
@@ -264,13 +264,11 @@
outline: none;
}
-#umapClickBehaviorContainer,
-#umapMediaFilterContainer {
+#umapClickBehaviorContainer {
font-size: 0.85em;
}
-#umapClickBehaviorContainer label,
-#umapMediaFilterContainer label {
+#umapClickBehaviorContainer label {
cursor: pointer;
user-select: none;
}
diff --git a/photomap/frontend/static/javascript/media-filter.js b/photomap/frontend/static/javascript/media-filter.js
index 6dbc7eeb..f904701d 100644
--- a/photomap/frontend/static/javascript/media-filter.js
+++ b/photomap/frontend/static/javascript/media-filter.js
@@ -12,7 +12,8 @@
// 1. which album indices are videos, fetched from /media_indices on every
// album change (a deletion is renumbered locally instead — slideState
// does the same to the live list, and a refetch would force a rebuild of
-// the very slides the deletion path is careful to keep in place);
+// the very slides the deletion path is careful to keep in place). Each
+// update fires `mediaIndicesUpdated`, whether or not the filter changed;
// 2. the derived views the rest of the app needs — a predicate over global
// indices, a filter for result lists, and the "browse list": every index
// the filter keeps, which search.js hands to the slide machinery as the
@@ -28,6 +29,8 @@ import { fetchJson } from "./utils.js";
let videoIndices = new Set();
let totalIndices = 0;
let loadedAlbum = null;
+// The last /media_indices fetch failed, so the album's contents are unknown.
+let loadFailed = false;
let loadSequence = 0;
// The album whose /media_indices fetch is in flight, or null. A filter change
// during the fetch must not announce from the previous album's set; the load
@@ -44,18 +47,32 @@ let announcedFilter = "both";
// nothing. Either way there is nothing to filter, and a browse list would be
// a slower spelling of "the whole album" — or a blank screen. So the stored
// preference only takes effect on an album that has both kinds, which is also
-// what the map's radio buttons require before they enable themselves.
+// when the Show menu enables itself.
export function effectiveMediaFilter() {
const filter = state.mediaFilter;
if (filter !== "images" && filter !== "videos") {
return "both";
}
- if (videoIndices.size === 0 || videoIndices.size >= totalIndices) {
+ if (albumMediaMix() !== "mixed") {
return "both";
}
return filter;
}
+// What the loaded album holds: "mixed" is the only case with anything to filter
+// here. "unknown" means the fetch failed; browsing fails open then, but the map
+// classifies its own points and may still be filtered. An album not loaded yet
+// counts as "images".
+export function albumMediaMix() {
+ if (loadFailed) {
+ return "unknown";
+ }
+ if (videoIndices.size === 0) {
+ return "images";
+ }
+ return videoIndices.size >= totalIndices ? "videos" : "mixed";
+}
+
// Whether the active filter shows the album entry at `index`.
export function isIndexVisible(index) {
const filter = effectiveMediaFilter();
@@ -119,9 +136,11 @@ export async function loadMediaIndices(album, reason = "album") {
const sequence = ++loadSequence;
loadingAlbum = album;
let data = { total: 0, video_indices: [] };
+ let failed = false;
try {
data = await fetchJson(`media_indices/${encodeURIComponent(album)}`);
} catch (err) {
+ failed = true;
// No index yet, or the server is away: fail open. An album with no known
// videos is one the filter leaves alone.
console.warn("Failed to load media indices; media filter is inactive:", err);
@@ -130,10 +149,12 @@ export async function loadMediaIndices(album, reason = "album") {
return;
}
loadingAlbum = null;
+ loadFailed = failed;
videoIndices = new Set(data?.video_indices || []);
totalIndices = data?.total || 0;
loadedAlbum = album;
announce(reason);
+ window.dispatchEvent(new CustomEvent("mediaIndicesUpdated"));
}
// Renumber the video set after `deletedIndices` were removed from the album,
@@ -161,6 +182,7 @@ function applyDeletion(deletedIndices, totalImages) {
export function _setMediaIndicesForTest({ album = null, total = 0, videoIndices: videos = [] } = {}) {
loadedAlbum = album;
loadingAlbum = null;
+ loadFailed = false;
totalIndices = total;
videoIndices = new Set(videos);
announcedFilter = "both";
@@ -173,6 +195,7 @@ window.addEventListener("albumChanged", (e) => {
// The live list has already been renumbered in place by slideState;
// keep our set in step and stay quiet so nothing is rebuilt.
applyDeletion(detail.deletedIndices, detail.totalImages);
+ window.dispatchEvent(new CustomEvent("mediaIndicesUpdated"));
return;
}
if (detail.changeType !== "refresh") {
@@ -186,7 +209,7 @@ window.addEventListener("albumChanged", (e) => {
});
// Fired by the mediaFilter setting's onSet hook in state.js — which runs for
-// the user's radio click, and also when a server-side preference record is
+// the user's choice in the Show menu, and also when a server-side preference record is
// applied at boot, where no settingsUpdated event is dispatched.
window.addEventListener("mediaFilterSettingChanged", () => {
if (loadingAlbum !== null) {
diff --git a/photomap/frontend/static/javascript/show-menu.js b/photomap/frontend/static/javascript/show-menu.js
new file mode 100644
index 00000000..c19af795
--- /dev/null
+++ b/photomap/frontend/static/javascript/show-menu.js
@@ -0,0 +1,188 @@
+// show-menu.js
+// The Search panel's "Show" control: a quick way to choose between images,
+// videos and both without opening the semantic map.
+//
+// It holds no filter logic of its own. A choice goes through setMediaFilter,
+// and media-filter.js and umap.js react to the event that setter dispatches.
+
+import { albumMediaMix, effectiveMediaFilter } from "./media-filter.js";
+import { setMediaFilter, state } from "./state.js";
+
+const MENU_ID = "showMediaMenu";
+
+// Camera and clapper rather than the picture frame: the frame already means
+// "search by image" a few icons along, and these two read as a pair.
+const CAMERA_PATHS = `
+
+ `;
+const CLAPPER_PATHS = `
+
+
+
+ `;
+// "Both" is literally the other two icons. Scaling them down thins the lines
+// with them, so the stroke is raised to stay near the weight of the icons
+// beside it.
+const BOTH_PATHS = `
+ ${CAMERA_PATHS}
+ ${CLAPPER_PATHS}`;
+
+const OPTIONS = [
+ { value: "images", label: "Images", title: "Images only", paths: CAMERA_PATHS },
+ { value: "videos", label: "Videos", title: "Videos only", paths: CLAPPER_PATHS },
+ { value: "both", label: "Both", title: "Images and videos", paths: BOTH_PATHS },
+];
+
+function iconSvg(paths) {
+ return ``;
+}
+
+function activeOption() {
+ // With the album's contents unknown only the map can still be filtered, and
+ // what it follows is the stored value.
+ const filter = albumMediaMix() === "unknown" ? state.mediaFilter : effectiveMediaFilter();
+ return OPTIONS.find((option) => option.value === filter) || OPTIONS[OPTIONS.length - 1];
+}
+
+function tooltipText(mix, option) {
+ switch (mix) {
+ case "videos":
+ return "This album contains only videos";
+ case "images":
+ return "This album contains no videos";
+ case "unknown":
+ return `Show: ${option.title} (map only: could not read this album's media list)`;
+ default:
+ return `Show: ${option.title}`;
+ }
+}
+
+// Make the button read what is actually in force. That is the effective filter
+// rather than the stored one: a stored "videos" on an all-photo album filters
+// nothing, and the album-specific reason is what the disabled tooltip explains.
+//
+// Stays enabled when the media list could not be read: the map may be drawing
+// a filter that this is the only way to change.
+function syncButton() {
+ const button = document.getElementById("showMediaBtn");
+ const icon = document.getElementById("showMediaIcon");
+ if (!button || !icon) {
+ return;
+ }
+ const option = activeOption();
+ const mix = albumMediaMix();
+ icon.innerHTML = iconSvg(option.paths);
+ button.disabled = mix === "images" || mix === "videos";
+ button.setAttribute("aria-label", `Show: ${option.title}`);
+ // On the container, because a disabled button shows no tooltip of its own in
+ // every browser, and the disabled case is the one that needs explaining.
+ button.parentElement.title = tooltipText(mix, option);
+}
+
+function closeMenu() {
+ const menu = document.getElementById(MENU_ID);
+ if (menu) {
+ menu._cleanup();
+ menu.remove();
+ }
+ document.getElementById("showMediaBtn")?.setAttribute("aria-expanded", "false");
+}
+
+function openMenu() {
+ closeMenu();
+ const button = document.getElementById("showMediaBtn");
+ const menu = document.createElement("div");
+ menu.id = MENU_ID;
+ const active = activeOption();
+ for (const option of OPTIONS) {
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "show-media-item";
+ item.dataset.value = option.value;
+ item.innerHTML = `${iconSvg(option.paths)}${option.label}`;
+ if (option === active) {
+ item.setAttribute("aria-current", "true");
+ }
+ item.addEventListener("click", () => {
+ closeMenu();
+ setMediaFilter(option.value);
+ });
+ menu.appendChild(item);
+ }
+ document.body.appendChild(menu);
+
+ // In line with the button but above the whole panel, which is pinned to the
+ // bottom of the window, so that the menu does not cover the panel's title.
+ const left = Math.min(button.getBoundingClientRect().left, window.innerWidth - menu.offsetWidth - 6);
+ const panelTop = button.closest("#searchPanel")?.getBoundingClientRect().top ?? button.getBoundingClientRect().top;
+ menu.style.left = `${Math.max(6, left)}px`;
+ menu.style.top = `${Math.max(6, panelTop - menu.offsetHeight - 6)}px`;
+
+ // Capture phase: the bookmarks button stops its click from bubbling, so a
+ // listener in the bubble phase would leave this menu open behind that one.
+ // pointerdown as well, because the map's draggable titlebar cancels the touch
+ // and no click follows. The button is left to its own handler, which toggles
+ // the menu shut.
+ const onOutside = (ev) => {
+ if (!menu.contains(ev.target) && !button.contains(ev.target)) {
+ closeMenu();
+ }
+ };
+ // Capture and stop, so that dismissing the menu is all Escape does.
+ const onKey = (ev) => {
+ if (ev.key === "Escape") {
+ ev.stopPropagation();
+ closeMenu();
+ }
+ };
+ document.addEventListener("click", onOutside, true);
+ document.addEventListener("pointerdown", onOutside, true);
+ window.addEventListener("keydown", onKey, true);
+ // The menu is placed once, against a panel these move or hide.
+ window.addEventListener("resize", closeMenu);
+ document.addEventListener("fullscreenchange", closeMenu);
+ document.addEventListener("webkitfullscreenchange", closeMenu);
+ menu._cleanup = () => {
+ document.removeEventListener("click", onOutside, true);
+ document.removeEventListener("pointerdown", onOutside, true);
+ window.removeEventListener("keydown", onKey, true);
+ window.removeEventListener("resize", closeMenu);
+ document.removeEventListener("fullscreenchange", closeMenu);
+ document.removeEventListener("webkitfullscreenchange", closeMenu);
+ };
+ button.setAttribute("aria-expanded", "true");
+}
+
+function refresh() {
+ // An open menu may describe an album or a filter that no longer applies.
+ closeMenu();
+ syncButton();
+}
+
+export function initializeShowMenu() {
+ const button = document.getElementById("showMediaBtn");
+ if (!button) {
+ return;
+ }
+ // Deliberately NOT stopPropagation: the other popups close from listeners on
+ // document, and swallowing this click would strand them open behind this one.
+ button.addEventListener("click", () => {
+ if (document.getElementById(MENU_ID)) {
+ closeMenu();
+ } else {
+ openMenu();
+ }
+ });
+ syncButton();
+}
+
+// The stored filter changed: this menu, or the server's copy of the preference
+// arriving after boot.
+window.addEventListener("mediaFilterSettingChanged", refresh);
+// The album's classification changed: an album switch, a reindex, a deletion.
+window.addEventListener("mediaIndicesUpdated", refresh);
+// Close as soon as the album changes, not when its list lands: until then the
+// menu describes an album the user has left.
+window.addEventListener("albumChanged", closeMenu);
+
+document.addEventListener("DOMContentLoaded", initializeShowMenu);
diff --git a/photomap/frontend/static/javascript/umap-helpers.js b/photomap/frontend/static/javascript/umap-helpers.js
index b4b3faa4..c606bd67 100644
--- a/photomap/frontend/static/javascript/umap-helpers.js
+++ b/photomap/frontend/static/javascript/umap-helpers.js
@@ -40,9 +40,3 @@ export function filterPointsByMediaType(points, filter) {
}
return points;
}
-
-// True if any point is a video. Drives whether the filter is offered at all —
-// a "videos only" radio on an all-photo album can only produce a blank map.
-export function hasVideoPoints(points) {
- return Array.isArray(points) && points.some((p) => p?.media === "video");
-}
diff --git a/photomap/frontend/static/javascript/umap.js b/photomap/frontend/static/javascript/umap.js
index 24c205e5..baf34500 100644
--- a/photomap/frontend/static/javascript/umap.js
+++ b/photomap/frontend/static/javascript/umap.js
@@ -14,14 +14,13 @@ import { getCurrentSlideIndex, slideState } from "./slide-state.js";
import {
persistSettings,
setUmapClickSelectsCluster,
- setMediaFilter,
setUmapControlsVisible,
setUmapExitFullscreenOnSelection,
setUmapShowHoverThumbnails,
setUmapShowLandmarks,
state,
} from "./state.js";
-import { filterPointsByMediaType, findLandmarkClusterAt, hasVideoPoints } from "./umap-helpers.js";
+import { filterPointsByMediaType, findLandmarkClusterAt } from "./umap-helpers.js";
import { checkUmapReindexOngoing, initUmapReindexButton } from "./umap-reindex.js";
import { debounce, getPercentile, isColorLight, makeDraggable } from "./utils.js";
@@ -196,11 +195,9 @@ function visiblePoints() {
// plotted during an album switch.
//
// A filter this album cannot honour keeps everything rather than blanking the
-// map. The radios disable themselves for exactly this case, but the setting
-// can also arrive from the server *after* they were disabled (state.js
-// reconciles asynchronously, which is the whole reason this branch added the
-// mediaFilterSettingChanged listener), and a disabled radio is not a filter
-// that stopped applying.
+// map, which would read as "the semantic map is broken". The stored preference
+// is left alone: an album that cannot honour it is a fact about the album, not
+// a choice by the user, and it still applies on the next album that can.
function effectiveMapFilter() {
const filter = state.mediaFilter;
if (filter !== "images" && filter !== "videos") {
@@ -851,15 +848,6 @@ export async function fetchUmapData() {
const yMin = getPercentile(ys, 1);
const yMax = getPercentile(ys, 99);
- // Reconcile the filter against this album *before* the trace is built.
- // The album may have no videos at all, in which case a persisted
- // "videos only" would otherwise plot an empty trace. Doing it afterwards
- // still ended up looking right, but only because the setUmapColorMode()
- // at the end of this function happens to redraw trace 0 from the
- // corrected filter — a rescue that reads like a coincidence and would
- // stop working the moment that call moved or became conditional.
- updateMediaFilterAvailability();
-
// Prepare marker arrays
const drawn = visiblePoints();
const markerColors = drawn.map((p) => getClusterColor(p.cluster));
@@ -1410,29 +1398,6 @@ window.addEventListener("stateReady", () => {
}
});
}
-
- // Media filter radio buttons - initialize from state
- const mediaFilterRadios = MEDIA_FILTER_RADIO_IDS.map((id) => document.getElementById(id));
- if (mediaFilterRadios.every(Boolean)) {
- const active =
- mediaFilterRadios.find((radio) => radio.value === state.mediaFilter) ||
- mediaFilterRadios.find((radio) => radio.value === DEFAULT_MEDIA_FILTER);
- if (active) {
- active.checked = true;
- }
-
- mediaFilterRadios.forEach((radio) => {
- radio.addEventListener("change", (e) => {
- if (!e.target.checked) {
- return;
- }
- // Just record it. The redraw hangs off mediaFilterSettingChanged,
- // which this setter dispatches, so the click and a value arriving from
- // the server take the identical path — and take it once.
- setMediaFilter(e.target.value);
- });
- });
- }
});
// Height of the controls block before this file started measuring it. Used as
@@ -1463,94 +1428,13 @@ function reservedControlsHeight(gap, hiddenFallback) {
return (measured > 0 ? measured : UNMEASURED_CONTROLS_HEIGHT) + gap;
}
-// Listed in the order they appear in the control row.
-const MEDIA_FILTER_RADIO_IDS = ["umapMediaFilterImagesRadio", "umapMediaFilterVideosRadio", "umapMediaFilterBothRadio"];
-
-// Selected when nothing is persisted, and fallen back to when the stored
-// value isn't one we recognize. Matched by value rather than by position in
-// the list above, so reordering the radios in the UI cannot silently change
-// which one is the default.
+// Fallen back to when the stored filter isn't one we recognize, or is one the
+// album cannot honour.
const DEFAULT_MEDIA_FILTER = "both";
-// Offer the filter only when there is something to filter.
-//
-// A persisted "videos only" restored on an all-photo album would otherwise
-// render a blank map, which reads as "the semantic map is broken". The
-// controls are disabled rather than hidden so the control row keeps its
-// height — the window-sizing constants depend on that.
-function updateMediaFilterAvailability() {
- const container = document.getElementById("umapMediaFilterContainer");
- if (!container) {
- return;
- }
- const albumHasVideos = hasVideoPoints(points);
-
- // Deliberately NOT setMediaFilter(DEFAULT_MEDIA_FILTER) here. An album that
- // cannot honour the filter is a fact about the album, not a choice by the
- // user, and since this branch put mediaFilter in the server model that write
- // would travel: browse an all-photo album once and "videos" is gone from
- // state, localStorage and the server record, so switching back to a mixed
- // album comes up "Both". `effectiveMapFilter` degrades the drawing instead,
- // leaving the stored preference to mean what the user meant by it.
- MEDIA_FILTER_RADIO_IDS.forEach((id) => {
- const radio = document.getElementById(id);
- if (!radio) {
- return;
- }
- radio.disabled = !albumHasVideos;
- if (!albumHasVideos) {
- radio.checked = radio.value === DEFAULT_MEDIA_FILTER;
- }
- });
- if (albumHasVideos) {
- // Re-enabling is the other half of the listener below: a filter that
- // arrived while these were disabled was dropped rather than shown, since
- // the album it arrived on could not honour it. This album can, so the
- // radios have to catch up with the filter that is actually in force —
- // otherwise they read "Both" while every view browses videos only.
- syncMediaFilterRadios(state.mediaFilter);
- }
- container.style.opacity = albumHasVideos ? "1" : "0.5";
- container.title = albumHasVideos ? "" : "This album contains no videos";
-}
-
-// Move the checked radio to `value`, unless this album has taken the controls
-// away — while they are disabled updateMediaFilterAvailability owns what they
-// read, and it calls this itself once they come back.
-function syncMediaFilterRadios(value) {
- const radios = MEDIA_FILTER_RADIO_IDS.map((id) => document.getElementById(id));
- if (!radios.every(Boolean) || radios.some((radio) => radio.disabled)) {
- return;
- }
- // Falls back the way the build-time init does: an unrecognized value (a
- // hand-edited or corrupt localStorage entry — _parseStored does no
- // validation) must not leave the previous album's radio checked over a map
- // that is no longer filtered that way.
- const match =
- radios.find((radio) => radio.value === value) || radios.find((radio) => radio.value === DEFAULT_MEDIA_FILTER);
- if (match) {
- // Assigning `checked` fires no change event, so this cannot loop back
- // through the radios' own handler.
- match.checked = true;
- }
-}
-
-// Keep the radios reading what the filter actually is.
-//
-// They are checked from `state.mediaFilter` when the controls are built, which
-// is before the server's copy of the preference can arrive — state.js
-// reconciles asynchronously after boot, and the server is authoritative when
-// the localStorage cache is missing or stale (the case an iOS storage eviction
-// leaves behind). Now that the filter is stored server-side at all, a device
-// restoring "videos" that way would filter every view to videos while the map's
-// own controls still read "Both".
-window.addEventListener("mediaFilterSettingChanged", async (e) => {
- syncMediaFilterRadios(e.detail?.value);
- // And redraw. Moving the radio is not enough on its own: assigning `checked`
- // fires no change event (which is what stops this looping), so before this
- // the map kept drawing the old visible set under a radio that had already
- // flipped — and the user could not fix it by clicking the radio that was
- // already checked.
+// Redraw when the filter changes: from the Search panel's Show menu, or when
+// state.js applies the server's copy of the preference after boot.
+window.addEventListener("mediaFilterSettingChanged", async () => {
if (!mapExists) {
return;
}
diff --git a/photomap/frontend/static/main.js b/photomap/frontend/static/main.js
index 9438401f..cbe03bab 100644
--- a/photomap/frontend/static/main.js
+++ b/photomap/frontend/static/main.js
@@ -18,6 +18,7 @@ import "./javascript/search-ui.js";
import "./javascript/search.js";
import "./javascript/seek-slider.js";
import "./javascript/settings.js";
+import "./javascript/show-menu.js";
import { slideState } from "./javascript/slide-state.js";
import "./javascript/state.js";
import "./javascript/swiper.js";
diff --git a/photomap/frontend/templates/modules/search-panel.html b/photomap/frontend/templates/modules/search-panel.html
index fdfb9e8b..b0469cf3 100644
--- a/photomap/frontend/templates/modules/search-panel.html
+++ b/photomap/frontend/templates/modules/search-panel.html
@@ -23,6 +23,14 @@
Bookmarks
+
+
+
+
Show
+
+
-
-
- Show:
-
-
-
-
-
{
expect(mf.mediaBrowseList()).toEqual([{ index: 1 }, { index: 2 }, { index: 3 }]);
});
});
+
+describe("mediaIndicesUpdated", () => {
+ let updates;
+ const onUpdate = () => updates++;
+
+ beforeEach(() => {
+ updates = 0;
+ window.addEventListener("mediaIndicesUpdated", onUpdate);
+ });
+
+ afterEach(() => {
+ window.removeEventListener("mediaIndicesUpdated", onUpdate);
+ });
+
+ it("fires on a load even when no filter is announced", async () => {
+ fetchJson.mockResolvedValueOnce({ total: 3, video_indices: [] });
+ mockState.mediaFilter = "both";
+ await mf.loadMediaIndices("alb");
+ expect(updates).toBe(1);
+ expect(mf.albumMediaMix()).toBe("images");
+ });
+
+ it("reports the mix as unknown after a failed fetch, and recovers on the next", async () => {
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
+ fetchJson.mockRejectedValueOnce(new Error("500"));
+ await mf.loadMediaIndices("alb");
+ expect(updates).toBe(1);
+ expect(mf.albumMediaMix()).toBe("unknown");
+ mockState.mediaFilter = "videos";
+ expect(mf.effectiveMediaFilter()).toBe("both");
+
+ fetchJson.mockResolvedValueOnce({ total: 3, video_indices: [1] });
+ await mf.loadMediaIndices("alb");
+ expect(mf.albumMediaMix()).toBe("mixed");
+ warn.mockRestore();
+ });
+
+ it("fires for a deletion that uses up the album's videos", () => {
+ mf._setMediaIndicesForTest({ album: "alb", total: 3, videoIndices: [1] });
+ expect(mf.albumMediaMix()).toBe("mixed");
+ window.dispatchEvent(
+ new CustomEvent("albumChanged", {
+ detail: { album: "alb", changeType: "deletion", deletedIndices: [1], totalImages: 2 },
+ })
+ );
+ expect(updates).toBe(1);
+ expect(mf.albumMediaMix()).toBe("images");
+ });
+});
diff --git a/tests/frontend/show-menu.test.js b/tests/frontend/show-menu.test.js
new file mode 100644
index 00000000..91e7e903
--- /dev/null
+++ b/tests/frontend/show-menu.test.js
@@ -0,0 +1,234 @@
+// show-menu.js: the Search panel's images/videos/both control.
+//
+// It owns no filter logic, so these cover what it does own: the choice reaching
+// setMediaFilter, the icon and disabled state following the album, and a menu
+// that closes in every way the app's other popups expect it to.
+
+import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
+
+const JS = "../../photomap/frontend/static/javascript";
+
+const mockState = { album: "alb", mediaFilter: "both" };
+const fetchJson = jest.fn();
+const setMediaFilter = jest.fn((value) => {
+ mockState.mediaFilter = value;
+ window.dispatchEvent(new CustomEvent("mediaFilterSettingChanged", { detail: { value } }));
+});
+
+jest.unstable_mockModule(`${JS}/state.js`, () => ({ state: mockState, setMediaFilter }));
+jest.unstable_mockModule(`${JS}/utils.js`, () => ({ fetchJson }));
+
+const mf = await import(`${JS}/media-filter.js`);
+const { initializeShowMenu } = await import(`${JS}/show-menu.js`);
+
+const MIXED = { album: "alb", total: 6, videoIndices: [1, 4] };
+const ALL_PHOTOS = { album: "alb", total: 6, videoIndices: [] };
+const ALL_VIDEOS = { album: "alb", total: 2, videoIndices: [0, 1] };
+
+const button = () => document.getElementById("showMediaBtn");
+const menu = () => document.getElementById("showMediaMenu");
+const item = (value) => menu().querySelector(`[data-value="${value}"]`);
+const iconHtml = () => document.getElementById("showMediaIcon").innerHTML;
+
+function loadAlbum(mix) {
+ mf._setMediaIndicesForTest(mix);
+ window.dispatchEvent(new CustomEvent("mediaIndicesUpdated"));
+}
+
+function iconFor(value) {
+ // The menu renders the same artwork the button adopts.
+ button().click();
+ const html = item(value).querySelector("svg").outerHTML;
+ button().click();
+ return html;
+}
+
+beforeEach(() => {
+ mockState.mediaFilter = "both";
+ setMediaFilter.mockClear();
+ document.body.innerHTML = `
+
+
+
+
+
+
`;
+ mf._setMediaIndicesForTest(MIXED);
+ initializeShowMenu();
+});
+
+afterEach(() => {
+ // Closes any open menu, which removes its document and window listeners.
+ window.dispatchEvent(new CustomEvent("mediaIndicesUpdated"));
+});
+
+describe("choosing", () => {
+ it("offers images, videos and both, marking the active one", () => {
+ button().click();
+ expect([...menu().children].map((el) => el.dataset.value)).toEqual(["images", "videos", "both"]);
+ expect(item("both").getAttribute("aria-current")).toBe("true");
+ expect(item("images").hasAttribute("aria-current")).toBe(false);
+ expect(button().getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("records the choice through the shared setter and closes", () => {
+ button().click();
+ item("videos").click();
+ expect(setMediaFilter).toHaveBeenCalledWith("videos");
+ expect(menu()).toBeNull();
+ expect(button().getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("changes the icon to match the mode", () => {
+ const icons = Object.fromEntries(["images", "videos", "both"].map((v) => [v, iconFor(v)]));
+ expect(new Set(Object.values(icons)).size).toBe(3);
+ for (const value of ["images", "videos", "both"]) {
+ button().click();
+ item(value).click();
+ expect(iconHtml()).toBe(icons[value]);
+ }
+ });
+
+ it("follows a change made elsewhere, such as the server's copy arriving after boot", () => {
+ const videosIcon = iconFor("videos");
+ mockState.mediaFilter = "videos";
+ window.dispatchEvent(new CustomEvent("mediaFilterSettingChanged", { detail: { value: "videos" } }));
+ expect(iconHtml()).toBe(videosIcon);
+ expect(document.getElementById("showContainer").title).toBe("Show: Videos only");
+ });
+});
+
+describe("closing", () => {
+ it("toggles shut from its own button", () => {
+ button().click();
+ button().click();
+ expect(menu()).toBeNull();
+ });
+
+ it("closes on a click elsewhere", () => {
+ button().click();
+ document.body.click();
+ expect(menu()).toBeNull();
+ });
+
+ it("closes even when the click stops propagating, as the bookmarks button's does", () => {
+ const bookmarks = document.getElementById("bookmarkMenuBtn");
+ bookmarks.addEventListener("click", (e) => e.stopPropagation());
+ button().click();
+ bookmarks.click();
+ expect(menu()).toBeNull();
+ });
+
+ it("keeps Escape from reaching the app's shortcuts", () => {
+ const shortcut = jest.fn();
+ document.addEventListener("keydown", shortcut);
+ button().click();
+ document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ document.removeEventListener("keydown", shortcut);
+ expect(menu()).toBeNull();
+ expect(shortcut).not.toHaveBeenCalled();
+ });
+
+ it("leaves no listeners behind to swallow later clicks or Escape", () => {
+ const shortcut = jest.fn();
+ document.addEventListener("keydown", shortcut);
+ button().click();
+ button().click();
+ document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ document.removeEventListener("keydown", shortcut);
+ expect(shortcut).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes when the album changes underneath it", () => {
+ button().click();
+ loadAlbum(ALL_PHOTOS);
+ expect(menu()).toBeNull();
+ });
+
+ it("closes as soon as an album switch starts, before its media list lands", () => {
+ button().click();
+ window.dispatchEvent(new CustomEvent("albumChanged", { detail: { album: "other" } }));
+ expect(menu()).toBeNull();
+ });
+
+ it("closes on a pointerdown that no click follows, as on the map's titlebar", () => {
+ button().click();
+ document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
+ expect(menu()).toBeNull();
+ });
+
+ it("stays open for a pointerdown on itself, so that the click can land", () => {
+ button().click();
+ item("videos").dispatchEvent(new Event("pointerdown", { bubbles: true }));
+ expect(menu()).not.toBeNull();
+ button().dispatchEvent(new Event("pointerdown", { bubbles: true }));
+ expect(menu()).not.toBeNull();
+ });
+
+ it.each([
+ ["resize", window],
+ ["fullscreenchange", document],
+ ])("closes on %s, which moves or hides the panel it was placed against", (type, target) => {
+ button().click();
+ target.dispatchEvent(new Event(type));
+ expect(menu()).toBeNull();
+ });
+});
+
+describe("an album whose media list could not be read", () => {
+ async function failLoad() {
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
+ fetchJson.mockRejectedValueOnce(new Error("500"));
+ await mf.loadMediaIndices("alb");
+ warn.mockRestore();
+ }
+
+ it("stays usable, reads the stored filter, and makes no claim about the album", async () => {
+ // The map classifies its own points, so it can still be filtered, and this
+ // is the only control that can change that.
+ const videosIcon = iconFor("videos");
+ mockState.mediaFilter = "videos";
+ await failLoad();
+ expect(button().disabled).toBe(false);
+ expect(iconHtml()).toBe(videosIcon);
+ const tooltip = document.getElementById("showContainer").title;
+ expect(tooltip).toMatch(/could not read/);
+ expect(tooltip).not.toMatch(/no videos/);
+
+ button().click();
+ item("both").click();
+ expect(setMediaFilter).toHaveBeenCalledWith("both");
+ });
+});
+
+describe("albums with nothing to filter", () => {
+ it("disables on an all-photo album and says why", () => {
+ loadAlbum(ALL_PHOTOS);
+ expect(button().disabled).toBe(true);
+ expect(document.getElementById("showContainer").title).toBe("This album contains no videos");
+ });
+
+ it("disables on an all-video album and says why", () => {
+ loadAlbum(ALL_VIDEOS);
+ expect(button().disabled).toBe(true);
+ expect(document.getElementById("showContainer").title).toBe("This album contains only videos");
+ });
+
+ it("shows Both, not a stored filter the album cannot honour, and keeps the preference", () => {
+ const bothIcon = iconFor("both");
+ mockState.mediaFilter = "videos";
+ loadAlbum(ALL_PHOTOS);
+ expect(iconHtml()).toBe(bothIcon);
+ expect(setMediaFilter).not.toHaveBeenCalled();
+ expect(mockState.mediaFilter).toBe("videos");
+ });
+
+ it("comes back on a mixed album reading the stored filter", () => {
+ const videosIcon = iconFor("videos");
+ mockState.mediaFilter = "videos";
+ loadAlbum(ALL_PHOTOS);
+ loadAlbum(MIXED);
+ expect(button().disabled).toBe(false);
+ expect(iconHtml()).toBe(videosIcon);
+ });
+});
diff --git a/tests/frontend/umap-cluster-labels.test.js b/tests/frontend/umap-cluster-labels.test.js
index 35b4c490..f36f6ea6 100644
--- a/tests/frontend/umap-cluster-labels.test.js
+++ b/tests/frontend/umap-cluster-labels.test.js
@@ -32,7 +32,6 @@ const mockSetClusterLabels = jest.fn();
jest.unstable_mockModule(`${JS}/state.js`, () => ({
state: mockState,
- setMediaFilter: jest.fn(),
setUmapShowLandmarks: jest.fn(),
setUmapClickSelectsCluster: jest.fn(),
setUmapControlsVisible: jest.fn(),
diff --git a/tests/frontend/umap-effective-eps.test.js b/tests/frontend/umap-effective-eps.test.js
index 137a23d3..7edbbc46 100644
--- a/tests/frontend/umap-effective-eps.test.js
+++ b/tests/frontend/umap-effective-eps.test.js
@@ -35,24 +35,12 @@ const mockState = {
searchResults: [],
};
-// Mirrors the real setter (state.js `_makeSetter` + the mediaFilter spec's
-// onSet): no-op when unchanged, otherwise assign and announce. umap.js redraws
-// off that event, so a mock that only assigned would leave the radios changing
-// nothing.
-const setMediaFilter = jest.fn((v) => {
- if (mockState.mediaFilter === v) {
- return;
- }
- mockState.mediaFilter = v;
- window.dispatchEvent(new CustomEvent("mediaFilterSettingChanged", { detail: { value: v } }));
-});
const setUmapShowLandmarks = jest.fn((v) => {
mockState.umapShowLandmarks = v;
});
jest.unstable_mockModule(`${JS}/state.js`, () => ({
state: mockState,
- setMediaFilter,
setUmapShowLandmarks,
setUmapClickSelectsCluster: jest.fn(),
setUmapControlsVisible: jest.fn(),
diff --git a/tests/frontend/umap-eps-auto.test.js b/tests/frontend/umap-eps-auto.test.js
index 99aa7c30..4c2e52dd 100644
--- a/tests/frontend/umap-eps-auto.test.js
+++ b/tests/frontend/umap-eps-auto.test.js
@@ -32,7 +32,6 @@ const mockState = {
jest.unstable_mockModule(`${JS}/state.js`, () => ({
state: mockState,
- setMediaFilter: jest.fn(),
setUmapShowLandmarks: jest.fn(),
setUmapClickSelectsCluster: jest.fn(),
setUmapControlsVisible: jest.fn(),
diff --git a/tests/frontend/umap-eps-debounce.test.js b/tests/frontend/umap-eps-debounce.test.js
index 13c484bb..4a160a65 100644
--- a/tests/frontend/umap-eps-debounce.test.js
+++ b/tests/frontend/umap-eps-debounce.test.js
@@ -43,16 +43,12 @@ const mockState = {
searchResults: [],
};
-const setMediaFilter = jest.fn((v) => {
- mockState.mediaFilter = v;
-});
const setSearchResults = jest.fn();
// Mutable so a test can put the swiper on a specific image.
const currentSlideIndex = [-1, 0, null];
jest.unstable_mockModule(`${JS}/state.js`, () => ({
state: mockState,
- setMediaFilter,
setUmapShowLandmarks: jest.fn((v) => {
mockState.umapShowLandmarks = v;
}),
diff --git a/tests/frontend/umap-helpers.test.js b/tests/frontend/umap-helpers.test.js
index c77a3a36..b984da55 100644
--- a/tests/frontend/umap-helpers.test.js
+++ b/tests/frontend/umap-helpers.test.js
@@ -6,7 +6,6 @@ import {
MEDIA_FILTERS,
filterPointsByMediaType,
findLandmarkClusterAt,
- hasVideoPoints,
} from "../../photomap/frontend/static/javascript/umap-helpers.js";
describe("findLandmarkClusterAt", () => {
@@ -123,20 +122,3 @@ describe("filterPointsByMediaType", () => {
expect(MEDIA_FILTERS).toEqual(["both", "images", "videos"]);
});
});
-
-describe("hasVideoPoints", () => {
- it("detects a video among the points", () => {
- expect(hasVideoPoints([{ media: "image" }, { media: "video" }])).toBe(true);
- });
-
- it("reports false for an all-image album", () => {
- // Drives disabling the filter: a "videos only" radio on an all-photo
- // album can only ever produce a blank map.
- expect(hasVideoPoints([{ media: "image" }, {}])).toBe(false);
- });
-
- it("reports false for empty or missing input", () => {
- expect(hasVideoPoints([])).toBe(false);
- expect(hasVideoPoints(undefined)).toBe(false);
- });
-});
diff --git a/tests/frontend/umap-landmarks.test.js b/tests/frontend/umap-landmarks.test.js
index 601b3698..0e147b15 100644
--- a/tests/frontend/umap-landmarks.test.js
+++ b/tests/frontend/umap-landmarks.test.js
@@ -59,7 +59,7 @@ const mockState = {
// Mirrors the real setter (state.js `_makeSetter` + the mediaFilter spec's
// onSet): no-op when unchanged, otherwise assign and announce. umap.js redraws
-// off that event, so a mock that only assigned would leave the radios changing
+// off that event, so a mock that only assigned would leave the filter changing
// nothing.
const setMediaFilter = jest.fn((v) => {
if (mockState.mediaFilter === v) {
@@ -169,13 +169,7 @@ describe("umap.js landmarks", () => {
}
async function selectFilter(value) {
- const radio = document.getElementById(
- { both: "umapMediaFilterBothRadio", images: "umapMediaFilterImagesRadio", videos: "umapMediaFilterVideosRadio" }[
- value
- ]
- );
- radio.checked = true;
- radio.dispatchEvent(new Event("change"));
+ setMediaFilter(value);
await settle();
}
diff --git a/tests/frontend/umap-media-filter.test.js b/tests/frontend/umap-media-filter.test.js
index 9a354dee..658fd882 100644
--- a/tests/frontend/umap-media-filter.test.js
+++ b/tests/frontend/umap-media-filter.test.js
@@ -147,13 +147,7 @@ const plotDiv = () => document.getElementById("umapPlot");
const mainTrace = () => plotDiv().data[0];
function selectFilter(value) {
- const radio = document.getElementById(
- { both: "umapMediaFilterBothRadio", images: "umapMediaFilterImagesRadio", videos: "umapMediaFilterVideosRadio" }[
- value
- ]
- );
- radio.checked = true;
- radio.dispatchEvent(new Event("change"));
+ setMediaFilter(value);
}
/** Indices handed to setSearchResults by the most recent selection. */
@@ -353,23 +347,12 @@ describe("an album with no videos", () => {
expect(setMediaFilter).not.toHaveBeenCalled();
expect(mockState.mediaFilter).toBe("videos");
});
-
- it("disables the radios and checks Both", () => {
- expect(document.getElementById("umapMediaFilterVideosRadio").disabled).toBe(true);
- expect(document.getElementById("umapMediaFilterImagesRadio").disabled).toBe(true);
- expect(document.getElementById("umapMediaFilterBothRadio").checked).toBe(true);
- });
-
- it("explains why the control is unavailable", () => {
- expect(document.getElementById("umapMediaFilterContainer").title).toMatch(/no videos/i);
- });
});
-// The filter can also change without anyone touching the radios: state.js
-// applies the server's copy of the preference after boot, which is how a
-// device whose localStorage was evicted gets its settings back. That happens
-// after the controls were built from the value the radios currently show.
-describe("a filter applied from outside the controls", () => {
+// state.js applies the server's copy of the preference after boot, which is how
+// a device whose localStorage was evicted gets its settings back. It announces
+// the value without going through the setter.
+describe("a filter applied from outside the setter", () => {
beforeEach(async () => {
setMediaFilter.mockClear();
mockState.mediaFilter = "both";
@@ -377,22 +360,7 @@ describe("a filter applied from outside the controls", () => {
await boot(MIXED_POINTS);
});
- it("moves the checked radio to match", () => {
- expect(document.getElementById("umapMediaFilterBothRadio").checked).toBe(true);
-
- mockState.mediaFilter = "videos";
- window.dispatchEvent(new CustomEvent("mediaFilterSettingChanged", { detail: { value: "videos" } }));
-
- expect(document.getElementById("umapMediaFilterVideosRadio").checked).toBe(true);
- expect(document.getElementById("umapMediaFilterBothRadio").checked).toBe(false);
- });
-
- it("redraws the map, not just the radio", async () => {
- // Assigning `checked` fires no change event — that is what stops this
- // looping back through the radios' own handler — so the redraw has to be
- // driven from here too. Otherwise the radio reads Videos over a map still
- // drawing all 13 points, and clicking the radio that is already checked
- // fires nothing, so the user cannot even correct it.
+ it("redraws the map", async () => {
expect(mainTrace().x).toHaveLength(MIXED_POINTS.length);
mockState.mediaFilter = "videos";
@@ -404,10 +372,9 @@ describe("a filter applied from outside the controls", () => {
});
});
-// Each case boots its own module: the radios are only disabled by a real
-// fetchUmapData, and a second boot() inside one test would reuse the cached
-// module, whose map is already current and skips the redraw.
-describe("a filter applied from outside the controls, on an album with no videos", () => {
+// Each case boots its own module: a second boot() inside one test would reuse
+// the cached module, whose map is already current and skips the redraw.
+describe("a filter applied from outside the setter, on an album with no videos", () => {
let umapModule;
beforeEach(async () => {
@@ -417,23 +384,18 @@ describe("a filter applied from outside the controls, on an album with no videos
umapModule = await boot(IMAGE_ONLY_POINTS);
});
- it("does not re-check radios the album has disabled", () => {
- // The album disables the controls and pins them to Both; a late-arriving
- // "videos" must not tick a radio the album cannot honour.
- expect(document.getElementById("umapMediaFilterVideosRadio").disabled).toBe(true);
-
+ it("does not blank the map", async () => {
+ mockState.mediaFilter = "videos";
window.dispatchEvent(new CustomEvent("mediaFilterSettingChanged", { detail: { value: "videos" } }));
+ await settle();
- expect(document.getElementById("umapMediaFilterVideosRadio").checked).toBe(false);
- expect(document.getElementById("umapMediaFilterBothRadio").checked).toBe(true);
+ expect(mainTrace().x).toHaveLength(IMAGE_ONLY_POINTS.length);
});
- it("catches the radios up once an album that has videos re-enables them", async () => {
- // The filter dropped above is still in force — it was never un-set, only
- // left unshown. An album that can honour it must not go on reading "Both"
- // while every view browses videos only.
- window.dispatchEvent(new CustomEvent("mediaFilterSettingChanged", { detail: { value: "videos" } }));
+ it("applies once an album that can honour it is loaded", async () => {
+ // The filter was never un-set, only unable to apply.
mockState.mediaFilter = "videos";
+ window.dispatchEvent(new CustomEvent("mediaFilterSettingChanged", { detail: { value: "videos" } }));
currentSlideIndex = [-1, MIXED_POINTS.length, null];
installFetchMock(MIXED_POINTS);
@@ -441,8 +403,6 @@ describe("a filter applied from outside the controls, on an album with no videos
await umapModule.fetchUmapData();
await settle();
- expect(document.getElementById("umapMediaFilterVideosRadio").disabled).toBe(false);
- expect(document.getElementById("umapMediaFilterVideosRadio").checked).toBe(true);
- expect(document.getElementById("umapMediaFilterBothRadio").checked).toBe(false);
+ expect(mainTrace().x).toHaveLength(3);
});
});
diff --git a/tests/frontend/umap-reindex-refresh.test.js b/tests/frontend/umap-reindex-refresh.test.js
index 5373c386..d5b18b61 100644
--- a/tests/frontend/umap-reindex-refresh.test.js
+++ b/tests/frontend/umap-reindex-refresh.test.js
@@ -34,7 +34,6 @@ const mockState = {
jest.unstable_mockModule(`${JS}/state.js`, () => ({
state: mockState,
- setMediaFilter: jest.fn(),
setUmapShowLandmarks: jest.fn(),
setUmapClickSelectsCluster: jest.fn(),
setUmapControlsVisible: jest.fn(),