mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
Merge pull request #4727 from andest01/chore/storybook-save-state-display-v2
Chore: Storybook Save State support
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user