Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] エンジンのモック作成+それを使ったコンポーネントテスト #2152

Draft
wants to merge 39 commits into
base: main
Choose a base branch
from

Conversation

Hiroshiba
Copy link
Member

@Hiroshiba Hiroshiba commented Jul 2, 2024

内容

の解決を目指したプルリクエストです。
ついでにストーリーブック上でコンポーネントテストする方法を色々試そうとしてます。

TalkEditorの表示と、モックエンジンを使ったピッチ推論までできたのですが、なぜかscssが読み込まれずにスプリッターの色指定がうまくいってないです。
Viteとかの設定な気がしないでもないので、詳しい方いらっしゃったらヘルプいただけると助かります 🙇

追記:わかりました!!!たぶん色の初期化をしてないからでした!!

関連 Issue

fix #2144

スクリーンショット・動画など

こんな感じで境界線がない。多分正確には透明になってる。
image

その他

@Hiroshiba
Copy link
Member Author

テーマ周りで気になった挙動まとめ

  • ipcのset/getが同じ関数になってる
  • availableThemesだけセットしたいのにcurrentThemeもセットするmutationしかない
  • currentThemeのセッターに副作用がある

@Hiroshiba Hiroshiba force-pushed the エンジンのmockを作る branch from ddc7f1b to b1b10e4 Compare July 12, 2024 14:51
@Hiroshiba
Copy link
Member Author

とりあえずスナップショットテストができた!
それとは関係ないところでeslintエラーが出たのでissue立ててみました

@Hiroshiba
Copy link
Member Author

いろいろ試して、とりあえずトーク&ソングのモックを実装し、トーク&ソングエディタで音声(電子音)を再生できるところまで作りました。
が、ちょっとStorybookが中規模なテストにだいぶ不向きなことがわかってきました。

  • Vuexとの連携が難しい
    • stateの値を取ってくるくらいはできる
    • なぜかproxyが解けたり解けなかったりしてcommitがエラーになったりする、迂回はできるけど・・・。
  • snapshotが気軽にできない
    • windowオブジェクトを介して無理やりstoriesから使えるようにする、みたいな方法になる
  • できないUI操作が結構多い
    • dragが標準実装されてない
    • 任意座標にクリックとかができないっぽい?
      • 少なくともドキュメントにはない

SingEditor/TalkEditorをテストするのは流石にe2eになるかなぁという印象です。playwrigthとか。
最近はvitestでもコンポーネントテストができるっぽいので、そちらにも期待。

エンジンのmock(正確にはVuex内のエンジン関数のmock)はいろいろ役立つと思うので、何かしらの形で実装しようかなと思ってます。
加えて音声含むe2eテストを作るか、あるいはエンジンを利用するVuexテストを作ろうかなと。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Sep 15, 2024

ただ消すのもちょっともったいないので、コードとしてここに残しておこうと思います。

`.storybook/test-runner.ts`
import { type TestRunnerConfig } from "@storybook/test-runner";

const config: TestRunnerConfig = {
  async preVisit(page) {
    // テスト用のスナップショット関数を追加する。
    // *.stories.ts内で`window.storybookTestSnapshot`を使って呼び出せる。
    if (await page.evaluate(() => !("storybookTestSnapshot" in window))) {
      await page.exposeBinding(
        "storybookTestSnapshot",
        async (_, obj: unknown) => {
          expect(obj).toMatchSnapshot();
        },
      );
    }
  },
};

export default config;
`.storybook/main.ts`
import type { StorybookConfig } from "@storybook/vue3-vite";
import { assetsPath, dicPath } from "@/mock/engineMock/constants";

const config: StorybookConfig = {
  stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@chromatic-com/storybook",
    "@storybook/addon-interactions",
    "@storybook/addon-themes",
  ],
  core: {
    builder: "@storybook/builder-vite",
  },
  framework: {
    name: "@storybook/vue3-vite",
    options: {
      docgen: "vue-component-meta",
    },
  },
  staticDirs: [
    // モックエンジン用のファイル
    { from: "../node_modules/kuromoji/dict", to: dicPath },
    { from: "../tests/assets", to: assetsPath },
  ],
};

