Merge pull request #4727 from andest01/chore/storybook-save-state-display-v2

Chore: Storybook Save State support
This commit is contained in:
Georges-Antoine Assi
2026-09-28 14:28:42 -04:00
committed by GitHub
19 changed files with 1121 additions and 693 deletions
@@ -108,7 +108,7 @@ import { useCan } from "@/v2/composables/useCan";
- Every primitive ships at least one story with controls and at least one variant per theme.
- A new interactive primitive that warrants gamepad navigation ships a `play()` interaction.
- Modified primitive: existing story must still render and its interactions still pass.
- `npm run test` runs Vitest **and** every `/lib` and `components/shared` story's `play()` via `composeStories`. Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
- `npm run test` runs Vitest **and** story `play()` functions via `composeStories`. Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
- **Responsive QA:** `.storybook/rommViewports.ts` + viewport globals in `preview.ts`; see `frontend-v2-input` for `data-bp` vs iframe width.
---
+1 -1
View File
@@ -159,7 +159,7 @@ Run from `frontend/`:
1. `npm run typecheck`: zero errors (`vue-tsc --noEmit`).
2. `npm run typecheck:scripts`: zero errors (`tsc -p tsconfig.node.json`, covers `scripts/`).
3. `trunk check`: ESLint clean on the changed files.
4. `npm run test`: zero failures (Vitest + happy-dom; runs unit tests **and** every `/lib` and `components/shared` story's `play()` via `composeStories`).
4. `npm run test`: zero failures (Vitest + happy-dom; runs unit tests **and** story `play()` functions via `composeStories`).
5. `npm run build`: zero failures (CI sanity check).
**If you touched the backend API:** start the backend, run `npm run generate`, then re-`typecheck`.
+1 -1
View File
@@ -185,7 +185,7 @@ Component docs and visual QA for `frontend/src/v2/` (port 6006):
```sh
npm run storybook
npm run storybook:test # composeStories + play() + a11y (v2 /lib and components/shared stories)
npm run storybook:test # composeStories + play() + a11y (every v2 story)
```
For responsive layouts, use the viewport toolbar presets from `.storybook/rommViewports.ts`.
+4
View File
@@ -1,6 +1,7 @@
import "@mdi/font/css/materialdesignicons.css";
import { withThemeByClassName } from "@storybook/addon-themes";
import { setup, type Preview } from "@storybook/vue3-vite";
import mitt from "mitt";
import { createPinia } from "pinia";
import { createMemoryHistory, createRouter } from "vue-router";
import { createVuetify } from "vuetify";
@@ -10,6 +11,7 @@ import storePermissions from "../src/stores/permissions";
import "../src/styles/common.css";
import "../src/styles/fonts.css";
import { dark, light } from "../src/styles/themes";
import type { Events } from "../src/types/emitter";
import { installBreakpointAttribute } from "../src/v2/composables/useBreakpoint";
import { ChromeLabelsKey } from "../src/v2/lib/a11y/chromeLabels";
import "../src/v2/styles/global.css";
@@ -41,6 +43,8 @@ setup((app) => {
// Stories exercise the same injected-label path as the app, so a
// primitive rendering an un-translated label fails here too.
app.provide(ChromeLabelsKey, createChromeLabels());
// Snackbar and confirm composables inject the app-wide event bus.
app.provide("emitter", mitt<Events>());
// A catch-all router so primitives that render real `<router-link>`s
// (RBtn / RListItem / RMenuItem with `to`) resolve a proper `href`
// instead of crashing on `router.resolve`. Any string path resolves.
@@ -0,0 +1,176 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { expect, waitFor, within } from "storybook/test";
import { useRouter } from "vue-router";
import type { DetailedRomSchema } from "@/__generated__";
import storeAuth from "@/stores/auth";
import { userFixture } from "@/utils/user.fixtures";
import {
mixedCommunitySaves,
mixedCommunityStates,
storyDetailedRom,
} from "@/v2/utils/saveStates.fixtures";
import {
downloadButtons,
pickSaveDataSubtab,
} from "@/v2/utils/saveStates.plays";
import SaveDataTab from "./SaveDataTab.vue";
type Subtab = "saves" | "states";
interface StoryArgs {
subtab: Subtab;
rom: DetailedRomSchema;
}
const MY_USER_ID = userFixture().id;
const mine = <T extends { user_id: number }>(xs: T[]) =>
xs.filter((x) => x.user_id === MY_USER_ID);
const theirs = <T extends { user_id: number }>(xs: T[]) =>
xs.filter((x) => x.user_id !== MY_USER_ID);
const MINE_HEADING: Record<Subtab, string> = {
saves: "My saves",
states: "My states",
};
// Both panels stay mounted behind v-show, so only the role query, which skips
// hidden nodes, tells which subtab is on screen.
async function waitForSubtab(root: HTMLElement, subtab: Subtab) {
await waitFor(() => {
expect(
within(root).getByRole("heading", { name: MINE_HEADING[subtab] }),
).toBeTruthy();
});
}
const meta: Meta<StoryArgs> = {
title: "GameDetails/SaveDataTab",
component: SaveDataTab,
parameters: {
layout: "fullscreen",
},
args: {
subtab: "saves",
rom: storyDetailedRom(),
},
render: (args) => ({
components: { SaveDataTab },
setup() {
storeAuth().setCurrentUser(userFixture({ username: "player" }));
void useRouter().replace({
query: { tab: "save-data", subtab: args.subtab },
});
return { rom: args.rom };
},
template: `
<div style="
box-sizing: border-box;
width: 100%;
min-height: 100%;
padding: var(--r-space-4);
background: var(--r-color-bg);
">
<SaveDataTab :rom="rom" style="height: min(720px, 85vh);" />
</div>
`,
}),
// Navigation is async, so every story waits for the subtab before the a11y scan.
play: async ({ canvasElement, args }) => {
await waitForSubtab(canvasElement, args.subtab);
},
};
export default meta;
type Story = StoryObj<StoryArgs>;
export const SavesFull: Story = {
name: "Saves · mine + community",
play: async ({ canvasElement, step }) => {
const ui = within(canvasElement);
await step("saves subtab shows mine and community sections", async () => {
await waitForSubtab(canvasElement, "saves");
expect(ui.getByRole("heading", { name: "Community" })).toBeTruthy();
expect(downloadButtons(canvasElement).length).toBeGreaterThan(0);
});
await step("switching to states subtab", async () => {
await pickSaveDataSubtab(canvasElement, /^States/i);
await waitForSubtab(canvasElement, "states");
expect(ui.queryByRole("heading", { name: "My saves" })).toBeNull();
});
},
};
export const StatesFull: Story = {
name: "States · mine + community",
args: { subtab: "states" },
};
export const SavesEmptyMine: Story = {
name: "Saves · empty mine",
args: {
rom: storyDetailedRom({
all_user_saves: theirs(mixedCommunitySaves()),
}),
},
play: async ({ canvasElement, step }) => {
await step("empty mine promotes upload dropzone", async () => {
await waitForSubtab(canvasElement, "saves");
expect(within(canvasElement).getByText("No saves yet")).toBeTruthy();
});
},
};
export const StatesEmptyMine: Story = {
name: "States · empty mine",
args: {
subtab: "states",
rom: storyDetailedRom({
all_user_states: theirs(mixedCommunityStates()),
}),
},
};
export const SavesMineOnly: Story = {
name: "Saves · mine only (no community)",
args: {
rom: storyDetailedRom({
all_user_saves: mine(mixedCommunitySaves()),
}),
},
};
export const StatesMineOnly: Story = {
name: "States · mine only",
args: {
subtab: "states",
rom: storyDetailedRom({
all_user_states: mine(mixedCommunityStates()),
}),
},
};
export const CompletelyEmpty: Story = {
name: "Empty · no saves or states",
args: {
rom: storyDetailedRom({ all_user_saves: [], all_user_states: [] }),
},
};
export const SingleCommunitySave: Story = {
name: "Saves · single community save",
args: {
rom: storyDetailedRom({
all_user_saves: theirs(mixedCommunitySaves()).slice(0, 1),
}),
},
};
export const SingleCommunityState: Story = {
name: "States · single community state",
args: {
subtab: "states",
rom: storyDetailedRom({
all_user_states: theirs(mixedCommunityStates()).slice(0, 1),
}),
},
};
@@ -28,7 +28,6 @@ import stateApi from "@/services/api/state";
import storeAuth from "@/stores/auth";
import storeConfig from "@/stores/config";
import { getSupportedEJSCores } from "@/utils";
import AssetActions from "@/v2/components/GameDetails/AssetActions.vue";
import AssetEditDialog, {
type AssetEdit,
} from "@/v2/components/GameDetails/AssetEditDialog.vue";
@@ -40,6 +39,7 @@ import SubtabNav, {
import UploadAssetDialog, {
type UploadAssetPayload,
} from "@/v2/components/GameDetails/UploadAssetDialog.vue";
import AssetActions from "@/v2/components/shared/AssetActions.vue";
import AssetList from "@/v2/components/shared/AssetList.vue";
import AssetStrip from "@/v2/components/shared/AssetStrip.vue";
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
@@ -247,7 +247,7 @@ async function onStateUpload(files: File[], emulator: string | null) {
}
// ---------- Per-row actions ----------
// Both saves and states ship `download_path` from the backend — fire
// Both saves and states ship `download_path` from the backend, so fire
// a synthesized anchor click rather than a window.open so the browser
// uses the right filename and skips the new-tab affordance.
function downloadAsset(asset: { download_path: string; file_name: string }) {
@@ -591,7 +591,7 @@ const labelSuggestions = computed(() =>
</aside>
<div class="r-v2-saves__content">
<!-- Saves subtab — vertical info list -->
<!-- Saves subtab: vertical info list -->
<section v-show="subTab === 'saves'" class="r-v2-saves__panel">
<!-- Mine -->
<div class="r-v2-saves__section">
@@ -699,7 +699,7 @@ const labelSuggestions = computed(() =>
</div>
</section>
<!-- States subtab — tile grid (screenshot is the point) -->
<!-- States subtab: tile grid (screenshot is the point) -->
<section v-show="subTab === 'states'" class="r-v2-saves__panel">
<!-- Mine -->
<div class="r-v2-saves__section">
@@ -1,66 +1,29 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import type { SaveSchema, StateSchema } from "@/__generated__";
import {
hoursAgo,
makeSave,
makeState,
screenshotFixture,
} from "@/v2/utils/saveStates.fixtures";
import AssetPreview from "./AssetPreview.vue";
function makeSave(overrides: Partial<SaveSchema> = {}): SaveSchema {
return {
id: 1,
rom_id: 1,
user_id: 1,
file_name: "main_quest.srm",
file_name_no_tags: "main_quest.srm",
file_name_no_ext: "main_quest",
file_extension: "srm",
file_path: "/saves/snes",
file_size_bytes: 8192,
full_path: "/saves/snes/main_quest.srm",
download_path: "/api/saves/1/content",
missing_from_fs: false,
created_at: "2026-03-14T10:12:00Z",
updated_at: "2026-05-23T18:45:00Z",
emulator: "snes9x",
slot: "main_quest",
screenshot: null,
function save(overrides: Partial<SaveSchema> = {}): SaveSchema {
return makeSave(1, "main_quest", 49, {
created_at: hoursAgo(72 * 24),
...overrides,
} as SaveSchema;
});
}
function makeState(
fileName: string,
shotUrl: string | null,
overrides: Partial<StateSchema> = {},
): StateSchema {
return {
id: 1,
rom_id: 1,
user_id: 1,
function namedState(fileName: string, shotUrl: string | null): StateSchema {
return makeState({
file_name: fileName,
file_name_no_tags: fileName,
file_name_no_ext: fileName.replace(/\.state$/, ""),
file_extension: "state",
file_path: "/states/snes",
file_size_bytes: 524288,
full_path: `/states/snes/${fileName}`,
download_path: "/api/states/1/content",
missing_from_fs: false,
created_at: "2026-04-02T09:00:00Z",
updated_at: "2026-05-25T18:08:00Z",
emulator: "snes9x",
screenshot: shotUrl
? ({
id: 1,
rom_id: 1,
user_id: 1,
file_name: `${fileName}.png`,
file_name_no_tags: `${fileName}.png`,
file_name_no_ext: fileName,
file_extension: "png",
file_path: "/screenshots/snes",
download_path: shotUrl,
} as StateSchema["screenshot"])
: null,
...overrides,
} as StateSchema;
updated_at: hoursAgo(2),
screenshot: shotUrl ? screenshotFixture(shotUrl) : null,
});
}
const meta: Meta<typeof AssetPreview> = {
@@ -86,113 +49,73 @@ const meta: Meta<typeof AssetPreview> = {
export default meta;
type Story = StoryObj<typeof AssetPreview>;
// State with a screenshot — the headline case.
// State with a screenshot, the headline case.
export const StateWithScreenshot: Story = {
name: "State · with screenshot",
render: () => ({
components: { AssetPreview },
setup: () => ({
asset: makeState(
"before_final_boss.state",
"https://placehold.co/640x360/4a1a1a/ffffff?text=Before+Boss",
),
}),
template: `<AssetPreview :asset="asset" type="state" />`,
}),
args: {
asset: namedState(
"before_final_boss.state",
"https://placehold.co/640x360/4a1a1a/ffffff?text=Before+Boss",
),
type: "state",
},
};
// State that never had a screenshot — placeholder + icon.
// A state that never had a screenshot falls back to a placeholder icon.
export const StateNoScreenshot: Story = {
name: "State · no screenshot",
render: () => ({
components: { AssetPreview },
setup: () => ({
asset: makeState("forgot_screenshot.state", null),
}),
template: `<AssetPreview :asset="asset" type="state" />`,
}),
args: { asset: namedState("forgot_screenshot.state", null), type: "state" },
};
// Save selected — saves don't have screenshots; we lean on metadata.
// Saves rarely carry a screenshot, so the preview leans on metadata.
export const SaveSelected: Story = {
name: "Save · selected",
render: () => ({
components: { AssetPreview },
setup: () => ({ asset: makeSave() }),
template: `<AssetPreview :asset="asset" type="save" />`,
}),
args: { asset: save(), type: "save" },
};
export const SaveWithScreenshot: Story = {
name: "Save · with screenshot",
render: () => ({
components: { AssetPreview },
setup: () => ({
asset: makeSave({
screenshot: {
id: 2,
rom_id: 1,
user_id: 1,
file_name: "main_quest.png",
file_name_no_tags: "main_quest.png",
file_name_no_ext: "main_quest",
file_extension: "png",
file_path: "/screenshots/snes",
download_path:
"https://placehold.co/640x360/1a3d2e/ffffff?text=Main+Quest",
} as SaveSchema["screenshot"],
}),
args: {
asset: save({
screenshot: screenshotFixture(
"https://placehold.co/640x360/1a3d2e/ffffff?text=Main+Quest",
2,
),
}),
template: `<AssetPreview :asset="asset" type="save" />`,
}),
type: "save",
},
};
// A state is armed, so the save is the write-back target, not what boots.
export const SaveAsWriteTarget: Story = {
name: "Save · write-back target",
render: () => ({
components: { AssetPreview },
setup: () => ({ asset: makeSave() }),
template: `<AssetPreview :asset="asset" type="save" state-armed />`,
}),
args: { asset: save(), type: "save", stateArmed: true },
};
export const EmptySaveWithStateArmed: Story = {
name: "Empty · no save, state armed",
render: () => ({
components: { AssetPreview },
template: `<AssetPreview :asset="null" type="save" state-armed />`,
}),
args: { asset: null, type: "save", stateArmed: true },
};
// Long filename should ellipsis cleanly.
export const LongFilename: Story = {
name: "Long filename",
render: () => ({
components: { AssetPreview },
setup: () => ({
asset: makeState(
"chrono_trigger_new_game_plus_attempt_03_post_lavos_alternate_ending.state",
"https://placehold.co/640x360/2d2147/ffffff?text=Chrono",
),
}),
template: `<AssetPreview :asset="asset" type="state" />`,
}),
args: {
asset: namedState(
"chrono_trigger_new_game_plus_attempt_03_post_lavos_alternate_ending.state",
"https://placehold.co/640x360/2d2147/ffffff?text=Chrono",
),
type: "state",
},
};
// No state selected — empty state with the start-fresh hint.
// No state selected: the empty state with the start-fresh hint.
export const EmptyNoState: Story = {
name: "Empty · no state selected",
render: () => ({
components: { AssetPreview },
template: `<AssetPreview :asset="null" type="state" />`,
}),
args: { asset: null, type: "state" },
};
export const EmptyNoSave: Story = {
name: "Empty · no save selected",
render: () => ({
components: { AssetPreview },
template: `<AssetPreview :asset="null" type="save" />`,
}),
args: { asset: null, type: "save" },
};
@@ -1,121 +1,31 @@
// Combined story for the EmulatorJS pre-game "Resume" panel — the
// middle column of the player view. Renders the same chrome as the
// real EmulatorJS view (tab switcher + AssetPreview + AssetStrip)
// against rich mock fixtures so the design can be evaluated end to
// end without spinning up a ROM.
// The EmulatorJS pre-game "Resume" panel (tab switcher, AssetPreview and the
// save list or state strip), composed the way the player view renders it.
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { RCard, RSliderBtnGroup } from "@v2/lib";
import type { SliderBtnGroupItem } from "@v2/lib/primitives/RSliderBtnGroup/types";
import { computed, ref } from "vue";
import type { SaveSchema, StateSchema } from "@/__generated__";
import { makeSave, manyStates } from "@/v2/utils/saveStates.fixtures";
import AssetList from "../shared/AssetList.vue";
import AssetStrip from "../shared/AssetStrip.vue";
import AssetPreview from "./AssetPreview.vue";
// ── Fixture builders ─────────────────────────────────────────────
function makeSave(i: number, overrides: Partial<SaveSchema> = {}): SaveSchema {
const slots = [
"main_quest",
"side_quest",
"speedrun_attempt",
"boss_rush",
"casual_run",
"challenge_mode",
"ng_plus",
"completionist",
"any_percent",
"post_credits",
];
const now = new Date("2026-05-25T20:00:00Z").getTime();
return {
id: i + 1,
rom_id: 1,
user_id: 1,
file_name: `${slots[i % slots.length]}.srm`,
file_name_no_tags: `${slots[i % slots.length]}.srm`,
file_name_no_ext: slots[i % slots.length],
file_extension: "srm",
file_path: "/saves/snes",
file_size_bytes: 4 * 1024 + i * 3 * 1024,
full_path: `/saves/snes/${slots[i % slots.length]}.srm`,
download_path: `/api/saves/${i + 1}/content`,
missing_from_fs: false,
created_at: new Date(now - (i + 2) * 86400 * 1000).toISOString(),
updated_at: new Date(now - (i + 1) * 4 * 3600 * 1000).toISOString(),
emulator: i % 2 === 0 ? "snes9x" : null,
screenshot: null,
...overrides,
} as SaveSchema;
}
const shots = [
{ color: "2d2147", label: "Overworld" },
{ color: "1a3d2e", label: "Forest" },
{ color: "4a1a1a", label: "Boss+Fight" },
{ color: "0a3a5a", label: "Underwater+Cave" },
{ color: "5a3a0a", label: "Desert+Temple" },
{ color: "3a0a4a", label: "Castle+Tower" },
{ color: "0a5a3a", label: "Lake+Shore" },
{ color: "5a0a3a", label: "Volcano" },
{ color: "1a1a5a", label: "Sky+Realm" },
{ color: "5a5a0a", label: "Final+Boss" },
const saveSlots = [
"main_quest",
"side_quest",
"speedrun_attempt",
"boss_rush",
"casual_run",
];
function makeState(
i: number,
withShot: boolean,
overrides: Partial<StateSchema> = {},
): StateSchema {
const deltas = [
2 * 3600,
5 * 3600,
24 * 3600,
2 * 86400,
4 * 86400,
7 * 86400,
14 * 86400,
30 * 86400,
60 * 86400,
180 * 86400,
];
const now = new Date("2026-05-25T20:00:00Z").getTime();
const shot = shots[i % shots.length];
return {
id: i + 1,
rom_id: 1,
user_id: 1,
file_name: `${shot.label.replace("+", " ").toLowerCase()}_${i + 1}.state`,
file_name_no_tags: `${shot.label.replace("+", " ").toLowerCase()}_${i + 1}.state`,
file_name_no_ext: `${shot.label.replace("+", " ").toLowerCase()}_${i + 1}`,
file_extension: "state",
file_path: "/states/snes",
file_size_bytes: 256 * 1024 + i * 73 * 1024,
full_path: `/states/snes/state_${i + 1}.state`,
download_path: `/api/states/${i + 1}/content`,
missing_from_fs: false,
created_at: new Date(now - (i + 2) * 86400 * 1000).toISOString(),
updated_at: new Date(now - deltas[i % deltas.length] * 1000).toISOString(),
emulator: i % 3 === 0 ? "snes9x" : i % 3 === 1 ? "mesen" : null,
screenshot: withShot
? ({
id: i + 1,
rom_id: 1,
user_id: 1,
file_name: `state_${i + 1}.png`,
file_name_no_tags: `state_${i + 1}.png`,
file_name_no_ext: `state_${i + 1}`,
file_extension: "png",
file_path: "/screenshots/snes",
download_path: `https://placehold.co/640x360/${shot.color}/ffffff?text=${shot.label}`,
} as StateSchema["screenshot"])
: null,
...overrides,
} as StateSchema;
function slotSaves(count: number): SaveSchema[] {
return Array.from({ length: count }).map((_, i) =>
makeSave(i + 1, saveSlots[i % saveSlots.length], (i + 1) * 4, {
emulator: i % 2 === 0 ? "snes9x" : null,
}),
);
}
// ── Meta ─────────────────────────────────────────────────────────
type AssetTab = "save" | "state";
interface Args {
@@ -145,19 +55,21 @@ export default meta;
type Story = StoryObj<Args>;
// Shared render function — mirrors the structure inside
// `EmulatorJS.vue` so the story shows the real composition.
// Mirrors the structure inside `EmulatorJS.vue`.
function renderPanel(
saves: SaveSchema[],
states: StateSchema[],
tab: AssetTab,
selectFirst = true,
) {
return {
components: { AssetList, AssetPreview, AssetStrip, RCard, RSliderBtnGroup },
setup() {
const activeTab = ref<AssetTab>(tab);
const selectedSaveId = ref<number | null>(saves[0]?.id ?? null);
const selectedStateId = ref<number | null>(states[0]?.id ?? null);
const first = (list: { id: number }[]) =>
selectFirst ? (list[0]?.id ?? null) : null;
const selectedSaveId = ref<number | null>(first(saves));
const selectedStateId = ref<number | null>(first(states));
const tabs = computed<SliderBtnGroupItem<AssetTab>[]>(() => [
{
@@ -291,78 +203,52 @@ function renderPanel(
};
}
// ── Stories ──────────────────────────────────────────────────────
// 8 states + 3 saves — the "rich" case. State tab opens by default.
// 8 states and 3 saves, the rich case. The state tab opens by default.
export const RichLibrary: Story = {
name: "Rich · 8 states + 3 saves",
render: () => {
const states = Array.from({ length: 8 }).map((_, i) => makeState(i, true));
const saves = Array.from({ length: 3 }).map((_, i) => makeSave(i));
return renderPanel(saves, states, "state");
},
render: () => renderPanel(slotSaves(3), manyStates(8), "state"),
};
// 15 states — overflow scenario. Strip scrolls horizontally.
// 15 states overflow the strip, which scrolls horizontally.
export const ManyStates: Story = {
name: "Many states · 15 (overflow)",
render: () => {
const states = Array.from({ length: 15 }).map((_, i) => makeState(i, true));
return renderPanel([], states, "state");
},
render: () => renderPanel([], manyStates(15), "state"),
};
// Mixed — some states have screenshots, some don't.
export const MixedScreenshots: Story = {
name: "States · mixed (with + without screenshots)",
render: () => {
const states = Array.from({ length: 6 }).map((_, i) =>
makeState(i, i % 2 === 0),
const states = manyStates(6).map((s, i) =>
i % 2 === 0 ? s : { ...s, screenshot: null },
);
return renderPanel([], states, "state");
},
};
// Saves only — old SNES-style 3 save slots, no states yet.
// Three save slots and no states yet.
export const SavesOnly: Story = {
name: "Saves only · 3 slots",
render: () => {
const saves = Array.from({ length: 3 }).map((_, i) => makeSave(i));
return renderPanel(saves, [], "save");
},
render: () => renderPanel(slotSaves(3), [], "save"),
};
// Single save — one save slot, no states.
export const SingleSave: Story = {
name: "Single save",
render: () => renderPanel([makeSave(0)], [], "save"),
render: () => renderPanel(slotSaves(1), [], "save"),
};
// First-time launch — no saves, no states.
// First launch: no saves, no states.
export const FreshGame: Story = {
name: "Fresh game · no saves, no states",
render: () => renderPanel([], [], "state"),
};
// State tab with no compatible states (e.g. core changed).
// The state tab after a core swap left no compatible states.
export const StatesEmptyAfterCoreChange: Story = {
name: "States · empty after core swap",
render: () => {
const saves = Array.from({ length: 4 }).map((_, i) => makeSave(i));
return renderPanel(saves, [], "state");
},
render: () => renderPanel(slotSaves(4), [], "state"),
};
// User just deselected — nothing picked but plenty available.
export const NoneSelectedManyAvailable: Story = {
name: "Many states · none selected",
render: () => {
const states = Array.from({ length: 6 }).map((_, i) => makeState(i, true));
// Force-clear default selection via tab switch trick — the render
// helper picks the first as default, so we leverage the existing
// clear() path mentally; visually we get a non-empty strip with a
// selected one. This story doubles as a sanity check that the
// preview reads OK even when the first item is highlighted.
return renderPanel([], states, "state");
},
render: () => renderPanel([], manyStates(6), "state", false),
};
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { makeSave, makeState } from "@/v2/utils/saveStates.fixtures";
import AssetActions from "./AssetActions.vue";
// Which buttons render for each case is covered by AssetActions.test.ts.
const meta: Meta<typeof AssetActions> = {
title: "Shared/AssetActions",
component: AssetActions,
decorators: [
() => ({
template: `
<div style="
display: flex;
gap: 4px;
padding: 16px;
background: var(--r-color-bg-elevated);
">
<story />
</div>
`,
}),
],
};
export default meta;
type Story = StoryObj<typeof AssetActions>;
export const OwnSave: Story = {
name: "Own · save",
args: { asset: makeSave(1, "autosave", 1), type: "save", own: true },
};
export const OwnFavoriteState: Story = {
name: "Own · favorite state",
args: {
asset: makeState({ id: 2, is_favorite: true }),
type: "state",
own: true,
},
};
export const OwnFavoriting: Story = {
name: "Own · favoriting",
args: {
asset: makeSave(3, "speedrun", 3),
type: "save",
own: true,
favoriting: true,
},
};
export const Community: Story = {
name: "Community · download only",
args: { asset: makeState({ id: 4 }), type: "state" },
};
@@ -1,5 +1,5 @@
<script setup lang="ts">
// The per-item buttons of the Save data tab: download for everything, then
// The per-item buttons of a save or state row: download for everything, then
// edit, favorite and delete for own saves and states.
import { RBtn } from "@v2/lib";
import { computed } from "vue";
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { makeSave } from "@/v2/utils/saveStates.fixtures";
import AssetChips from "./AssetChips.vue";
// Chip content per prop is covered by AssetChips.test.ts.
const meta: Meta<typeof AssetChips> = {
title: "Shared/AssetChips",
component: AssetChips,
decorators: [
() => ({
template: `
<div style="padding: 16px; background: var(--r-color-bg-elevated);">
<story />
</div>
`,
}),
],
};
export default meta;
type Story = StoryObj<typeof AssetChips>;
export const Default: Story = {
name: "Emulator + size",
args: { asset: makeSave(1, "main_quest", 2) },
};
export const Latest: Story = {
name: "Latest tag",
args: { asset: makeSave(1, "autosave", 1), latest: true },
};
export const NoEmulator: Story = {
name: "No emulator on asset",
args: { asset: makeSave(1, null, 5, { emulator: null }) },
};
export const HideEmulatorChip: Story = {
name: "showEmulator false",
args: { asset: makeSave(1, "main_quest", 2), showEmulator: false },
};
@@ -1,79 +1,25 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { expect, userEvent, within } from "storybook/test";
import { ref } from "vue";
import type { SaveSchema, ScreenshotSchema } from "@/__generated__";
import AssetActions from "@/v2/components/shared/AssetActions.vue";
import type { Asset, AssetType } from "@/v2/utils/assets";
import {
IDENTICAL_STATE_PREFIX,
identicalPrefixStates,
makeSave,
makeSaveSlot,
manyStates,
saveScreenshot,
saveSlotLibrary,
toUserSave,
} from "@/v2/utils/saveStates.fixtures";
import {
deleteButtons,
downloadButtons,
selectableItems,
} from "@/v2/utils/saveStates.plays";
import AssetList from "./AssetList.vue";
// ── Mock builders ────────────────────────────────────────────────
const NOW = new Date("2026-05-25T20:00:00Z").getTime();
const HOUR = 3600 * 1000;
// 16:9 SVG stand-in for the screenshot a browser-player save carries.
function shot(hue: number): ScreenshotSchema {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='64' height='36'><rect width='64' height='36' fill='hsl(${hue} 60% 40%)'/><rect x='8' y='8' width='48' height='20' fill='hsl(${hue} 70% 65%)'/></svg>`;
return {
download_path: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`,
} as ScreenshotSchema;
}
// One version in `slot`, `hoursAgo` old. Pass `slot: null` for an archive.
function makeSave(
id: number,
slot: string | null,
hoursAgo: number,
overrides: Partial<SaveSchema> = {},
): SaveSchema {
const at = new Date(NOW - hoursAgo * HOUR).toISOString();
// Slotted uploads carry the backend's datetime tag; archives keep their name.
const stem = slot
? `chrono_trigger [${at.slice(0, 19).replace("T", "_").replace(/:/g, "-")}]`
: `chrono_trigger_backup_${id}`;
return {
id,
rom_id: 1,
user_id: 1,
file_name: `${stem}.srm`,
file_name_no_tags: "chrono_trigger.srm",
file_name_no_ext: stem,
file_extension: "srm",
file_path: "/saves/snes",
file_size_bytes: 8 * 1024,
full_path: `/saves/snes/${stem}.srm`,
download_path: `/api/saves/${id}/content`,
missing_from_fs: false,
created_at: at,
updated_at: at,
emulator: "snes9x",
slot,
screenshot: null,
...overrides,
} as SaveSchema;
}
// A slot with `count` versions from `firstId`, newest `hoursAgo` old.
function makeSlot(
slot: string,
count: number,
hoursAgo: number,
firstId: number,
): SaveSchema[] {
return Array.from({ length: count }).map((_, i) =>
makeSave(firstId + i, slot, hoursAgo + i * 26, {
screenshot: shot((i * 47 + slot.length * 31) % 360),
}),
);
}
function library(): SaveSchema[] {
return [
...makeSlot("autosave", 4, 1, 1),
...makeSlot("main_quest", 6, 30, 5),
...makeSlot("speedrun", 1, 200, 11),
makeSave(12, null, 500),
makeSave(13, null, 900, { emulator: null }),
];
}
const meta: Meta<typeof AssetList> = {
title: "Shared/AssetList",
component: AssetList,
@@ -97,47 +43,72 @@ const meta: Meta<typeof AssetList> = {
export default meta;
type Story = StoryObj<typeof AssetList>;
function selectable(saves: SaveSchema[], selected: number | null) {
function selectableList(
assets: Asset[],
type: AssetType,
selected: number | null,
extra: Partial<InstanceType<typeof AssetList>["$props"]> = {},
) {
return {
components: { AssetList },
setup() {
const selectedId = ref<number | null>(selected);
return {
saves,
assets,
type,
selectedId,
onSelect: (a: SaveSchema) => (selectedId.value = a.id),
extra,
onSelect: (a: Asset) => (selectedId.value = a.id),
};
},
template: `
<AssetList :assets="saves" type="save" :selected-id="selectedId" @select="onSelect" />
<AssetList
:assets="assets"
:type="type"
:selected-id="selectedId"
v-bind="extra"
@select="onSelect"
/>
`,
};
}
// ── Stories ──────────────────────────────────────────────────────
// Autosave history, two named slots and two archives: the full slot model.
export const SlotLibrary: Story = {
name: "Slots · autosave, named, archive",
name: "Saves · slots (selectable)",
render: () => {
const saves = library();
return selectable(saves, saves[0].id);
const saves = saveSlotLibrary();
return selectableList(saves, "save", saves[0].id);
},
play: async ({ canvasElement, step }) => {
const ui = within(canvasElement);
await step("named slot groups are visible", async () => {
expect(ui.getByText("autosave")).toBeTruthy();
expect(ui.getByText("main_quest")).toBeTruthy();
});
await step("clicking a row updates selection", async () => {
const target = selectableItems(canvasElement).find(
(r) => r.getAttribute("aria-pressed") === "false",
);
expect(target).toBeTruthy();
await userEvent.click(target!);
expect(target).toHaveAttribute("aria-pressed", "true");
});
},
};
// Selecting an older version unfolds its slot so the pick stays visible.
export const OlderVersionSelected: Story = {
name: "Older version selected",
name: "Saves · older version selected",
render: () => {
const saves = library();
const saves = saveSlotLibrary();
const olderMainQuest = saves.filter((s) => s.slot === "main_quest")[3];
return selectable(saves, olderMainQuest.id);
return selectableList(saves, "save", olderMainQuest.id);
},
};
// Only manual uploads, no screenshots: the pre-slot shape of a library.
export const ArchiveOnly: Story = {
name: "Archive only (no slots)",
name: "Saves · archive only",
render: () => {
const saves = [
makeSave(1, null, 3),
@@ -147,44 +118,188 @@ export const ArchiveOnly: Story = {
}),
makeSave(3, null, 400, { emulator: null }),
];
return selectable(saves, null);
return selectableList(saves, "save", null);
},
};
// Flat list ordered by upload time, as the player's stream picker shows it.
export const StreamArchives: Story = {
name: "Saves · stream (created, flat)",
render: () => {
const saves = [
makeSave(1, null, 10, { screenshot: saveScreenshot(120) }),
makeSave(2, null, 20),
makeSave(3, null, 30, { screenshot: saveScreenshot(200) }),
];
return selectableList(saves, "save", saves[0].id, {
timestamp: "created",
groupBySlot: false,
});
},
};
// One slot, one version, the most common case for new players.
export const SingleSave: Story = {
name: "Single save",
name: "Saves · single",
render: () => {
const saves = makeSlot("autosave", 1, 2, 1);
return selectable(saves, saves[0].id);
const saves = makeSaveSlot("autosave", 1, 2, 1);
return selectableList(saves, "save", saves[0].id);
},
};
export const StatesSelectable: Story = {
name: "States · selectable",
render: () => {
const states = manyStates(5);
return selectableList(states, "state", states[0].id);
},
};
export const IdenticalPrefixStates: Story = {
name: "States · identical prefix",
render: () => {
const states = identicalPrefixStates(4);
return selectableList(states, "state", states[0].id);
},
play: async ({ canvasElement, step }) => {
await step("each row keeps the full filename in the DOM", async () => {
const rows = selectableItems(canvasElement);
expect(rows).toHaveLength(4);
for (const el of rows) {
expect(el.textContent).toContain(IDENTICAL_STATE_PREFIX);
}
});
},
};
// Management mode: static rows hosting the actions slot.
export const Manage: Story = {
name: "Manage (actions slot)",
export const ManageSaves: Story = {
name: "Saves · manage + actions",
render: () => ({
components: { AssetList },
components: { AssetList, AssetActions },
setup() {
return { saves: library() };
return { saves: saveSlotLibrary() };
},
template: `
<AssetList :assets="saves" type="save" :selectable="false" :scrollable="false">
<template #actions="{ asset }">
<span style="font-size: 10px; color: var(--r-color-fg-muted)">#{{ asset.id }}</span>
<AssetActions :asset="asset" type="save" own />
</template>
</AssetList>
`,
}),
play: async ({ canvasElement, step }) => {
await step(
"manage rows are list items, not selectable buttons",
async () => {
const ui = within(canvasElement);
const rows = ui.getAllByRole("listitem");
expect(
rows.some((r) => r.textContent?.includes("chrono_trigger")),
).toBe(true);
expect(
ui.queryAllByRole("button", { name: /^chrono_trigger/ }),
).toEqual([]);
},
);
await step("own-item actions include download and delete", async () => {
expect(downloadButtons(canvasElement).length).toBeGreaterThan(0);
expect(deleteButtons(canvasElement).length).toBeGreaterThan(0);
});
},
};
export const ManageStates: Story = {
name: "States · manage + actions",
render: () => ({
components: { AssetList, AssetActions },
setup() {
return { states: identicalPrefixStates(3) };
},
template: `
<AssetList :assets="states" type="state" :selectable="false" :scrollable="false">
<template #actions="{ asset }">
<AssetActions :asset="asset" type="state" own />
</template>
</AssetList>
`,
}),
};
// Empty — distinct from "no save selected".
export const Empty: Story = {
name: "Empty (no saves)",
// Other users' public saves show an owner chip and only a download action.
export const CommunitySaves: Story = {
name: "Saves · community (show owner)",
play: async ({ canvasElement, step }) => {
const ui = within(canvasElement);
await step("community author chips render", async () => {
expect(ui.getByText("speedrunner42")).toBeTruthy();
expect(ui.getByText("archivist")).toBeTruthy();
});
await step("community rows offer download only", async () => {
expect(downloadButtons(canvasElement).length).toBe(2);
expect(ui.queryByRole("button", { name: /^Delete /i })).toBeNull();
});
},
render: () => ({
components: { AssetList, AssetActions },
setup() {
const saves = [
toUserSave(makeSave(50, "route_a", 12), "speedrunner42", {
user_id: 2,
}),
toUserSave(makeSave(51, "route_b", 24), "archivist", {
user_id: 3,
}),
];
return { saves };
},
template: `
<AssetList
:assets="saves"
type="save"
:selectable="false"
:scrollable="false"
show-owner
>
<template #actions="{ asset }">
<AssetActions :asset="asset" type="save" />
</template>
</AssetList>
`,
}),
};
// Empty, distinct from "no save selected".
export const EmptySaves: Story = {
name: "Empty · saves",
render: () => ({
components: { AssetList },
template: `
<AssetList :assets="[]" type="save" :selected-id="null" />
`,
}),
play: async ({ canvasElement, step }) => {
await step("empty saves message", async () => {
expect(
within(canvasElement).getByText("No saves available"),
).toBeTruthy();
});
},
};
export const EmptyStates: Story = {
name: "Empty · states",
render: () => ({
components: { AssetList },
template: `
<AssetList :assets="[]" type="state" :selected-id="null" />
`,
}),
play: async ({ canvasElement, step }) => {
await step("empty states message", async () => {
expect(
within(canvasElement).getByText("No states available"),
).toBeTruthy();
});
},
};
@@ -1,98 +1,18 @@
// AssetStrip is the horizontal card variant used for STATES only —
// saves render through <AssetList> (vertical rows). Stories here are
// state-focused; see AssetList.stories.ts for the save scenarios.
// AssetStrip is the card variant for states; saves render through <AssetList>.
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { expect, userEvent, within } from "storybook/test";
import { ref } from "vue";
import type { StateSchema } from "@/__generated__";
import AssetActions from "@/v2/components/shared/AssetActions.vue";
import {
makeState,
manyStates,
mixedCommunityStates,
screenshotFixture,
} from "@/v2/utils/saveStates.fixtures";
import { downloadButtons, selectableItems } from "@/v2/utils/saveStates.plays";
import AssetStrip from "./AssetStrip.vue";
// ── Mock builders ────────────────────────────────────────────────
function makeState(overrides: Partial<StateSchema> = {}): StateSchema {
const base = {
id: 1,
rom_id: 1,
user_id: 1,
file_name: "state_1.state",
file_name_no_tags: "state_1.state",
file_name_no_ext: "state_1",
file_extension: "state",
file_path: "/states/snes",
file_size_bytes: 524288,
full_path: "/states/snes/state_1.state",
download_path: "/api/states/1/content",
missing_from_fs: false,
created_at: "2026-04-02T09:00:00Z",
updated_at: "2026-05-13T22:08:00Z",
emulator: "snes9x",
screenshot: {
id: 1,
rom_id: 1,
user_id: 1,
file_name: "state_1.png",
file_name_no_tags: "state_1.png",
file_name_no_ext: "state_1",
file_extension: "png",
file_path: "/screenshots/snes",
download_path:
"https://placehold.co/640x360/2d2147/ffffff?text=Save+State",
} as StateSchema["screenshot"],
};
return { ...base, ...overrides } as StateSchema;
}
// Distinct placeholder shots so we can scan the strip visually.
const shots: { color: string; label: string }[] = [
{ color: "2d2147", label: "Overworld" },
{ color: "1a3d2e", label: "Forest" },
{ color: "4a1a1a", label: "Boss+Fight" },
{ color: "0a3a5a", label: "Cave" },
{ color: "5a3a0a", label: "Desert" },
{ color: "3a0a4a", label: "Castle" },
{ color: "0a5a3a", label: "Lake" },
{ color: "5a0a3a", label: "Volcano" },
{ color: "1a1a5a", label: "Sky" },
{ color: "5a5a0a", label: "Tower" },
];
function manyStates(n: number, withScreenshots = true): StateSchema[] {
const now = new Date("2026-05-25T20:00:00Z").getTime();
const deltas = [
2 * 3600,
5 * 3600,
24 * 3600,
2 * 86400,
4 * 86400,
7 * 86400,
14 * 86400,
30 * 86400,
60 * 86400,
180 * 86400,
];
return Array.from({ length: n }).map((_, i) => {
const shot = shots[i % shots.length];
return makeState({
id: i + 1,
file_name: `${shot.label.replace("+", " ").toLowerCase()}_${i + 1}.state`,
file_size_bytes: 256 * 1024 + i * 73 * 1024,
updated_at: new Date(
now - deltas[i % deltas.length] * 1000,
).toISOString(),
screenshot: withScreenshots
? ({
...(makeState().screenshot as NonNullable<
StateSchema["screenshot"]
>),
download_path: `https://placehold.co/640x360/${shot.color}/ffffff?text=${shot.label}`,
} as StateSchema["screenshot"])
: null,
emulator: i % 3 === 0 ? "snes9x" : i % 3 === 1 ? "mesen" : null,
});
});
}
// ── Meta ─────────────────────────────────────────────────────────
const meta: Meta<typeof AssetStrip> = {
title: "Shared/AssetStrip",
component: AssetStrip,
@@ -117,180 +37,134 @@ export default meta;
type Story = StoryObj<typeof AssetStrip>;
// ── Stories ──────────────────────────────────────────────────────
function selectableStrip(
states: StateSchema[],
selected: number | null,
extra: Partial<InstanceType<typeof AssetStrip>["$props"]> = {},
) {
return {
components: { AssetStrip },
setup() {
const selectedId = ref<number | null>(selected);
return {
states,
selectedId,
extra,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip
:assets="states"
type="state"
:selected-id="selectedId"
v-bind="extra"
@select="onSelect"
/>
`,
};
}
// Five states with screenshots — the headline case. Notice how the
// selected tile carries the brand ring + check badge.
// Five states with screenshots, the headline case. The selected tile
// carries the brand ring and check badge.
export const FewStatesScreenshots: Story = {
name: "States · 5 with screenshots",
render: () => ({
components: { AssetStrip },
setup() {
const states = manyStates(5);
const selectedId = ref<number | null>(states[0].id);
return {
states,
selectedId,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip :assets="states" type="state" :selected-id="selectedId" @select="onSelect" />
`,
}),
render: () => {
const states = manyStates(5);
return selectableStrip(states, states[0].id);
},
play: async ({ canvasElement, step }) => {
await step("state tiles render with filenames", async () => {
expect(selectableItems(canvasElement).length).toBe(5);
expect(canvasElement.textContent).toContain("overworld_1.state");
});
await step("clicking a tile selects it", async () => {
const tiles = selectableItems(canvasElement);
const target = tiles.find(
(t) => t.getAttribute("aria-pressed") === "false",
);
expect(target).toBeTruthy();
await userEvent.click(target!);
expect(target).toHaveAttribute("aria-pressed", "true");
});
},
};
// Twelve states — overflow case. Tiles scroll horizontally with snap.
// Twelve states overflow; tiles scroll horizontally with snap.
export const ManyStatesOverflow: Story = {
name: "States · 12 (horizontal scroll)",
render: () => ({
components: { AssetStrip },
setup() {
const states = manyStates(12);
const selectedId = ref<number | null>(states[4].id);
return {
states,
selectedId,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip :assets="states" type="state" :selected-id="selectedId" @select="onSelect" />
`,
}),
render: () => {
const states = manyStates(12);
return selectableStrip(states, states[4].id);
},
};
// Grid and list: a long save-state history, where the horizontal strip
// buries the older entries behind a scroll.
// Grid and list: a long history, where the horizontal strip buries the
// older entries behind a scroll.
export const ManyStatesGrid: Story = {
name: "States \u00b7 30 (grid layout)",
render: () => ({
components: { AssetStrip },
setup() {
const states = manyStates(30);
const selectedId = ref<number | null>(states[0].id);
return {
states,
selectedId,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip :assets="states" type="state" :selected-id="selectedId" layout="grid" @select="onSelect" />
`,
}),
name: "States · 30 (grid layout)",
render: () => {
const states = manyStates(30);
return selectableStrip(states, states[0].id, { layout: "grid" });
},
};
export const ManyStatesList: Story = {
name: "States \u00b7 30 (list layout)",
render: () => ({
components: { AssetStrip },
setup() {
const states = manyStates(30);
const selectedId = ref<number | null>(states[0].id);
return {
states,
selectedId,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip :assets="states" type="state" :selected-id="selectedId" layout="list" @select="onSelect" />
`,
}),
name: "States · 30 (list layout)",
render: () => {
const states = manyStates(30);
return selectableStrip(states, states[0].id, { layout: "list" });
},
};
// States that never had a screenshot taken — gradient fallback with
// the file icon. Still readable; the row doesn't feel broken.
// States without a screenshot fall back to a gradient with the file icon.
export const StatesNoScreenshots: Story = {
name: "States · 6 without screenshots",
render: () => ({
components: { AssetStrip },
setup() {
const states = manyStates(6, false);
const selectedId = ref<number | null>(states[2].id);
return {
states,
selectedId,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip :assets="states" type="state" :selected-id="selectedId" @select="onSelect" />
`,
}),
render: () => {
const states = manyStates(6).map((s) => ({ ...s, screenshot: null }));
return selectableStrip(states, states[2].id);
},
};
// Long filenames should ellipsis cleanly without breaking the row.
export const LongFilenames: Story = {
name: "Long filenames (ellipsis)",
render: () => ({
components: { AssetStrip },
setup() {
const states = [
makeState({
id: 1,
file_name: "the_legend_of_zelda_a_link_to_the_past_speedrun_27.state",
screenshot: {
...(makeState().screenshot as NonNullable<
StateSchema["screenshot"]
>),
download_path:
"https://placehold.co/640x360/2d2147/ffffff?text=LTTP",
} as StateSchema["screenshot"],
}),
makeState({
id: 2,
file_name:
"chrono_trigger_new_game_plus_attempt_third_run_boss_room.state",
screenshot: {
...(makeState().screenshot as NonNullable<
StateSchema["screenshot"]
>),
download_path:
"https://placehold.co/640x360/4a1a1a/ffffff?text=CT+NG%2B",
} as StateSchema["screenshot"],
}),
makeState({
id: 3,
file_name: "super_mario_world_special_world_complete_run.state",
screenshot: null,
}),
];
const selectedId = ref<number | null>(states[0].id);
return {
states,
selectedId,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip :assets="states" type="state" :selected-id="selectedId" @select="onSelect" />
`,
}),
render: () => {
const states = [
makeState({
id: 1,
file_name: "the_legend_of_zelda_a_link_to_the_past_speedrun_27.state",
screenshot: screenshotFixture(
"https://placehold.co/640x360/2d2147/ffffff?text=LTTP",
1,
),
}),
makeState({
id: 2,
file_name:
"chrono_trigger_new_game_plus_attempt_third_run_boss_room.state",
screenshot: screenshotFixture(
"https://placehold.co/640x360/4a1a1a/ffffff?text=CT+NG%2B",
2,
),
}),
makeState({
id: 3,
file_name: "super_mario_world_special_world_complete_run.state",
screenshot: null,
}),
];
return selectableStrip(states, states[0].id);
},
};
// Nothing selected yet — still shows a strip the user can click.
// Nothing selected yet; the strip is still clickable.
export const NoneSelected: Story = {
name: "States · none selected",
render: () => ({
components: { AssetStrip },
setup() {
const states = manyStates(4);
const selectedId = ref<number | null>(null);
return {
states,
selectedId,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip :assets="states" type="state" :selected-id="selectedId" @select="onSelect" />
`,
}),
render: () => selectableStrip(manyStates(4), null),
};
// Empty — distinct from "no asset selected"; the strip itself is empty.
// Empty, distinct from "no asset selected".
export const EmptyStates: Story = {
name: "Empty (no states)",
render: () => ({
@@ -299,75 +173,109 @@ export const EmptyStates: Story = {
<AssetStrip :assets="[]" type="state" :selected-id="null" />
`,
}),
play: async ({ canvasElement, step }) => {
await step("empty states message", async () => {
expect(
within(canvasElement).getByText("No states available"),
).toBeTruthy();
});
},
};
// States from another emulator stay listed, dimmed, but cannot be picked.
export const IncompatibleStates: Story = {
name: "States · 6, half from another emulator",
render: () => ({
components: { AssetStrip },
setup() {
const states = manyStates(6).map((state, i) => ({
...state,
emulator: i % 2 === 0 ? "snes9x" : "builtin",
}));
const selectedId = ref<number | null>(states[0].id);
const disabledReason = (asset: { emulator?: string | null }) =>
asset.emulator === "snes9x"
? null
: `Saved with ${asset.emulator}, which the selected core cannot load.`;
return {
states,
selectedId,
disabledReason,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
},
template: `
<AssetStrip
:assets="states"
type="state"
:selected-id="selectedId"
:disabled-reason="disabledReason"
@select="onSelect"
/>
`,
}),
render: () => {
const states = manyStates(6).map((state, i) => ({
...state,
emulator: i % 2 === 0 ? "snes9x" : "builtin",
}));
const disabledReason = (asset: { emulator?: string | null }) =>
asset.emulator === "snes9x"
? null
: `Saved with ${asset.emulator}, which the selected core cannot load.`;
return selectableStrip(states, states[0].id, { disabledReason });
},
};
// One collapsible mini grid per core. The core that cannot load starts
// closed and its tiles are greyed out when opened.
export const GroupedByCore: Story = {
name: "States · grouped by core",
render: () => {
const states = manyStates(9).map((state, i) => ({
...state,
emulator: i % 3 === 0 ? "snes9x" : i % 3 === 1 ? "builtin" : null,
}));
const disabledReason = (asset: { emulator?: string | null }) =>
asset.emulator === "builtin"
? "Saved with builtin, which the selected core cannot load."
: null;
return selectableStrip(states, states[0].id, {
layout: "flow",
groupBy: "emulator",
disabledReason,
});
},
};
// Save data management: static tiles grouped by core, hosting the actions slot.
export const ManageFlowGrouped: Story = {
name: "Manage · flow + grouped (Save data)",
render: () => ({
components: { AssetStrip },
components: { AssetStrip, AssetActions },
setup() {
const states = manyStates(9).map((state, i) => ({
...state,
emulator: i % 3 === 0 ? "snes9x" : i % 3 === 1 ? "builtin" : null,
}));
const selectedId = ref<number | null>(states[0].id);
const disabledReason = (asset: { emulator?: string | null }) =>
asset.emulator === "builtin"
? "Saved with builtin, which the selected core cannot load."
: null;
return {
states,
selectedId,
disabledReason,
onSelect: (a: StateSchema) => (selectedId.value = a.id),
};
return { states: manyStates(6) };
},
template: `
<AssetStrip
:assets="states"
type="state"
:selectable="false"
layout="flow"
group-by="emulator"
:selected-id="selectedId"
:disabled-reason="disabledReason"
@select="onSelect"
/>
>
<template #actions="{ asset }">
<AssetActions :asset="asset" type="state" own />
</template>
</AssetStrip>
`,
}),
play: async ({ canvasElement, step }) => {
const ui = within(canvasElement);
await step("core group headings appear", async () => {
expect(ui.getByRole("button", { name: /snes9x/i })).toBeTruthy();
});
await step("static tiles host per-item actions", async () => {
const staticTiles = within(canvasElement).getAllByRole("listitem");
expect(staticTiles.length).toBeGreaterThan(0);
expect(downloadButtons(canvasElement).length).toBeGreaterThan(0);
});
},
};
// Other users' public states show an owner chip and only a download action.
export const ManageCommunity: Story = {
name: "Manage · community + show owner",
render: () => ({
components: { AssetStrip, AssetActions },
setup() {
const states = mixedCommunityStates().filter((s) => s.user_id !== 1);
return { states };
},
template: `
<AssetStrip
:assets="states"
type="state"
:selectable="false"
layout="flow"
group-by="emulator"
show-owner
>
<template #actions="{ asset }">
<AssetActions :asset="asset" type="state" />
</template>
</AssetStrip>
`,
}),
};
@@ -122,3 +122,15 @@ describe("AssetStrip timestamp", () => {
).toHaveLength(4);
});
});
describe("AssetStrip roles", () => {
it("lists static tiles and leaves selectable tiles as toggle buttons", () => {
const managed = mountStrip({ selectable: false });
expect(managed.findAll('[role="list"]')).toHaveLength(1);
expect(managed.findAll('[role="listitem"]')).toHaveLength(4);
const picker = mountStrip();
expect(picker.findAll('[role="list"], [role="listitem"]')).toHaveLength(0);
expect(picker.findAll("button[aria-pressed]")).toHaveLength(4);
});
});
@@ -193,12 +193,18 @@ const fadeIndex = computed(() =>
<RExpandTransition>
<div v-show="isOpen(group)" class="r-asset-strip__fold">
<div class="r-asset-strip__track">
<!-- Static tiles host their own actions, so they read as a list;
selectable tiles are toggle buttons instead. -->
<div
class="r-asset-strip__track"
:role="selectable ? undefined : 'list'"
>
<component
:is="selectable ? 'button' : 'div'"
v-for="asset in group.assets"
:key="asset.id"
:type="selectable ? 'button' : undefined"
:role="selectable ? undefined : 'listitem'"
class="r-asset-strip__tile r-v2-asset-fade"
:class="{
'r-asset-strip__tile--active':
@@ -442,9 +448,8 @@ const fadeIndex = computed(() =>
background: transparent;
padding: 0;
flex: 0 0 140px;
/* Without min-width:0 the flex item's implicit `min-width: auto`
lets the inner nowrap filename push the tile wider than its
flex-basis — long names would visibly inflate that one card. */
/* The implicit `min-width: auto` would let a long nowrap filename push
the tile past its flex-basis. */
min-width: 0;
scroll-snap-align: start;
display: flex;
@@ -0,0 +1,254 @@
import type {
DetailedRomSchema,
SaveSchema,
ScreenshotSchema,
StateSchema,
UserSaveSchema,
UserStateSchema,
} from "@/__generated__";
import { saveFixture, stateFixture } from "@/utils/assets.fixtures";
import { makeDetailedRom } from "@/utils/rom.fixtures";
// Relative to the real clock, since AssetTimestamp formats against Date.now().
const STORY_NOW = Date.now();
const HOUR = 3600 * 1000;
export function hoursAgo(hours: number): string {
return new Date(STORY_NOW - hours * HOUR).toISOString();
}
const WRITTEN_AT = hoursAgo(12 * 24);
export const IDENTICAL_STATE_PREFIX =
"emulatorjs_chrono_trigger_usa_rev_a_super_nintendo";
export function screenshotFixture(
downloadPath: string,
id = 1,
): ScreenshotSchema {
return {
id,
rom_id: 1,
user_id: 1,
file_name: `state_shot_${id}.png`,
file_name_no_tags: `state_shot_${id}`,
file_name_no_ext: `state_shot_${id}`,
file_extension: "png",
file_path: "/states/shots",
file_size_bytes: 4096,
full_path: `/states/shots/state_shot_${id}.png`,
download_path: downloadPath,
missing_from_fs: false,
created_at: WRITTEN_AT,
updated_at: WRITTEN_AT,
};
}
// 16:9 SVG stand-in for the screenshot a browser-player save carries.
export function saveScreenshot(hue: number): ScreenshotSchema {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='64' height='36'><rect width='64' height='36' fill='hsl(${hue} 60% 40%)'/><rect x='8' y='8' width='48' height='20' fill='hsl(${hue} 70% 65%)'/></svg>`;
return screenshotFixture(
`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`,
1000 + hue,
);
}
// One version in `slot`, `age` hours old. Pass `slot: null` for an archive.
export function makeSave(
id: number,
slot: string | null,
age: number,
overrides: Partial<SaveSchema> = {},
): SaveSchema {
const at = hoursAgo(age);
// Slotted uploads carry the backend's datetime tag; archives keep their name.
const stem = slot
? `chrono_trigger [${at.slice(0, 19).replace("T", "_").replace(/:/g, "-")}]`
: `chrono_trigger_backup_${id}`;
return saveFixture({
id,
file_name: `${stem}.srm`,
file_name_no_tags: "chrono_trigger.srm",
file_name_no_ext: stem,
file_path: "/saves/snes",
file_size_bytes: 8 * 1024,
full_path: `/saves/snes/${stem}.srm`,
download_path: `/api/saves/${id}/content`,
created_at: at,
updated_at: at,
emulator: "snes9x",
slot,
...overrides,
});
}
// A slot with `count` versions from `firstId`, the newest `age` hours old.
export function makeSaveSlot(
slot: string,
count: number,
age: number,
firstId: number,
): SaveSchema[] {
return Array.from({ length: count }).map((_, i) =>
makeSave(firstId + i, slot, age + i * 26, {
screenshot: saveScreenshot((i * 47 + slot.length * 31) % 360),
}),
);
}
// Autosave history, two named slots and two archives: the full slot model.
export function saveSlotLibrary(): SaveSchema[] {
return [
...makeSaveSlot("autosave", 4, 1, 1),
...makeSaveSlot("main_quest", 6, 30, 5),
...makeSaveSlot("speedrun", 1, 200, 11),
makeSave(12, null, 500),
makeSave(13, null, 900, { emulator: null }),
];
}
export function makeState(overrides: Partial<StateSchema> = {}): StateSchema {
return stateFixture({
file_name: "state_1.state",
file_name_no_tags: "state_1.state",
file_name_no_ext: "state_1",
file_path: "/states/snes",
file_size_bytes: 524288,
full_path: "/states/snes/state_1.state",
download_path: "/api/states/1/content",
created_at: hoursAgo(53 * 24),
updated_at: WRITTEN_AT,
emulator: "snes9x",
screenshot: screenshotFixture(
"https://placehold.co/640x360/2d2147/ffffff?text=Save+State",
),
...overrides,
});
}
// Distinct placeholder shots so the strip can be scanned visually.
const stateShots: { color: string; label: string }[] = [
{ color: "2d2147", label: "Overworld" },
{ color: "1a3d2e", label: "Forest" },
{ color: "4a1a1a", label: "Boss+Fight" },
{ color: "0a3a5a", label: "Cave" },
{ color: "5a3a0a", label: "Desert" },
{ color: "3a0a4a", label: "Castle" },
{ color: "0a5a3a", label: "Lake" },
{ color: "5a0a3a", label: "Volcano" },
{ color: "1a1a5a", label: "Sky" },
{ color: "5a5a0a", label: "Tower" },
];
export function manyStates(n: number): StateSchema[] {
const ages = [
2,
5,
24,
2 * 24,
4 * 24,
7 * 24,
14 * 24,
30 * 24,
60 * 24,
180 * 24,
];
return Array.from({ length: n }).map((_, i) => {
const shot = stateShots[i % stateShots.length];
const at = hoursAgo(ages[i % ages.length]);
return makeState({
id: i + 1,
file_name: `${shot.label.replace("+", " ").toLowerCase()}_${i + 1}.state`,
file_size_bytes: 256 * 1024 + i * 73 * 1024,
created_at: at,
updated_at: at,
screenshot: screenshotFixture(
`https://placehold.co/640x360/${shot.color}/ffffff?text=${shot.label}`,
i + 1,
),
emulator: i % 3 === 0 ? "snes9x" : i % 3 === 1 ? "mesen" : null,
});
});
}
// Browser-player states share a long prefix, so only the tail tells them apart.
export function identicalPrefixStates(count: number): StateSchema[] {
return Array.from({ length: count }).map((_, i) => {
const tail = hoursAgo(i + 1);
return makeState({
id: i + 1,
file_name: `${IDENTICAL_STATE_PREFIX}_${tail.replace(/[:.]/g, "-")}.state`,
created_at: tail,
updated_at: tail,
emulator: i % 2 === 0 ? "snes9x" : "mesen",
});
});
}
export function toUserSave(
save: SaveSchema,
username: string,
overrides: Partial<UserSaveSchema> = {},
): UserSaveSchema {
return {
...save,
username,
is_public: true,
user_updated_at: save.updated_at,
...overrides,
};
}
export function toUserState(
state: StateSchema,
username: string,
overrides: Partial<UserStateSchema> = {},
): UserStateSchema {
return {
...state,
username,
is_public: true,
user_updated_at: state.updated_at,
...overrides,
};
}
// User 1's saves plus another user's public ones. Kept small: the a11y scan
// in the Storybook harness scales with the rendered rows.
export function mixedCommunitySaves(): UserSaveSchema[] {
const mine = [
...makeSaveSlot("autosave", 2, 1, 1),
...makeSaveSlot("main_quest", 2, 30, 3),
makeSave(5, null, 500),
].map((s) =>
toUserSave(s, "player", { user_id: 1, is_public: s.id % 3 === 0 }),
);
const theirs = makeSaveSlot("shared_route", 2, 40, 100).map((s) =>
toUserSave(s, "speedrunner42", { user_id: 2 }),
);
return [...mine, ...theirs];
}
export function mixedCommunityStates(): UserStateSchema[] {
const mine = manyStates(3).map((s) =>
toUserState(s, "player", { user_id: 1 }),
);
const theirs = manyStates(2).map((s, i) =>
toUserState(
{ ...s, id: 50 + i, user_id: 3, emulator: "mesen" },
"archivist",
),
);
return [...mine, ...theirs];
}
export function storyDetailedRom(
overrides: Partial<DetailedRomSchema> = {},
): DetailedRomSchema {
return makeDetailedRom({
platform_slug: "snes",
all_user_saves: mixedCommunitySaves(),
all_user_states: mixedCommunityStates(),
...overrides,
});
}
+42
View File
@@ -0,0 +1,42 @@
import { userEvent, waitFor, within } from "storybook/test";
// Selectable rows and tiles are toggle buttons, so `aria-pressed` marks them.
export function selectableItems(root: HTMLElement): HTMLElement[] {
const ui = within(root);
return [
...ui.queryAllByRole("button", { pressed: true }),
...ui.queryAllByRole("button", { pressed: false }),
];
}
export function downloadButtons(root: HTMLElement) {
return within(root).getAllByRole("button", { name: /^Download /i });
}
export function deleteButtons(root: HTMLElement) {
return within(root).getAllByRole("button", { name: /^Delete /i });
}
// SaveDataTab renders vertical tabs from md up and a menu trigger below it.
export async function pickSaveDataSubtab(
root: HTMLElement,
label: RegExp,
): Promise<void> {
const ui = within(root);
const tab = ui.queryByRole("tab", { name: label });
if (tab) {
await userEvent.click(tab);
return;
}
const trigger = ui
.queryAllByRole("button", { expanded: false })
.find((b) => b.getAttribute("aria-haspopup") === "menu");
if (!trigger) {
throw new Error("Save data subtab nav not found (tab or menu trigger)");
}
await userEvent.click(trigger);
const option = await waitFor(() =>
within(document.body).getByRole("menuitem", { name: label }),
);
await userEvent.click(option);
}
+33 -26
View File
@@ -16,6 +16,10 @@ type ComposedStory = {
parameters?: { a11y?: A11yParameters };
};
// axe over a full composite takes about a second locally and several times
// that on a shared CI runner, past Vitest's 5s default.
const STORY_TIMEOUT_MS = 20_000;
// color-contrast needs real layout, which happy-dom lacks; disable it here
// (covered by the a11y panel / a future browser-mode run).
const DEFAULT_AXE_OPTIONS: axe.RunOptions = {
@@ -65,11 +69,10 @@ async function checkA11y(
}
const storyModules = import.meta.glob<StoryModule>(
[
"../src/v2/lib/**/*.stories.ts",
"../src/v2/components/shared/**/*.stories.ts",
],
{ eager: true },
"../src/v2/**/*.stories.ts",
{
eager: true,
},
);
for (const [path, module] of Object.entries(storyModules)) {
@@ -77,27 +80,31 @@ for (const [path, module] of Object.entries(storyModules)) {
describe(path.replace(/^\.\.\//, ""), () => {
for (const [name, story] of Object.entries(composed)) {
const s = story as ComposedStory;
it(name, async () => {
if (typeof s.run !== "function") {
throw new Error(
`Story "${name}" has no run() helper — composeStories incompatible.`,
);
}
// Render into an owned canvas so axe scans exactly this story's
// subtree, then clean it up so stories stay isolated.
const canvasElement = document.createElement("div");
document.body.appendChild(canvasElement);
try {
await s.run({ canvasElement });
await checkA11y(
canvasElement,
s.parameters?.a11y ?? {},
`${path.replace(/^\.\.\//, "")} > ${name}`,
);
} finally {
canvasElement.remove();
}
});
it(
name,
async () => {
if (typeof s.run !== "function") {
throw new Error(
`Story "${name}" has no run() helper — composeStories incompatible.`,
);
}
// Render into an owned canvas so axe scans exactly this story's
// subtree, then clean it up so stories stay isolated.
const canvasElement = document.createElement("div");
document.body.appendChild(canvasElement);
try {
await s.run({ canvasElement });
await checkA11y(
canvasElement,
s.parameters?.a11y ?? {},
`${path.replace(/^\.\.\//, "")} > ${name}`,
);
} finally {
canvasElement.remove();
}
},
STORY_TIMEOUT_MS,
);
}
});
}