test(#10336): add components/MkC.* stories (#13830)

* test(storybook): add `components/MkC.*` stories

* test(storybook): add some tests

* test: add sleep

* test: comment-out flaky test

* test(storybook): add test for `MkChannelFollowButton`

* chore(storybook): tweak sleep duration in `MkChannelFollowButton` story test

* fix(chromatic): add delay to `MkChannelList`

* chore: replace `mswDecorator` with `mswLoader`

* fix(storybook): tweak some parameters

* chore: serve static files

* fix(chromatic): add delay to `MkCwButton`

* chore: delete logging for debug

* fix: add right click in `MkContextMenu` play

* refactor: remove unused imports
This commit is contained in:
zyoshoka
2024-06-08 18:00:54 +09:00
committed by GitHub
parent 61fae45390
commit 9849aab402
28 changed files with 1083 additions and 86 deletions

View File

@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { expect, userEvent, within } from '@storybook/test';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelFollowButton from './MkChannelFollowButton.vue';
import { semaphore } from '@/scripts/test-utils.js';
import { i18n } from '@/i18n.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
return {
components: {
MkChannelFollowButton,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelFollowButton v-bind="props" />',
};
},
args: {
channel: channel(),
full: true,
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts.follow);
await userEvent.click(buttonElement);
await sleep(1000);
await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow);
await sleep(100);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/channels/follow', async ({ request }) => {
action('POST /api/channels/follow')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/channels/unfollow', async ({ request }) => {
action('POST /api/channels/unfollow')(await request.json());
return HttpResponse.json({});
}),
],
},
},
} satisfies StoryObj<typeof MkChannelFollowButton>;

View File

@@ -26,17 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
channel: Record<string, any>;
channel: Misskey.entities.Channel;
full?: boolean;
}>(), {
full: false,
});
const isFollowing = ref<boolean>(props.channel.isFollowing);
const isFollowing = ref(props.channel.isFollowing);
const wait = ref(false);
async function onClick() {

View File

@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelList from './MkChannelList.vue';
export const Default = {
render(args) {
return {
components: {
MkChannelList,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelList v-bind="props" />',
};
},
args: {
pagination: {
endpoint: 'channels/search',
limit: 10,
},
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/channels/search', async ({ request, params }) => {
action('POST /api/channels/search')(await request.json());
return HttpResponse.json(params.untilId === 'lastchannel' ? [] : [
channel(),
channel('lastchannel', 'Last Channel', null),
]);
}),
],
},
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkChannelList>;

View File