export default config;
`.storybook/test-runner.ts`
import { type TestRunnerConfig } from "@storybook/test-runner";

const config: TestRunnerConfig = {
  async preVisit(page) {
    // テスト用のスナップショット関数を追加する。
    // *.stories.ts内で`window.storybookTestSnapshot`を使って呼び出せる。
    if (await page.evaluate(() => !("storybookTestSnapshot" in window))) {
      await page.exposeBinding(
        "storybookTestSnapshot",
        async (_, obj: unknown) => {
          expect(obj).toMatchSnapshot();
        },
      );
    }
  },
};

export default config;
`.storybook\preview-head.html`
<!-- %BROWSER_PRELOAD% -->
<!-- FIXME: 色取得のために必要。DIできるようにしたい。 -->
<script type="module" src="/src/backend/browser/preload.ts"></script>
`src\components\Talk\TalkEditor.stories.ts`
import { userEvent, within, expect, fn, waitFor } from "@storybook/test";

import { Meta, StoryObj } from "@storybook/vue3";
import { provide, toRaw } from "vue";
import TalkEditor from "./TalkEditor.vue";
import { createStoreWrapper, storeKey } from "@/store";
import { HotkeyManager, hotkeyManagerKey } from "@/plugins/hotkeyPlugin";
import { createOpenAPIEngineMock, mockHost } from "@/mock/engineMock";
import { proxyStoreCreator } from "@/store/proxy";
import {
  CharacterInfo,
  defaultHotkeySettings,
  DefaultStyleId,
  EngineId,
  EngineInfo,
  SpeakerId,
  StyleId,
  ThemeConf,
} from "@/type/preload";
import { getEngineManifestMock } from "@/mock/engineMock/manifestMock";
import {
  getSpeakerInfoMock,
  getSpeakersMock,
} from "@/mock/engineMock/speakerResourceMock";
import { setFont, themeToCss } from "@/domain/dom";
import defaultTheme from "@/../public/themes/default.json";
import { cloneWithUnwrapProxy } from "@/helpers/cloneWithUnwrapProxy";
import { assetsPath } from "@/mock/engineMock/constants";

