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:
Georges-Antoine Assi
2026-10-01 16:42:43 -04:00
committed by GitHub
24 changed files with 327 additions and 69 deletions
+1
View File
@@ -152,6 +152,7 @@
"edit-save": "Редактирай запис",
"edit-screenshot": "Редактирай екранна снимка",
"edit-state": "Редактирай бърз запис",
"exclude-failed": "{n} изтрити игри не можаха да бъдат изключени от сканиранията",
"favorite": "Любим",
"favorites-updated-n": "{n} елемент е обновен | {n} елемента са обновени",
"file": "Файл",
+1
View 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",
+1
View File
@@ -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",
+1
View 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": "Favourite",
"favorites-updated-n": "{n} item updated | {n} items updated",
"file": "File",
+1
View 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",
+1
View 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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -152,6 +152,7 @@
"edit-save": "セーブを編集",
"edit-screenshot": "スクリーンショットを編集",
"edit-state": "ステートセーブを編集",
"exclude-failed": "削除した{n}件のゲームをスキャン対象から除外できませんでした",
"favorite": "お気に入り",
"favorites-updated-n": "{n} 件を更新しました",
"file": "ファイル",
+1
View File
@@ -152,6 +152,7 @@
"edit-save": "세이브 편집",
"edit-screenshot": "스크린샷 편집",
"edit-state": "상태 편집",
"exclude-failed": "삭제된 게임 {n}개를 스캔에서 제외하지 못했습니다",
"favorite": "즐겨찾기",
"favorites-updated-n": "{n}개 항목을 업데이트했습니다",
"file": "파일",
+1
View 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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -152,6 +152,7 @@
"edit-save": "Изменить сохранение",
"edit-screenshot": "Изменить скриншот",
"edit-state": "Изменить состояние",
"exclude-failed": "Не удалось исключить {n} удалённых игр из сканирования",
"favorite": "В избранное",
"favorites-updated-n": "Обновлён {n} элемент | Обновлено {n} элементов",
"file": "Файл",
+1
View 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",
+1
View File
@@ -152,6 +152,7 @@
"edit-save": "编辑存档",
"edit-screenshot": "编辑截图",
"edit-state": "编辑状态",
"exclude-failed": "无法将 {n} 个已删除的游戏排除在扫描之外",
"favorite": "收藏",
"favorites-updated-n": "已更新 {n} 个项目",
"file": "文件",
+1
View 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([]);
});
});
+24
View File
@@ -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;
}