@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { channel } from '../../.storybook/fakes.js';
import MkChannelPreview from './MkChannelPreview.vue';
export const Default = {
render(args) {
return {
components: {
MkChannelPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChannelPreview v-bind="props" />',
};
},
args: {
channel: channel(),
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkChannelPreview>;

View File

@@ -0,0 +1,117 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { DefaultBodyType, HttpResponse, HttpResponseResolver, JsonBodyType, PathParams, http } from 'msw';
import seedrandom from 'seedrandom';
import { action } from '@storybook/addon-actions';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChart from './MkChart.vue';
function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] {
const rng = seedrandom(seed);
const max = Math.floor(option?.mul ?? 250 * rng());
let accumulation = 0;
const array: number[] = [];
for (let i = 0; i < limit; i++) {
const num = Math.floor((max + 1) * rng());
if (option?.accumulate) {
accumulation += num;
array.unshift(accumulation);
} else {
array.push(num);
}
}
return array;
}
function getChartResolver(fields: string[], option?: { accumulate?: boolean, mulMap?: Record<string, number> }): HttpResponseResolver<PathParams, DefaultBodyType, JsonBodyType> {
return ({ request }) => {
action(`GET ${request.url}`)();
const limitParam = new URL(request.url).searchParams.get('limit');
const limit = limitParam ? parseInt(limitParam) : 30;
const res = {};
for (const field of fields) {
const layers = field.split('.');
let current = res;
while (layers.length > 1) {
const currentKey = layers.shift()!;
if (current[currentKey] == null) current[currentKey] = {};
current = current[currentKey];
}
current[layers[0]] = getChartArray(field, limit, {
accumulate: option?.accumulate,
mul: option?.mulMap != null && field in option.mulMap ? option.mulMap[field] : undefined,
});
}
return HttpResponse.json(res);
};
}
const Base = {
render(args) {
return {
components: {
MkChart,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChart v-bind="props" />',
};
},
args: {
src: 'federation',
span: 'hour',
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.get('/api/charts/federation', getChartResolver(
['deliveredInstances', 'inboxInstances', 'stalled', 'sub', 'pub', 'pubsub', 'subActive', 'pubActive'],
)),
http.get('/api/charts/notes', getChartResolver(
['local.total', 'remote.total'],
{ accumulate: true },
)),
http.get('/api/charts/drive', getChartResolver(
['local.incSize', 'local.decSize', 'remote.incSize', 'remote.decSize'],
{ mulMap: { 'local.incSize': 1e7, 'local.decSize': 5e6, 'remote.incSize': 1e6, 'remote.decSize': 5e5 } },
)),
],
},
},
} satisfies StoryObj<typeof MkChart>;
export const FederationChart = {
...Base,
args: {
src: 'federation',
},
} satisfies StoryObj<typeof MkChart>;
export const NotesTotalChart = {
...Base,
args: {
src: 'notes-total',
},
} satisfies StoryObj<typeof MkChart>;
export const DriveChart = {
...Base,
args: {
src: 'drive',
},
} satisfies StoryObj<typeof MkChart>;

View File

@@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
id-denylist violation when setting it. This is causing about 60+ lint issues.
As this is part of Chart.js's API it makes sense to disable the check here.
*/
import { onMounted, ref, shallowRef, watch, PropType } from 'vue';
import { onMounted, ref, shallowRef, watch } from 'vue';
import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
@@ -34,44 +35,55 @@ import MkChartLegend from '@/components/MkChartLegend.vue';
initChart();
const props = defineProps({
src: {
type: String,
required: true,
},
args: {
type: Object,
required: false,
},
limit: {
type: Number,
required: false,
default: 90,
},
span: {
type: String as PropType<'hour' | 'day'>,
required: true,
},
detailed: {
type: Boolean,
required: false,
default: false,
},
stacked: {
type: Boolean,
required: false,
default: false,
},
bar: {
type: Boolean,
required: false,
default: false,
},
aspectRatio: {
type: Number,
required: false,
default: null,
},
type ChartSrc =
| 'federation'
| 'ap-request'
| 'users'
| 'users-total'
| 'active-users'
| 'notes'
| 'local-notes'
| 'remote-notes'
| 'notes-total'
| 'drive'
| 'drive-files'
| 'instance-requests'
| 'instance-users'
| 'instance-users-total'
| 'instance-notes'
| 'instance-notes-total'
| 'instance-ff'
| 'instance-ff-total'
| 'instance-drive-usage'
| 'instance-drive-usage-total'
| 'instance-drive-files'
| 'instance-drive-files-total'
| 'per-user-notes'
| 'per-user-pv'
| 'per-user-following'
| 'per-user-followers'
| 'per-user-drive'
const props = withDefaults(defineProps<{
src: ChartSrc;
args?: {
host?: string;
user?: Misskey.entities.UserLite;
withoutAll?: boolean;
};
limit?: number;
span: 'hour' | 'day';
detailed?: boolean;
stacked?: boolean;
bar?: boolean;
aspectRatio?: number | null;
}>(), {
args: undefined,
limit: 90,
detailed: false,
stacked: false,
bar: false,
aspectRatio: null,
});
const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkChartLegend from './MkChartLegend.vue';
void MkChartLegend;

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkChartTooltip from './MkChartTooltip.vue';
void MkChartTooltip;

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { expect, within } from '@storybook/test';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkClickerGame from './MkClickerGame.vue';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const Default = {
render(args) {
return {
components: {
MkClickerGame,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkClickerGame v-bind="props" />',
};
},
async play({ canvasElement }) {
await sleep(1000);
const canvas = within(canvasElement);
const count = canvas.getByTestId('count');
// NOTE: flaky なので N/A も通しておく
await expect(count).toHaveTextContent(/^(0|N\/A)$/);
// FIXME: flaky
// const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
// await userEvent.click(buttonElement);
// await expect(count).toHaveTextContent('1');
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/i/registry/get', async ({ request }) => {
action('POST /api/i/registry/get')(await request.json());
return HttpResponse.json({
error: {
message: 'No such key.',
code: 'NO_SUCH_KEY',
id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a',
},
}, {
status: 400,
});
}),
http.post('/api/i/registry/set', async ({ request }) => {
action('POST /api/i/registry/set')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
http.post('/api/i/claim-achievement', async ({ request }) => {
action('POST /api/i/claim-achievement')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
],
},
},
} satisfies StoryObj<typeof MkClickerGame>;

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<div v-if="game.ready" :class="$style.game">
<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
<div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
<div :class="$style.count" class="" data-testid="count"><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
<button v-click-anime class="_button" @click="onClick">
<img src="/client-assets/cookie.png" :class="$style.img">
</button>

View File

@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { clip } from '../../.storybook/fakes.js';
import MkClipPreview from './MkClipPreview.vue';
export const Default = {
render(args) {
return {
components: {
MkClipPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkClipPreview v-bind="props" />',
};
},
args: {
clip: clip(),
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkClipPreview>;

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkCode_core from './MkCode.core.vue';
void MkCode_core;

View File

@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import MkCode from './MkCode.vue';
const code = `for (let i, 100) {
<: if (i % 15 == 0) "FizzBuzz"
elif (i % 3 == 0) "Fizz"
elif (i % 5 == 0) "Buzz"
else i
}`;
export const Default = {
render(args) {
return {
components: {
MkCode,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCode v-bind="props" />',
};
},
args: {
code,
lang: 'is',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCode>;

View File

@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import MkCodeEditor from './MkCodeEditor.vue';
const code = `for (let i, 100) {
<: if (i % 15 == 0) "FizzBuzz"
elif (i % 3 == 0) "Fizz"
elif (i % 5 == 0) "Buzz"
else i
}`;
export const Default = {
render(args) {
return {
components: {
MkCodeEditor,
},
data() {
return {
code,
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'change': action('change'),
'keydown': action('keydown'),
'enter': action('enter'),
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkCodeEditor v-model="code" v-bind="props" v-on="events" />',
};
},
args: {
lang: 'aiscript',
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><Suspense><story/></Suspense></div></div>',
}),
],
} satisfies StoryObj<typeof MkCodeEditor>;

View File

@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import MkCodeInline from './MkCodeInline.vue';
export const Default = {
render(args) {
return {
components: {
MkCodeInline,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCodeInline v-bind="props"/>',
};
},
args: {
code: '<: "Hello, world!"',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCodeInline>;

View File

@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import MkColorInput from './MkColorInput.vue';
export const Default = {
render(args) {
return {
components: {
MkColorInput,
},
data() {
return {
color: '#cccccc',
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkColorInput v-model="color" v-bind="props" v-on="events" />',
};
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkColorInput>;

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkContainer from './MkContainer.vue';
void MkContainer;

View File

@@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { userEvent, within } from '@storybook/test';
import MkContextMenu from './MkContextMenu.vue';
import * as os from '@/os.js';
export const Empty = {
render(args) {
return {
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
methods: {
onContextmenu(ev: MouseEvent) {
os.contextMenu(args.items, ev);
},
},
template: '<div @contextmenu.stop="onContextmenu">Right Click Here</div>',
};
},
args: {
items: [],
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const target = canvas.getByText('Right Click Here');
await userEvent.pointer({ keys: '[MouseRight>]', target });
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkContextMenu>;
export const SomeTabs = {
...Empty,
args: {
items: [
{
text: 'Home',
icon: 'ti ti-home',
action() {},
},
],
},
} satisfies StoryObj<typeof MkContextMenu>;

View File

@@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { file } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkCropperDialog from './MkCropperDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkCropperDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'ok': action('ok'),
'cancel': action('cancel'),
'closed': action('closed'),
};
},
},
template: '<MkCropperDialog v-bind="props" v-on="events" />',
};
},
args: {
file: file(),
aspectRatio: NaN,
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.get('/proxy/image.webp', async ({ request }) => {
const url = new URL(request.url).searchParams.get('url');
if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') {
const image = await (await fetch('client-assets/fedi.jpg')).blob();
return new HttpResponse(image, {
headers: {
'Content-Type': 'image/jpeg',
},
});
} else {
return new HttpResponse(null, { status: 404 });
}
}),
http.post('/api/drive/files/create', async ({ request }) => {
action('POST /api/drive/files/create')(await request.formData());
return HttpResponse.json(file());
}),
],
},
},
} satisfies StoryObj<typeof MkCropperDialog>;

View File

@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { emojiDetailed } from '../../.storybook/fakes.js';
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkCustomEmojiDetailedDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkCustomEmojiDetailedDialog v-bind="props" />',
};
},
args: {
emoji: emojiDetailed(),
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkCustomEmojiDetailedDialog>;

View File

@@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import { expect, userEvent, within } from '@storybook/test';
import { file } from '../../.storybook/fakes.js';
import MkCwButton from './MkCwButton.vue';
import { i18n } from '@/i18n.js';
import { semaphore } from '@/scripts/test-utils.js';
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const s = semaphore();
export const Default = {
render(args) {
return {
components: {
MkCwButton,
},
data() {
return {
showContent: false,
};
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
'update:modelValue': action('update:modelValue'),
};
},
},
template: '<MkCwButton v-model="showContent" v-bind="props" v-on="events" />',
};
},
args: {
text: 'Some CW content',
},
async play({ canvasElement }) {
await s.acquire();
await sleep(1000);
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.show);
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
await userEvent.click(buttonElement);
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.hide);
await userEvent.click(buttonElement);
s.release();
},
parameters: {
chromatic: {
// NOTE: テストが終わるまで待つ
delay: 5000,
},
layout: 'centered',
},
} satisfies StoryObj<typeof MkCwButton>;
export const IncludesTextAndDriveFile = {
...Default,
args: {
text: 'Some CW content',
files: [file()],
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
await expect(buttonElement).toHaveTextContent(' / ');
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.files({ count: 1 }));
},
} satisfies StoryObj<typeof MkCwButton>;

View File

@@ -7,3 +7,13 @@ export async function tick(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
}
/**
* @see https://github.com/misskey-dev/misskey/issues/11267
*/
export function semaphore(counter = 0, waiting: (() => void)[] = []) {
return {
acquire: () => ++counter > 1 && new Promise<void>(resolve => waiting.push(resolve)),
release: () => --counter && waiting.pop()?.(),
};
}