const meta: Meta<typeof TalkEditor> = {
  component: TalkEditor,
  args: {
    isEnginesReady: true,
    isProjectFileLoaded: false,
    onCompleteInitialStartup: fn(),
  },
  decorators: [
    (story, context) => {
      // CSS関連
      themeToCss(defaultTheme as ThemeConf);
      setFont("default");

      // ショートカットキーの管理
      const hotkeyManager = new HotkeyManager();
      provide(hotkeyManagerKey, hotkeyManager);
      hotkeyManager.load(defaultHotkeySettings);

      hotkeyManager.onEditorChange("talk");

      // setup store
      const store = createStoreWrapper({
        proxyStoreDI: proxyStoreCreator(createOpenAPIEngineMock()),
      });
      provide(storeKey, store);

      // なぜか必要、これがないとdispatch内でcommitしたときにエラーになる
      store.replaceState({
        ...cloneWithUnwrapProxy(store.state),
      });

      context.parameters.vuexState = store.state;

      // エンジンの情報
      const engineManifest = getEngineManifestMock();
      const engineId = EngineId(engineManifest.uuid);
      const engineInfo: EngineInfo = {
        uuid: engineId,
        host: mockHost,
        name: engineManifest.name,
        path: undefined,
        executionEnabled: false,
        executionFilePath: "not_found",
        executionArgs: [],
        isDefault: true,
        type: "path",
      };
      store.commit("SET_ENGINE_INFOS", {
        engineIds: [engineId],
        engineInfos: [engineInfo],
      });
      store.commit("SET_ENGINE_MANIFESTS", {
        engineManifests: { [engineId]: engineManifest },
      });
      store.commit("SET_ENGINE_SETTING", {
        engineId,
        engineSetting: {
          outputSamplingRate: engineManifest.defaultSamplingRate,
          useGpu: false,
        },
      });
      store.commit("SET_ENGINE_STATE", { engineId, engineState: "READY" });

      // キャラクター情報
      const speakers = getSpeakersMock();
      const characterInfos: CharacterInfo[] = speakers.map((speaker) => {
        const speakerInfo = getSpeakerInfoMock(speaker.speakerUuid, assetsPath);
        return {
          portraitPath: speakerInfo.portrait,
          metas: {
            speakerUuid: SpeakerId(speaker.speakerUuid),
            speakerName: speaker.name,
            styles: speakerInfo.styleInfos.map((styleInfo) => {
              const style = speaker.styles.find((s) => s.id === styleInfo.id);
              if (style == undefined) throw new Error("style not found");
              return {
                styleName: style.name,
                styleId: StyleId(style.id),
                styleType: style.type,
                iconPath: styleInfo.icon,
                portraitPath: styleInfo.portrait ?? speakerInfo.portrait,
                engineId,
                voiceSamplePaths: styleInfo.voiceSamples,
              };
            }),
            policy: speakerInfo.policy,
          },
        };
      });
      store.commit("SET_CHARACTER_INFOS", { engineId, characterInfos });
      store.commit("SET_USER_CHARACTER_ORDER", {
        userCharacterOrder: store.state.characterInfos[engineId].map(
          (c) => c.metas.speakerUuid,
        ),
      });

      // デフォルトスタイルID
      const defaultStyleIds: DefaultStyleId[] = speakers.map((speaker) => ({
        engineId: engineId,
        speakerUuid: SpeakerId(speaker.speakerUuid),
        defaultStyleId: StyleId(speaker.styles[0].id),
      }));
      store.commit("SET_DEFAULT_STYLE_IDS", { defaultStyleIds });

      return story();
    },
  ],
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  name: "デフォルト",
  play: async ({ args }) => {
    // 準備が完了するまで待機する
    await waitFor(
      () => expect(args.onCompleteInitialStartup).toHaveBeenCalled(),
      { timeout: 5000 },
    );
  },
};

export const NowLoading: Story = {
  name: "プロジェクトファイルを読み込み中",
  args: {
    isProjectFileLoaded: "waiting",
  },
};

export const TextInput: Story = {
  name: "テキスト入力のテスト",
  play: async ({ context, canvasElement, parameters }) => {
    await Default.play?.(context);

    const canvas = within(canvasElement);

    // テキスト欄に入力
    const textInput = await canvas.findByLabelText("1行目");
    await userEvent.type(textInput, "こんにちは、これはテストです。{enter}");

    const { audioItems, audioKeys } = parameters.vuexState;
    await window.storybookTestSnapshot?.({ audioItems, audioKeys });
  },
};

export const TextPaste: Story = {
  name: "テキストペーストのテスト",
  play: async ({ context, canvasElement, parameters }) => {
    await Default.play?.(context);

    const canvas = within(canvasElement);

    // テキスト欄に入力
    const textInput = await canvas.findByLabelText("1行目");
    await userEvent.click(textInput);
    await userEvent.paste("改行で改行\n読点で改行。最後の読点は改行しない。");

    const { audioItems, audioKeys } = parameters.vuexState;
    await window.storybookTestSnapshot?.({ audioItems, audioKeys });
  },
};
`src/type/globals.d.ts`
    // Storybookのtest-runnerのみで使用できるスナップショット関数
    storybookTestSnapshot?: (obj: unknown) => Promise<void>;
```</details>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Vuexのレイヤーでエンジンのmockを作る
1 participant