mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
Merge pull request #4896 from rommapp/fix/bulk-actions-whole-selection-4460
Size gallery bulk actions for whole-result selections
This commit is contained in:
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Редактирай запис",
|
||||
"edit-screenshot": "Редактирай екранна снимка",
|
||||
"edit-state": "Редактирай бърз запис",
|
||||
"exclude-failed": "{n} изтрити игри не можаха да бъдат изключени от сканиранията",
|
||||
"favorite": "Любим",
|
||||
"favorites-updated-n": "{n} елемент е обновен | {n} елемента са обновени",
|
||||
"file": "Файл",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Upravit uloženou pozici",
|
||||
"edit-screenshot": "Upravit screenshot",
|
||||
"edit-state": "Upravit stav",
|
||||
"exclude-failed": "{n} smazaných her se nepodařilo vyloučit ze skenování",
|
||||
"favorite": "Oblíbené",
|
||||
"favorites-updated-n": "{n} položka aktualizována | {n} položek aktualizováno",
|
||||
"file": "Soubor",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Spielstand bearbeiten",
|
||||
"edit-screenshot": "Screenshot bearbeiten",
|
||||
"edit-state": "Zustand bearbeiten",
|
||||
"exclude-failed": "{n} gelöschte Spiele konnten nicht von Scans ausgeschlossen werden",
|
||||
"favorite": "Favorit",
|
||||
"favorites-updated-n": "{n} Element aktualisiert | {n} Elemente aktualisiert",
|
||||
"file": "Datei",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Edit save",
|
||||
"edit-screenshot": "Edit screenshot",
|
||||
"edit-state": "Edit state",
|
||||
"exclude-failed": "Couldn't exclude {n} deleted games from scans",
|
||||
"favorite": "Favourite",
|
||||
"favorites-updated-n": "{n} item updated | {n} items updated",
|
||||
"file": "File",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Edit save",
|
||||
"edit-screenshot": "Edit screenshot",
|
||||
"edit-state": "Edit state",
|
||||
"exclude-failed": "Couldn't exclude {n} deleted games from scans",
|
||||
"favorite": "Favorite",
|
||||
"favorites-updated-n": "{n} item updated | {n} items updated",
|
||||
"file": "File",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Editar partida",
|
||||
"edit-screenshot": "Editar captura",
|
||||
"edit-state": "Editar estado",
|
||||
"exclude-failed": "No se pudieron excluir {n} juegos eliminados de los escaneos",
|
||||
"favorite": "Favorito",
|
||||
"favorites-updated-n": "{n} elemento actualizado | {n} elementos actualizados",
|
||||
"file": "Archivo",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Modifier la sauvegarde",
|
||||
"edit-screenshot": "Modifier la capture d'écran",
|
||||
"edit-state": "Modifier l'état",
|
||||
"exclude-failed": "Impossible d'exclure {n} jeux supprimés des analyses",
|
||||
"favorite": "Favori",
|
||||
"favorites-updated-n": "{n} élément mis à jour | {n} éléments mis à jour",
|
||||
"file": "Fichier",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Mentés szerkesztése",
|
||||
"edit-screenshot": "Képernyőkép szerkesztése",
|
||||
"edit-state": "Állás szerkesztése",
|
||||
"exclude-failed": "{n} törölt játékot nem sikerült kizárni a keresésekből",
|
||||
"favorite": "Kedvenc",
|
||||
"favorites-updated-n": "{n} elem frissítve | {n} elem frissítve",
|
||||
"file": "Fájl",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Modifica salvataggio",
|
||||
"edit-screenshot": "Modifica screenshot",
|
||||
"edit-state": "Modifica stato",
|
||||
"exclude-failed": "Impossibile escludere {n} giochi eliminati dalle scansioni",
|
||||
"favorite": "Preferito",
|
||||
"favorites-updated-n": "{n} elemento aggiornato | {n} elementi aggiornati",
|
||||
"file": "File",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "セーブを編集",
|
||||
"edit-screenshot": "スクリーンショットを編集",
|
||||
"edit-state": "ステートセーブを編集",
|
||||
"exclude-failed": "削除した{n}件のゲームをスキャン対象から除外できませんでした",
|
||||
"favorite": "お気に入り",
|
||||
"favorites-updated-n": "{n} 件を更新しました",
|
||||
"file": "ファイル",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "세이브 편집",
|
||||
"edit-screenshot": "스크린샷 편집",
|
||||
"edit-state": "상태 편집",
|
||||
"exclude-failed": "삭제된 게임 {n}개를 스캔에서 제외하지 못했습니다",
|
||||
"favorite": "즐겨찾기",
|
||||
"favorites-updated-n": "{n}개 항목을 업데이트했습니다",
|
||||
"file": "파일",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Edytuj zapis",
|
||||
"edit-screenshot": "Edytuj zrzut ekranu",
|
||||
"edit-state": "Edytuj stan",
|
||||
"exclude-failed": "Nie udało się wykluczyć {n} usuniętych gier ze skanowania",
|
||||
"favorite": "Ulubione",
|
||||
"favorites-updated-n": "Zaktualizowano {n} element | Zaktualizowano {n} elementów",
|
||||
"file": "Plik",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Editar save",
|
||||
"edit-screenshot": "Editar captura de tela",
|
||||
"edit-state": "Editar state",
|
||||
"exclude-failed": "Não foi possível excluir {n} jogos removidos das verificações",
|
||||
"favorite": "Favorito",
|
||||
"favorites-updated-n": "{n} item atualizado | {n} itens atualizados",
|
||||
"file": "Arquivo",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Editează salvarea",
|
||||
"edit-screenshot": "Editează captura de ecran",
|
||||
"edit-state": "Editează starea",
|
||||
"exclude-failed": "Nu s-au putut exclude {n} jocuri șterse din scanări",
|
||||
"favorite": "Favorit",
|
||||
"favorites-updated-n": "{n} element actualizat | {n} elemente actualizate",
|
||||
"file": "Fișier",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Изменить сохранение",
|
||||
"edit-screenshot": "Изменить скриншот",
|
||||
"edit-state": "Изменить состояние",
|
||||
"exclude-failed": "Не удалось исключить {n} удалённых игр из сканирования",
|
||||
"favorite": "В избранное",
|
||||
"favorites-updated-n": "Обновлён {n} элемент | Обновлено {n} элементов",
|
||||
"file": "Файл",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "Kaydı düzenle",
|
||||
"edit-screenshot": "Ekran görüntüsünü düzenle",
|
||||
"edit-state": "Durum kaydını düzenle",
|
||||
"exclude-failed": "Silinen {n} oyun taramalardan hariç tutulamadı",
|
||||
"favorite": "Favori",
|
||||
"favorites-updated-n": "{n} öge güncellendi",
|
||||
"file": "Dosya",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "编辑存档",
|
||||
"edit-screenshot": "编辑截图",
|
||||
"edit-state": "编辑状态",
|
||||
"exclude-failed": "无法将 {n} 个已删除的游戏排除在扫描之外",
|
||||
"favorite": "收藏",
|
||||
"favorites-updated-n": "已更新 {n} 个项目",
|
||||
"file": "文件",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"edit-save": "編輯存檔",
|
||||
"edit-screenshot": "編輯螢幕截圖",
|
||||
"edit-state": "編輯即時存檔",
|
||||
"exclude-failed": "無法將 {n} 個已刪除的遊戲排除在掃描之外",
|
||||
"favorite": "喜好",
|
||||
"favorites-updated-n": "已更新 {n} 個項目",
|
||||
"file": "檔案",
|
||||
|
||||
@@ -2,13 +2,14 @@ import { flushPromises, mount } from "@vue/test-utils";
|
||||
import mitt, { type Emitter } from "mitt";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { reactive } from "vue";
|
||||
import { defineComponent, reactive } from "vue";
|
||||
import storeRoms from "@/stores/roms";
|
||||
import type { Events } from "@/types/emitter";
|
||||
import { makeDetailedRom, makeRom } from "@/utils/rom.fixtures";
|
||||
import DeleteRomDialog from "./DeleteRomDialog.vue";
|
||||
|
||||
const { deleteRoms, push, snackbarError } = vi.hoisted(() => ({
|
||||
const { addExclusion, deleteRoms, push, snackbarError } = vi.hoisted(() => ({
|
||||
addExclusion: vi.fn(),
|
||||
deleteRoms: vi.fn(),
|
||||
push: vi.fn(),
|
||||
snackbarError: vi.fn(),
|
||||
@@ -29,7 +30,7 @@ vi.mock("vue-router", async (importOriginal) => ({
|
||||
vi.mock("@/plugins/router", () => ({ ROUTES: { PLATFORM: "platform" } }));
|
||||
vi.mock("@/services/api/rom", () => ({ default: { deleteRoms } }));
|
||||
vi.mock("@/services/api/config", () => ({
|
||||
default: { addExclusion: vi.fn() },
|
||||
default: { addExclusion },
|
||||
}));
|
||||
vi.mock("@/stores/config", () => ({
|
||||
default: () => ({ addExclusion: vi.fn() }),
|
||||
@@ -43,19 +44,35 @@ vi.mock("@/v2/composables/useSnackbar", () => ({
|
||||
|
||||
const RDialog = {
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
template: `<div v-if="modelValue"><slot name="content" /><slot name="footer" /></div>`,
|
||||
template: `<div v-if="modelValue"><slot name="content" /><slot name="append" /><slot name="footer" /></div>`,
|
||||
};
|
||||
const RBtn = {
|
||||
emits: ["click"],
|
||||
template: `<button type="button" @click="$emit('click')"><slot /></button>`,
|
||||
};
|
||||
const RCheckbox = {
|
||||
emits: ["update:modelValue"],
|
||||
template: `<input type="checkbox" @change="$emit('update:modelValue', true)" />`,
|
||||
};
|
||||
// happy-dom lays nothing out, so a windowed list would render no rows.
|
||||
const WholeList = defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
template: `<div><div v-for="(item, index) in items" :key="index"><slot :item="item" :index="index" /></div></div>`,
|
||||
});
|
||||
const stubs = {
|
||||
RDialog,
|
||||
RBtn,
|
||||
RCheckbox,
|
||||
RIcon: true,
|
||||
RVirtualScroller: WholeList,
|
||||
};
|
||||
|
||||
async function deleteShownGame() {
|
||||
const emitter: Emitter<Events> = mitt<Events>();
|
||||
const wrapper = mount(DeleteRomDialog, {
|
||||
global: {
|
||||
provide: { emitter },
|
||||
stubs: { RDialog, RBtn, RCheckbox: true, RIcon: true },
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
emitter.emit("showDeleteRomDialog", [
|
||||
@@ -106,3 +123,64 @@ describe("DeleteRomDialog", () => {
|
||||
expect(storeRoms().getDetailedRom(6)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeleteRomDialog with a large selection", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
deleteRoms.mockResolvedValue({
|
||||
data: { failed_ids: [], successful_items: 120 },
|
||||
});
|
||||
});
|
||||
|
||||
async function openWith(count: number) {
|
||||
const emitter: Emitter<Events> = mitt<Events>();
|
||||
const wrapper = mount(DeleteRomDialog, {
|
||||
global: {
|
||||
provide: { emitter },
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
emitter.emit(
|
||||
"showDeleteRomDialog",
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
makeRom({ id: i + 1, platform_id: 1 }),
|
||||
),
|
||||
);
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
it("hands every selected game to the virtual scroller", async () => {
|
||||
const wrapper = await openWith(120);
|
||||
|
||||
expect(wrapper.findComponent(WholeList).props("items")).toHaveLength(120);
|
||||
});
|
||||
|
||||
it("waits for the exclusions and reports the ones that failed", async () => {
|
||||
addExclusion
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("config not writable"));
|
||||
const wrapper = await openWith(2);
|
||||
|
||||
await wrapper.find('input[type="checkbox"]').trigger("change");
|
||||
await wrapper.findAll("button").at(-1)?.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(addExclusion).toHaveBeenCalledTimes(2);
|
||||
expect(snackbarError).toHaveBeenCalledWith(
|
||||
"rom.exclude-failed",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes every selected game", async () => {
|
||||
const wrapper = await openWith(120);
|
||||
|
||||
await wrapper.findAll("button").at(-1)?.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const sent = deleteRoms.mock.calls[0][0] as { roms: { id: number }[] };
|
||||
expect(sent.roms).toHaveLength(120);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// DeleteRomDialog: single or multi-ROM delete flow. Each row has a
|
||||
// "also remove file from disk" checkbox; a global "exclude on delete" flag
|
||||
// adds deleted filenames to the scan exclusion list so they don't re-appear.
|
||||
import { RBtn, RCheckbox, RDialog, RIcon } from "@v2/lib";
|
||||
// Rows are virtualised so a whole-library selection doesn't mount every cover.
|
||||
import { RBtn, RCheckbox, RDialog, RIcon, RVirtualScroller } from "@v2/lib";
|
||||
import type { Emitter } from "mitt";
|
||||
import { computed, inject, onBeforeUnmount, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -16,9 +17,14 @@ import type { Events } from "@/types/emitter";
|
||||
import { useRomSync } from "@/v2/composables/useRomSync";
|
||||
import { romIdFromRoute } from "@/v2/composables/useRouteRom";
|
||||
import { useSnackbar } from "@/v2/composables/useSnackbar";
|
||||
import { settleWithLimit } from "@/v2/utils/settleWithLimit";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
// A 66px row plus the 6px gap below it; `.r-v2-del-rom__row` pins the row.
|
||||
const ROW_HEIGHT_PX = 72;
|
||||
const LIST_MAX_HEIGHT_PX = 360;
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -32,6 +38,12 @@ const deleting = ref(false);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
const snackbar = useSnackbar();
|
||||
const configStore = storeConfig();
|
||||
const listHeight = computed(() =>
|
||||
Math.min(roms.value.length * ROW_HEIGHT_PX, LIST_MAX_HEIGHT_PX),
|
||||
);
|
||||
const fsIds = computed(() => new Set(romsToDeleteFromFs.value));
|
||||
const rowHeight = () => ROW_HEIGHT_PX;
|
||||
const rowKey = (rom: unknown) => (rom as SimpleRom).id;
|
||||
|
||||
const openHandler = (romsToDelete: SimpleRom[]) => {
|
||||
roms.value = romsToDelete;
|
||||
@@ -102,15 +114,30 @@ async function deleteRoms() {
|
||||
{ icon: "mdi-check-bold" },
|
||||
);
|
||||
if (exclude) {
|
||||
for (const rom of deletedRoms) {
|
||||
const type = rom.has_simple_single_file
|
||||
const exclusionType = (rom: SimpleRom) =>
|
||||
rom.has_simple_single_file
|
||||
? "EXCLUDED_SINGLE_FILES"
|
||||
: "EXCLUDED_MULTI_FILES";
|
||||
// One at a time: each call rewrites the config file server-side.
|
||||
const results = await settleWithLimit(deletedRoms, 1, (rom) =>
|
||||
configApi.addExclusion({
|
||||
exclusionValue: rom.fs_name,
|
||||
exclusionType: type,
|
||||
exclusionType: exclusionType(rom),
|
||||
}),
|
||||
);
|
||||
let failed = 0;
|
||||
results.forEach((result, i) => {
|
||||
const rom = deletedRoms[i];
|
||||
if (result.status === "fulfilled") {
|
||||
configStore.addExclusion(exclusionType(rom), rom.fs_name);
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
});
|
||||
if (failed > 0) {
|
||||
snackbar.error(t("rom.exclude-failed", { n: failed }), {
|
||||
icon: "mdi-close-circle",
|
||||
});
|
||||
configStore.addExclusion(type, rom.fs_name);
|
||||
}
|
||||
}
|
||||
romsStore.resetSelection();
|
||||
@@ -200,50 +227,55 @@ function closeDialog() {
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<ul class="r-v2-del-rom__list">
|
||||
<li
|
||||
v-for="rom in roms"
|
||||
:key="rom.id"
|
||||
class="r-v2-del-rom__row"
|
||||
:class="{
|
||||
'r-v2-del-rom__row--fs': romsToDeleteFromFs.includes(rom.id),
|
||||
}"
|
||||
>
|
||||
<div class="r-v2-del-rom__cover">
|
||||
<img
|
||||
v-if="coverFor(rom)"
|
||||
:src="coverFor(rom)!"
|
||||
:alt="rom.name ?? ''"
|
||||
/>
|
||||
<div v-else class="r-v2-del-rom__cover-placeholder">
|
||||
<RIcon icon="mdi-disc" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="r-v2-del-rom__meta">
|
||||
<p class="r-v2-del-rom__name" :title="rom.name ?? undefined">
|
||||
{{ rom.name || rom.fs_name }}
|
||||
</p>
|
||||
<p class="r-v2-del-rom__file" :title="rom.fs_name">
|
||||
{{ rom.fs_name }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="r-v2-del-rom__fs-toggle"
|
||||
:aria-pressed="romsToDeleteFromFs.includes(rom.id)"
|
||||
:aria-label="t('rom.delete-from-disk-aria', { name: rom.fs_name })"
|
||||
:class="{
|
||||
'r-v2-del-rom__fs-toggle--on': romsToDeleteFromFs.includes(
|
||||
rom.id,
|
||||
),
|
||||
}"
|
||||
@click="toggleRomOnFs(rom.id)"
|
||||
<RVirtualScroller
|
||||
:items="roms"
|
||||
:get-item-height="rowHeight"
|
||||
:get-item-key="rowKey"
|
||||
:height="listHeight"
|
||||
role="list"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
v-for="rom in [item as SimpleRom]"
|
||||
:key="rom.id"
|
||||
role="listitem"
|
||||
class="r-v2-del-rom__row"
|
||||
:class="{ 'r-v2-del-rom__row--fs': fsIds.has(rom.id) }"
|
||||
>
|
||||
<RIcon icon="mdi-harddisk-remove" size="14" />
|
||||
{{ t("rom.delete-file") }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="r-v2-del-rom__cover">
|
||||
<img
|
||||
v-if="coverFor(rom)"
|
||||
:src="coverFor(rom)!"
|
||||
:alt="rom.name ?? ''"
|
||||
/>
|
||||
<div v-else class="r-v2-del-rom__cover-placeholder">
|
||||
<RIcon icon="mdi-disc" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="r-v2-del-rom__meta">
|
||||
<p class="r-v2-del-rom__name" :title="rom.name ?? undefined">
|
||||
{{ rom.name || rom.fs_name }}
|
||||
</p>
|
||||
<p class="r-v2-del-rom__file" :title="rom.fs_name">
|
||||
{{ rom.fs_name }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="r-v2-del-rom__fs-toggle"
|
||||
:aria-pressed="fsIds.has(rom.id)"
|
||||
:aria-label="
|
||||
t('rom.delete-from-disk-aria', { name: rom.fs_name })
|
||||
"
|
||||
:class="{ 'r-v2-del-rom__fs-toggle--on': fsIds.has(rom.id) }"
|
||||
@click="toggleRomOnFs(rom.id)"
|
||||
>
|
||||
<RIcon icon="mdi-harddisk-remove" size="14" />
|
||||
{{ t("rom.delete-file") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</RVirtualScroller>
|
||||
</template>
|
||||
<template #append>
|
||||
<div class="r-v2-del-rom__append">
|
||||
@@ -308,18 +340,9 @@ function closeDialog() {
|
||||
background: var(--r-color-surface);
|
||||
}
|
||||
|
||||
.r-v2-del-rom__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.r-v2-del-rom__row {
|
||||
box-sizing: border-box;
|
||||
height: 66px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
align-items: center;
|
||||
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
selectAll,
|
||||
snackbarError,
|
||||
snackbarSuccess,
|
||||
updateUserRomProps,
|
||||
} = vi.hoisted(() => ({
|
||||
addRomsToCollection: vi.fn(),
|
||||
createCollection: vi.fn(),
|
||||
@@ -27,6 +28,7 @@ const {
|
||||
selectAll: vi.fn(),
|
||||
snackbarError: vi.fn(),
|
||||
snackbarSuccess: vi.fn(),
|
||||
updateUserRomProps: vi.fn(),
|
||||
}));
|
||||
|
||||
// The whole-result behavior is covered by the composable's own tests;
|
||||
@@ -65,6 +67,13 @@ vi.mock("@/services/api/collection", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/services/api/rom", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/services/api/rom")>();
|
||||
return {
|
||||
default: { ...actual.default, updateUserRomProps },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/v2/composables/useSnackbar", () => ({
|
||||
useSnackbar: () => ({
|
||||
success: snackbarSuccess,
|
||||
@@ -100,7 +109,10 @@ function select(...roms: SimpleRom[]) {
|
||||
roms.forEach((r, i) => selection.toggle(r, i));
|
||||
}
|
||||
|
||||
function mountBar(props: { hideDownload?: boolean } = {}) {
|
||||
function mountBar(
|
||||
props: { hideDownload?: boolean } = {},
|
||||
extraStubs: Record<string, unknown> = {},
|
||||
) {
|
||||
return mount(SelectionBar, {
|
||||
props,
|
||||
global: {
|
||||
@@ -120,6 +132,7 @@ function mountBar(props: { hideDownload?: boolean } = {}) {
|
||||
RMenuItem: true,
|
||||
RIcon: true,
|
||||
RDivider: true,
|
||||
...extraStubs,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -337,3 +350,41 @@ describe("SelectionBar select all", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelectionBar bulk status", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("ignores a status choice while the previous batch is running", async () => {
|
||||
let finish: () => void = () => {};
|
||||
updateUserRomProps.mockImplementation(
|
||||
() => new Promise<void>((resolve) => (finish = resolve)),
|
||||
);
|
||||
select(rom(1));
|
||||
const wrapper = mountBar(
|
||||
{},
|
||||
{
|
||||
RMenu: { template: "<div><slot /></div>" },
|
||||
RMenuItem: {
|
||||
props: { disabled: Boolean },
|
||||
emits: ["click"],
|
||||
template:
|
||||
"<button class='status-item' :disabled='disabled' @click=\"$emit('click')\"><slot /></button>",
|
||||
},
|
||||
},
|
||||
);
|
||||
const [first, second] = wrapper.findAll(".status-item");
|
||||
|
||||
await first.trigger("click");
|
||||
await second.trigger("click");
|
||||
expect(updateUserRomProps).toHaveBeenCalledTimes(1);
|
||||
expect(second.attributes("disabled")).toBeDefined();
|
||||
|
||||
finish();
|
||||
await flushPromises();
|
||||
await second.trigger("click");
|
||||
expect(updateUserRomProps).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
type StatusFlagKey,
|
||||
VISIBILITY_FLAG_KEYS,
|
||||
} from "@/v2/utils/romStatus";
|
||||
import { settleWithLimit } from "@/v2/utils/settleWithLimit";
|
||||
import {
|
||||
FILLET_PX,
|
||||
NOTCH_RADIUS_PX,
|
||||
@@ -188,7 +189,9 @@ async function bulkFavorite() {
|
||||
// ROM is missing it) sets it on all. The per-rom `updateUserRomProps` is
|
||||
// the only endpoint (no bulk variant), so we fan out one request per ROM,
|
||||
// optimistically write the store, and revert only the ROMs whose request
|
||||
// failed.
|
||||
// failed. The fan-out is bounded so a whole-library selection doesn't queue
|
||||
// thousands of requests on a single-worker backend.
|
||||
const STATUS_CONCURRENCY = 4;
|
||||
//
|
||||
// `enumAllActive` / `flagAllActive` mirror `allFavorited`: keyed by
|
||||
// status so each menu row can paint its active state and decide its
|
||||
@@ -218,10 +221,24 @@ const hasAnyStatus = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
// One batch at a time, so a slower earlier batch can't land after a newer choice.
|
||||
const applyingStatus = ref(false);
|
||||
|
||||
async function applyStatus(data: Partial<RomUserData>) {
|
||||
const roms = selection.roms;
|
||||
if (roms.length === 0) return;
|
||||
if (roms.length === 0 || applyingStatus.value) return;
|
||||
applyingStatus.value = true;
|
||||
try {
|
||||
await applyStatusTo(roms, data);
|
||||
} finally {
|
||||
applyingStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyStatusTo(
|
||||
roms: typeof selection.roms,
|
||||
data: Partial<RomUserData>,
|
||||
) {
|
||||
const before = new Map<number, RomUserData>();
|
||||
for (const rom of roms) {
|
||||
if (!rom.rom_user) continue;
|
||||
@@ -230,8 +247,8 @@ async function applyStatus(data: Partial<RomUserData>) {
|
||||
syncCachedRom(rom);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
roms.map((rom) => romApi.updateUserRomProps({ romId: rom.id, data })),
|
||||
const results = await settleWithLimit(roms, STATUS_CONCURRENCY, (rom) =>
|
||||
romApi.updateUserRomProps({ romId: rom.id, data }),
|
||||
);
|
||||
const failed = roms.filter((_, i) => results[i].status === "rejected");
|
||||
for (const rom of failed) {
|
||||
@@ -469,6 +486,7 @@ function clear() {
|
||||
:key="key"
|
||||
:icon="STATUS_ICONS[key]"
|
||||
:variant="enumAllActive[key] ? 'active' : 'default'"
|
||||
:disabled="applyingStatus"
|
||||
@click="toggleEnumStatus(key)"
|
||||
>
|
||||
{{ t(romStatusMap[key].i18nKey) }}
|
||||
@@ -485,6 +503,7 @@ function clear() {
|
||||
:icon="STATUS_ICONS[key]"
|
||||
:text-color="flagAllActive[key] ? 'brand-primary' : undefined"
|
||||
:icon-color="flagAllActive[key] ? 'brand-primary' : undefined"
|
||||
:disabled="applyingStatus"
|
||||
@click="toggleFlagStatus(key)"
|
||||
>
|
||||
{{ t(romStatusMap[key].i18nKey) }}
|
||||
@@ -503,6 +522,7 @@ function clear() {
|
||||
:icon="STATUS_ICONS[key]"
|
||||
:text-color="flagAllActive[key] ? 'brand-primary' : undefined"
|
||||
:icon-color="flagAllActive[key] ? 'brand-primary' : undefined"
|
||||
:disabled="applyingStatus"
|
||||
@click="toggleFlagStatus(key)"
|
||||
>
|
||||
{{ t(romStatusMap[key].i18nKey) }}
|
||||
@@ -516,6 +536,7 @@ function clear() {
|
||||
<RMenuItem
|
||||
icon="mdi-close-circle-outline"
|
||||
variant="danger"
|
||||
:disabled="applyingStatus"
|
||||
@click="clearStatus"
|
||||
>
|
||||
{{ t("rom.clear-all") }}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { settleWithLimit } from "./settleWithLimit";
|
||||
|
||||
describe("settleWithLimit", () => {
|
||||
it("never runs more than the limit at once", async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const task = async (n: number) => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
inFlight--;
|
||||
return n * 2;
|
||||
};
|
||||
|
||||
const results = await settleWithLimit([1, 2, 3, 4, 5, 6, 7], 3, task);
|
||||
|
||||
expect(peak).toBe(3);
|
||||
expect(results).toEqual(
|
||||
[2, 4, 6, 8, 10, 12, 14].map((value) => ({
|
||||
status: "fulfilled",
|
||||
value,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps each rejection at its item's index", async () => {
|
||||
const results = await settleWithLimit([1, 2, 3], 2, async (n) => {
|
||||
if (n === 2) throw new Error("boom");
|
||||
return n;
|
||||
});
|
||||
|
||||
expect(results.map((r) => r.status)).toEqual([
|
||||
"fulfilled",
|
||||
"rejected",
|
||||
"fulfilled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves an empty list", async () => {
|
||||
expect(await settleWithLimit([], 4, async () => 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** `Promise.allSettled` over `items`, with at most `limit` tasks in flight. */
|
||||
export async function settleWithLimit<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
task: (item: T) => Promise<R>,
|
||||
): Promise<PromiseSettledResult<R>[]> {
|
||||
const results = new Array<PromiseSettledResult<R>>(items.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
try {
|
||||
results[i] = { status: "fulfilled", value: await task(items[i]) };
|
||||
} catch (reason) {
|
||||
results[i] = { status: "rejected", reason };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Math.max(1, Math.min(limit, items.length));
|
||||
await Promise.all(Array.from({ length: workers }, worker));
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user