diff --git a/src/components/Dialog/Dialog.ts b/src/components/Dialog/Dialog.ts index c954fcf502..fbca3642ef 100644 --- a/src/components/Dialog/Dialog.ts +++ b/src/components/Dialog/Dialog.ts @@ -2,13 +2,15 @@ import { Dialog, Notify, Loading } from "quasar"; import SaveAllResultDialog from "./SaveAllResultDialog.vue"; import QuestionDialog from "./TextDialog/QuestionDialog.vue"; import MessageDialog from "./TextDialog/MessageDialog.vue"; +import VoiceLibraryPolicyDialog from "./VoiceLibraryPolicyDialog.vue"; import { DialogType } from "./TextDialog/common"; -import { AudioKey, ConfirmedTips } from "@/type/preload"; +import { ConfirmedTips, CharacterInfo, SpeakerId } from "@/type/preload"; import { AllActions, SaveResultObject, SaveResult, ErrorTypeForSaveAllResultDialog, + TermConfirmedAudioKey, } from "@/store/type"; import { DotNotationDispatch } from "@/store/vuex"; import { withProgress } from "@/store/ui"; @@ -171,47 +173,46 @@ export const showQuestionDialog = async (options: QuestionDialogOptions) => { }; export async function generateAndSaveOneAudioWithDialog({ - audioKey, + termConfirmedAudioKey, actions, filePath, disableNotifyOnGenerate, }: { - audioKey: AudioKey; + termConfirmedAudioKey: TermConfirmedAudioKey; actions: DotNotationDispatch; filePath?: string; disableNotifyOnGenerate: boolean; }): Promise { const result: SaveResultObject = await withProgress( actions.GENERATE_AND_SAVE_AUDIO({ - audioKey, + termConfirmedAudioKey, filePath, }), actions, ); - if (result == undefined) return; notifyResult(result, "audio", actions, disableNotifyOnGenerate); } export async function multiGenerateAndSaveAudioWithDialog({ - audioKeys, + termConfirmedAudioKeys, actions, dirPath, disableNotifyOnGenerate, }: { - audioKeys: AudioKey[]; + termConfirmedAudioKeys: TermConfirmedAudioKey[]; actions: DotNotationDispatch; dirPath?: string; disableNotifyOnGenerate: boolean; }): Promise { const result = await withProgress( actions.MULTI_GENERATE_AND_SAVE_AUDIO({ - audioKeys, + termConfirmedAudioKeys, dirPath, callback: (finishedCount) => actions.SET_PROGRESS_FROM_COUNT({ finishedCount, - totalCount: audioKeys.length, + totalCount: termConfirmedAudioKeys.length, }), }), actions, @@ -262,16 +263,19 @@ export async function multiGenerateAndSaveAudioWithDialog({ } export async function generateAndConnectAndSaveAudioWithDialog({ + termConfirmedAudioKeys, actions, filePath, disableNotifyOnGenerate, }: { + termConfirmedAudioKeys: TermConfirmedAudioKey[]; actions: DotNotationDispatch; filePath?: string; disableNotifyOnGenerate: boolean; }): Promise { const result = await withProgress( actions.GENERATE_AND_CONNECT_AND_SAVE_AUDIO({ + termConfirmedAudioKeys, filePath, callback: (finishedCount, totalCount) => actions.SET_PROGRESS_FROM_COUNT({ finishedCount, totalCount }), @@ -298,6 +302,46 @@ export async function connectAndExportTextWithDialog({ notifyResult(result, "text", actions, disableNotifyOnGenerate); } +/** + * 音声ライブラリ利用規約ダイアログを表示する。 + * 確認ボタンを押された場合は確認状況を保存する。 + */ +export async function showVoiceLibraryPolicyDialog({ + unconfirmedCharacterInfos, + currentConfirmedCharacterIds, + actions, +}: { + unconfirmedCharacterInfos: CharacterInfo[]; + currentConfirmedCharacterIds: SpeakerId[]; + actions: DotNotationDispatch; +}): Promise<"confirmed" | "canceled"> { + const { promise, resolve } = Promise.withResolvers< + "confirmed" | "canceled" + >(); + + Dialog.create({ + component: VoiceLibraryPolicyDialog, + componentProps: { + characterPolicyInfos: unconfirmedCharacterInfos.map((info) => ({ + id: info.metas.speakerUuid, + name: info.metas.speakerName, + policy: info.metas.policy, + portraitPath: info.portraitPath, + })), + }, + }) + .onOk((confirmedIds: SpeakerId[]) => { + void actions.SET_ROOT_MISC_SETTING({ + key: "termConfirmedCharacterIds", + value: [...currentConfirmedCharacterIds, ...confirmedIds], + }); + resolve("confirmed"); + }) + .onCancel(() => resolve("canceled")); + + return promise; +} + // 書き出し成功時の通知を表示 const showWriteSuccessNotify = ({ mediaType, diff --git a/src/components/Dialog/VoiceLibraryPolicyDialog.stories.ts b/src/components/Dialog/VoiceLibraryPolicyDialog.stories.ts new file mode 100644 index 0000000000..7358eb0940 --- /dev/null +++ b/src/components/Dialog/VoiceLibraryPolicyDialog.stories.ts @@ -0,0 +1,97 @@ +import { userEvent, within, expect, fn } from "storybook/test"; + +import { Meta, StoryObj } from "@storybook/vue3-vite"; +import VoiceLibraryPolicyDialog from "./VoiceLibraryPolicyDialog.vue"; +import { SpeakerId as toSpeakerId } from "@/type/preload"; +import { getPortraitUrl } from "@/mock/engineMock/characterResourceMock"; +import { uuid4 } from "@/helpers/random"; + +const testCharacterAId = toSpeakerId(uuid4()); +const testCharacterBId = toSpeakerId(uuid4()); +const testCharacterCId = toSpeakerId(uuid4()); + +const meta: Meta = { + component: VoiceLibraryPolicyDialog, + args: { + modelValue: false, + characterPolicyInfos: [ + { + id: testCharacterAId, + name: "テストキャラクターA", + policy: + "markdownテスト。**太字**。\\\n改行。\\\n[リンク](https://example.com)", + portraitPath: getPortraitUrl(0), + }, + { + id: testCharacterBId, + name: "テストキャラクターB", + policy: Array(50).fill("長いテキスト").join(""), + portraitPath: getPortraitUrl(1), + }, + ], + onOk: fn(), + onHide: fn(), + "onUpdate:modelValue": fn(), + }, + tags: ["!autodocs"], // ダイアログ系はautodocsのプレビューが正しく表示されないので無効化 +}; + +export default meta; +type Story = StoryObj; + +export const Opened: Story = { + name: "開いている", + args: { + modelValue: true, + }, +}; + +export const LongUrl: Story = { + name: "極端に長いURL", + args: { + modelValue: true, + characterPolicyInfos: [ + { + id: testCharacterCId, + name: "テストキャラクターC", + policy: + "極端に長いURLのテスト。https://example.com/very/long/path/to/some/policy/document/that/has/many/nested/directories/and/a/very/long/filename/with/query/parameters?param1=value1¶m2=value2¶m3=value3¶m4=value4¶m5=value5¶m6=value6¶m7=value7¶m8=value8¶m9=value9¶m10=value10", + portraitPath: getPortraitUrl(2), + }, + ], + }, +}; + +export const Ok: Story = { + name: "確認ボタンを押す", + args: { ...Opened.args }, + play: async ({ args }) => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const button = canvas.getByRole("button", { name: /確認して続行/ }); + await userEvent.click(button); + + await expect(args["onOk"]).toBeCalledWith([ + testCharacterAId, + testCharacterBId, + ]); + }, +}; + +export const Cancel: Story = { + name: "キャンセルボタンを押す", + args: { ...Opened.args }, + play: async ({ args }) => { + const canvas = within(document.body); // ダイアログなので例外的にdocument.bodyを使う + + const button = canvas.getByRole("button", { name: /キャンセル/ }); + await userEvent.click(button); + + await expect(args["onHide"]).toBeCalledWith(); + }, +}; + +export const Closed: Story = { + name: "閉じている", + tags: ["skip-screenshot"], +}; diff --git a/src/components/Dialog/VoiceLibraryPolicyDialog.vue b/src/components/Dialog/VoiceLibraryPolicyDialog.vue new file mode 100644 index 0000000000..214fa562b8 --- /dev/null +++ b/src/components/Dialog/VoiceLibraryPolicyDialog.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/mock/engineMock/characterResourceMock.ts b/src/mock/engineMock/characterResourceMock.ts index b17ce73845..3e29318ea6 100644 --- a/src/mock/engineMock/characterResourceMock.ts +++ b/src/mock/engineMock/characterResourceMock.ts @@ -6,7 +6,7 @@ import { Speaker, SpeakerInfo } from "@/openapi"; /** 立ち絵のURLを得る */ -function getPortraitUrl(characterIndex: number) { +export function getPortraitUrl(characterIndex: number) { const portraits = Object.values( import.meta.glob("./assets/portrait_*.png", { import: "default", diff --git a/src/store/audio.ts b/src/store/audio.ts index 81c1e6be5a..2186ee80a4 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -11,6 +11,7 @@ import { transformCommandStore, FetchAudioResult, EditorAudioQuery, + TermConfirmedAudioKey, } from "./type"; import { buildAudioFileNameFromRawData, @@ -65,6 +66,7 @@ import { UnreachableError } from "@/type/utility"; import { errorToMessage } from "@/helpers/errorHelper"; import path from "@/helpers/path"; import { generateTextFileData } from "@/helpers/fileDataGenerator"; +import { showVoiceLibraryPolicyDialog } from "@/components/Dialog/Dialog"; function generateAudioKey() { return AudioKey(uuid4()); @@ -233,6 +235,47 @@ export const audioStore = createPartialStore({ }, }, + /** + * 利用規約確認済みかどうかチェックし、確認済みならAudioKeyに変換する。 + * 未確認のキャラクターが含まれている場合は利用規約確認ダイアログを表示する。 + */ + CHECK_VOICE_LIBRARY_POLICY_CONFIRMATION: { + async action({ actions, state }, { audioKeys }) { + // 利用規約を未確認なキャラクターを取得する + const unconfirmedCharacterIds = (() => { + const speakerIds = audioKeys.map( + (audioKey) => state.audioItems[audioKey].voice.speakerId, + ); + const uniqueSpeakerIds = Array.from(new Set(speakerIds)); + return uniqueSpeakerIds.filter( + (speakerId) => !state.termConfirmedCharacterIds.includes(speakerId), + ); + })(); + + // 未確認キャラクターがいなければそのまま返す + if (unconfirmedCharacterIds.length === 0) { + return audioKeys.map((k) => TermConfirmedAudioKey(k)); + } + + // 未確認キャラクターに対して利用規約確認ダイアログを表示する + const unconfirmedCharacterInfos = Object.values(state.characterInfos) + .flatMap((engineCharacterInfos) => engineCharacterInfos) + .filter((characterInfo) => + unconfirmedCharacterIds.includes(characterInfo.metas.speakerUuid), + ); + const result = await showVoiceLibraryPolicyDialog({ + unconfirmedCharacterInfos, + currentConfirmedCharacterIds: state.termConfirmedCharacterIds, + actions, + }); + if (result === "canceled") { + return "canceled"; + } + + return audioKeys.map((k) => TermConfirmedAudioKey(k)); + }, + }, + /** * audio elementの再生オフセット。 * 選択+削除 や 挿入+選択+元に戻す などを行った場合でも範囲外にならないようにクランプする。 @@ -1369,14 +1412,16 @@ export const audioStore = createPartialStore({ async ( { state, getters, actions }, { - audioKey, + termConfirmedAudioKey, filePath, }: { - audioKey: AudioKey; + termConfirmedAudioKey: TermConfirmedAudioKey; filePath?: string; }, ): Promise => { - const defaultAudioFileName = getters.DEFAULT_AUDIO_FILE_NAME(audioKey); + const defaultAudioFileName = getters.DEFAULT_AUDIO_FILE_NAME( + termConfirmedAudioKey, + ); if (state.savingSetting.fixedExportEnabled) { filePath = path.join( state.savingSetting.fixedExportDir, @@ -1401,7 +1446,9 @@ export const audioStore = createPartialStore({ let fetchAudioResult: FetchAudioResult; try { - fetchAudioResult = await actions.FETCH_AUDIO({ audioKey }); + fetchAudioResult = await actions.FETCH_AUDIO({ + audioKey: termConfirmedAudioKey, + }); } catch (e) { const errorMessage = handlePossiblyNotMorphableError(e); return { @@ -1437,10 +1484,13 @@ export const audioStore = createPartialStore({ if (state.savingSetting.exportText) { await writeTextFile({ - text: extractExportText(state.audioItems[audioKey].text, { - enableMemoNotation: state.enableMemoNotation, - enableRubyNotation: state.enableRubyNotation, - }), + text: extractExportText( + state.audioItems[termConfirmedAudioKey].text, + { + enableMemoNotation: state.enableMemoNotation, + enableRubyNotation: state.enableRubyNotation, + }, + ), filePath: filePath.replace(/\.wav$/, ".txt"), encoding: state.savingSetting.fileEncoding, }).then(getValueOrThrow); @@ -1473,11 +1523,11 @@ export const audioStore = createPartialStore({ async ( { state, getters, actions }, { - audioKeys, + termConfirmedAudioKeys, dirPath, callback, }: { - audioKeys: AudioKey[]; + termConfirmedAudioKeys: TermConfirmedAudioKey[]; dirPath?: string; callback?: (finishedCount: number) => void; }, @@ -1496,11 +1546,11 @@ export const audioStore = createPartialStore({ let finishedCount = 0; - const promises = audioKeys.map((audioKey) => { - const name = getters.DEFAULT_AUDIO_FILE_NAME(audioKey); + const promises = termConfirmedAudioKeys.map((termConfirmedAudioKey) => { + const name = getters.DEFAULT_AUDIO_FILE_NAME(termConfirmedAudioKey); return actions .GENERATE_AND_SAVE_AUDIO({ - audioKey, + termConfirmedAudioKey, filePath: path.join(dirPath, name), }) .then((value) => { @@ -1518,9 +1568,11 @@ export const audioStore = createPartialStore({ async ( { state, getters, actions }, { + termConfirmedAudioKeys, filePath, callback, }: { + termConfirmedAudioKeys: TermConfirmedAudioKey[]; filePath?: string; callback?: (finishedCount: number, totalCount: number) => void; }, @@ -1558,14 +1610,16 @@ export const audioStore = createPartialStore({ return toBase64(new Uint8Array(arrayBuffer)); }; - const totalCount = state.audioKeys.length; + const totalCount = termConfirmedAudioKeys.length; let finishedCount = 0; let labOffset = 0; - for (const audioKey of state.audioKeys) { + for (const termConfirmedAudioKey of termConfirmedAudioKeys) { let fetchAudioResult: FetchAudioResult; try { - fetchAudioResult = await actions.FETCH_AUDIO({ audioKey }); + fetchAudioResult = await actions.FETCH_AUDIO({ + audioKey: termConfirmedAudioKey, + }); } catch (e) { const errorMessage = handlePossiblyNotMorphableError(e); return { @@ -1593,7 +1647,7 @@ export const audioStore = createPartialStore({ labOffset = Number(splitLab[splitLab.length - 2]); texts.push( - extractExportText(state.audioItems[audioKey].text, { + extractExportText(state.audioItems[termConfirmedAudioKey].text, { enableMemoNotation: state.enableMemoNotation, enableRubyNotation: state.enableRubyNotation, }), diff --git a/src/store/setting.ts b/src/store/setting.ts index 0ff8f8a556..8fe05b4e5f 100644 --- a/src/store/setting.ts +++ b/src/store/setting.ts @@ -45,6 +45,7 @@ export const settingStoreState: SettingStoreState = { showAddAudioItemButton: true, acceptTerms: "Unconfirmed", acceptRetrieveTelemetry: "Unconfirmed", + termConfirmedCharacterIds: [], experimentalSetting: { enableInterrogativeUpspeak: false, enableMorphing: false, @@ -156,6 +157,7 @@ export const settingStore = createPartialStore({ "openedEditor", "enableKatakanaEnglish", "enableMultiSelect", + "termConfirmedCharacterIds", ] as const; // rootMiscSettingKeysに値を足し忘れていたときに型エラーを出す検出用コード diff --git a/src/store/type.ts b/src/store/type.ts index 5bee982474..08f4d96a36 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -76,6 +76,12 @@ import type { Track, } from "@/domain/project/type"; import { LatestProjectType } from "@/infrastructures/projectFile/type"; +import { Brand } from "@/type/utility"; + +/** 利用規約確認済みのAudioKey */ +export type TermConfirmedAudioKey = Brand; +export const TermConfirmedAudioKey = (id: AudioKey): TermConfirmedAudioKey => + id as TermConfirmedAudioKey; /** * エディタ用のAudioQuery @@ -176,6 +182,12 @@ export type AudioStoreTypes = { getter: AudioKey[]; }; + CHECK_VOICE_LIBRARY_POLICY_CONFIRMATION: { + action(payload: { + audioKeys: AudioKey[]; + }): Promise<"canceled" | TermConfirmedAudioKey[]>; + }; + AUDIO_PLAY_START_POINT: { getter: number | undefined; }; @@ -435,14 +447,14 @@ export type AudioStoreTypes = { GENERATE_AND_SAVE_AUDIO: { action(payload: { - audioKey: AudioKey; + termConfirmedAudioKey: TermConfirmedAudioKey; filePath?: string; }): SaveResultObject; }; MULTI_GENERATE_AND_SAVE_AUDIO: { action(payload: { - audioKeys: AudioKey[]; + termConfirmedAudioKeys: TermConfirmedAudioKey[]; dirPath?: string; callback?: (finishedCount: number) => void; }): SaveResultObject[] | "canceled"; @@ -450,6 +462,7 @@ export type AudioStoreTypes = { GENERATE_AND_CONNECT_AND_SAVE_AUDIO: { action(payload: { + termConfirmedAudioKeys: TermConfirmedAudioKey[]; filePath?: string; callback?: (finishedCount: number, totalCount: number) => void; }): SaveResultObject; @@ -1965,6 +1978,7 @@ export type SettingStoreState = { availableThemes: ThemeConf[]; acceptTerms: AcceptTermsStatus; acceptRetrieveTelemetry: AcceptRetrieveTelemetryStatus; + termConfirmedCharacterIds: SpeakerId[]; // 利用規約を確認済みのキャラクターID experimentalSetting: ExperimentalSettingType; confirmedTips: ConfirmedTips; engineSettings: EngineSettings; diff --git a/src/store/ui.ts b/src/store/ui.ts index 94db2da956..2b5bb7a41c 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -28,6 +28,7 @@ import { showWarningDialog, } from "@/components/Dialog/Dialog"; import { objectEntries } from "@/helpers/typedEntries"; +import { UnreachableError } from "@/type/utility"; export function createUILockAction( action: ( @@ -481,8 +482,13 @@ export const uiStore = createPartialStore({ // TODO: この4つのアクションをVue側に移動したい SHOW_GENERATE_AND_SAVE_ALL_AUDIO_DIALOG: { async action({ state, actions }) { - await multiGenerateAndSaveAudioWithDialog({ + const result = await actions.CHECK_VOICE_LIBRARY_POLICY_CONFIRMATION({ audioKeys: state.audioKeys, + }); + if (result === "canceled") return; + + await multiGenerateAndSaveAudioWithDialog({ + termConfirmedAudioKeys: result, disableNotifyOnGenerate: state.confirmedTips.notifyOnGenerate, actions, }); @@ -491,7 +497,13 @@ export const uiStore = createPartialStore({ SHOW_GENERATE_AND_CONNECT_ALL_AUDIO_DIALOG: { async action({ actions, state }) { + const result = await actions.CHECK_VOICE_LIBRARY_POLICY_CONFIRMATION({ + audioKeys: state.audioKeys, + }); + if (result === "canceled") return; + await generateAndConnectAndSaveAudioWithDialog({ + termConfirmedAudioKeys: result, actions, disableNotifyOnGenerate: state.confirmedTips.notifyOnGenerate, }); @@ -511,14 +523,27 @@ export const uiStore = createPartialStore({ const selectedAudioKeys = getters.SELECTED_AUDIO_KEYS; if (state.enableMultiSelect && selectedAudioKeys.length > 1) { - await multiGenerateAndSaveAudioWithDialog({ + const result = await actions.CHECK_VOICE_LIBRARY_POLICY_CONFIRMATION({ audioKeys: selectedAudioKeys, + }); + if (result === "canceled") return; + + await multiGenerateAndSaveAudioWithDialog({ + termConfirmedAudioKeys: result, actions: actions, disableNotifyOnGenerate: state.confirmedTips.notifyOnGenerate, }); } else { + const result = await actions.CHECK_VOICE_LIBRARY_POLICY_CONFIRMATION({ + audioKeys: [activeAudioKey], + }); + if (result === "canceled") return; + + if (result.length != 1) { + throw new UnreachableError("result.length != 1"); + } await generateAndSaveOneAudioWithDialog({ - audioKey: activeAudioKey, + termConfirmedAudioKey: result[0], disableNotifyOnGenerate: state.confirmedTips.notifyOnGenerate, actions: actions, }); diff --git a/src/type/preload.ts b/src/type/preload.ts index 280bf4fe35..a334c9ce3d 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -397,6 +397,7 @@ export const rootMiscSettingSchema = z.object({ .default("MINUTES_SECONDS"), // 再生ヘッド位置の表示モード enableKatakanaEnglish: z.boolean().default(true), // 未知の英単語をカタカナ読みに変換するかどうか enableMultiSelect: z.boolean().default(true), // 複数選択を有効にするかどうか + termConfirmedCharacterIds: speakerIdSchema.array().default([]), // 利用規約を確認済みのキャラクターID }); export type RootMiscSettingType = z.infer; diff --git a/tests/e2e/browser/utils.ts b/tests/e2e/browser/utils.ts index 3dcdc9e92f..a6a4f00ba2 100644 --- a/tests/e2e/browser/utils.ts +++ b/tests/e2e/browser/utils.ts @@ -86,3 +86,52 @@ export async function fillAudioCell(page: Page, index: number, text: string) { export async function validateInput(locator: Locator, expectedText: string) { expect(await locator.inputValue()).toBe(expectedText); } + +/** 指定した数のAudioCellを追加する */ +export async function addAudioCells(page: Page, count: number) { + for (let i = 0; i < count; i++) { + await page.getByRole("button", { name: "テキストを追加" }).click(); + await page.waitForTimeout(100); + } +} + +/** AudioCellのキャラクターを変更する */ +export async function changeAudioCellCharacter( + page: Page, + nthChild: number, + digit: number, +) { + await test.step(`${nthChild}人目のキャラクターを変更`, async () => { + const audioCell = page.locator(`.audio-cell:nth-child(${nthChild})`); + await audioCell.click(); + await page.keyboard.press(`${ctrlLike}+Digit${digit}`); + }); +} + +/** AudioCellを範囲選択する */ +export async function selectAudioCellRange( + page: Page, + startIndex: number, + endIndex: number, +) { + await test.step(`${startIndex}番目から${endIndex}番目のAudioCellを範囲選択`, async () => { + await page.locator(`.audio-cell:nth-child(${startIndex})`).click(); + await page.keyboard.down("Shift"); + await page.locator(`.audio-cell:nth-child(${endIndex})`).click(); + await page.keyboard.up("Shift"); + }); +} + +/** トーク音声書き出し完了の通知を確認して閉じる */ +export async function waitForExportNotificationAndClose(page: Page) { + await test.step("トーク音声書き出し完了の通知を確認して閉じる", async () => { + // NOTE: なぜか前のnotifyの結果残ってしまっているので、.last()を使う + const notify = page.locator("#q-notify"); + await expect(notify.getByText("音声を書き出しました").last()).toBeVisible(); + await notify.getByRole("button", { name: "閉じる" }).last().click(); + await expect(notify).not.toBeVisible(); + }); +} + +/** プラットフォームに応じたCtrlキー。MacではMeta、それ以外ではControl。 */ +export const ctrlLike = process.platform === "darwin" ? "Meta" : "Control"; diff --git "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/utils.ts" "b/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/utils.ts" deleted file mode 100644 index f3d3783019..0000000000 --- "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/utils.ts" +++ /dev/null @@ -1,10 +0,0 @@ -import { Page } from "@playwright/test"; - -export async function addAudioCells(page: Page, count: number) { - for (let i = 0; i < count; i++) { - await page.getByRole("button", { name: "テキストを追加" }).click(); - await page.waitForTimeout(100); - } -} - -export const ctrlLike = process.platform === "darwin" ? "Meta" : "Control"; diff --git "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts" "b/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts" index 921fc982fb..33ce55f25a 100644 --- "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts" +++ "b/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\345\200\244\345\244\211\346\233\264.spec.ts" @@ -1,6 +1,6 @@ import { test, expect, Page } from "@playwright/test"; import { navigateToMain, gotoHome } from "../../navigators"; -import { addAudioCells } from "./utils"; +import { addAudioCells } from "../utils"; /* * 全てのAudioCellのキャラクター+スタイル名を取得する。 diff --git "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\351\201\270\346\212\236.spec.ts" "b/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\351\201\270\346\212\236.spec.ts" index 51da82f2e8..0d2bdfb543 100644 --- "a/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\351\201\270\346\212\236.spec.ts" +++ "b/tests/e2e/browser/\350\244\207\346\225\260\351\201\270\346\212\236/\351\201\270\346\212\236.spec.ts" @@ -1,6 +1,6 @@ import { test, expect, Page } from "@playwright/test"; import { navigateToMain, gotoHome } from "../../navigators"; -import { ctrlLike, addAudioCells } from "./utils"; +import { ctrlLike, addAudioCells } from "../utils"; test.beforeEach(async ({ page }) => { await gotoHome({ page }); diff --git "a/tests/e2e/browser/\351\237\263\345\243\260\343\203\251\343\202\244\343\203\226\343\203\251\343\203\252\345\210\251\347\224\250\350\246\217\347\264\204\347\242\272\350\252\215.spec.ts" "b/tests/e2e/browser/\351\237\263\345\243\260\343\203\251\343\202\244\343\203\226\343\203\251\343\203\252\345\210\251\347\224\250\350\246\217\347\264\204\347\242\272\350\252\215.spec.ts" new file mode 100644 index 0000000000..b8d11f5960 --- /dev/null +++ "b/tests/e2e/browser/\351\237\263\345\243\260\343\203\251\343\202\244\343\203\226\343\203\251\343\203\252\345\210\251\347\224\250\350\246\217\347\264\204\347\242\272\350\252\215.spec.ts" @@ -0,0 +1,156 @@ +import { test, expect } from "@playwright/test"; +import { gotoHome, navigateToMain } from "../navigators"; +import { getQuasarMenu, getNewestQuasarDialog } from "../locators"; +import { + mockShowSaveFileDialog, + mockWriteFile, + mockShowSaveDirectoryDialog, +} from "./mockUtility"; +import { + addAudioCells, + fillAudioCell, + changeAudioCellCharacter, + waitForExportNotificationAndClose, + selectAudioCellRange, +} from "./utils"; + +test.beforeEach(gotoHome); + +test.describe("音声ライブラリ利用規約確認", () => { + test.beforeEach(async ({ page }) => { + await navigateToMain(page); + + await test.step("テキスト欄にテキストを入力", async () => { + await fillAudioCell(page, 0, "1番目のテキスト"); + }); + }); + + test("音声ライブラリ利用規約ダイアログの表示", async ({ page }) => { + // 1つのキャラクターでのテスト: + // * 書き出し時に利用規約が表示される + // * キャンセル後、再度書き出しすると利用規約が再表示される + // * 確認後、再度書き出しでは利用規約が表示されない + // + // 複数キャラクターでのテスト: + // * 3人のキャラクターで書き出し時、未確認の2人分の利用規約のみ表示 + // * 確認後、再度3人で書き出しでは何も表示されない + await test.step("1つのキャラクターで書き出し時に利用規約が表示される", async () => { + await mockShowSaveFileDialog(page); + await mockWriteFile(page); + + await page.getByRole("button", { name: "ファイル" }).click(); + await getQuasarMenu(page, "選択音声を書き出し").click(); + + const dialog = getNewestQuasarDialog(page); + await expect( + dialog.getByText("音声ライブラリ利用規約のご案内"), + ).toBeVisible(); + + // 1つのキャラクターの利用規約が表示されることを確認 + const characterPolicies = dialog.getByRole("listitem"); + await expect(characterPolicies).toHaveCount(1); + }); + + await test.step("キャンセルボタンを押すとダイアログが閉じる", async () => { + const dialog = getNewestQuasarDialog(page); + await dialog.getByRole("button", { name: "キャンセル" }).click(); + await expect(dialog).not.toBeVisible(); + }); + + await test.step("再度書き出しを試行すると利用規約が再び表示される", async () => { + await mockShowSaveFileDialog(page); + await mockWriteFile(page); + + await page.getByRole("button", { name: "ファイル" }).click(); + await getQuasarMenu(page, "選択音声を書き出し").click(); + + const dialog = getNewestQuasarDialog(page); + await expect( + dialog.getByText("音声ライブラリ利用規約のご案内"), + ).toBeVisible(); + + // 1つのキャラクターの利用規約が表示されることを確認 + const characterPolicies = dialog.getByRole("listitem"); + await expect(characterPolicies).toHaveCount(1); + }); + + await test.step("確認ボタンを押すと書き出しが実行される", async () => { + const dialog = getNewestQuasarDialog(page); + await dialog.getByRole("button", { name: "確認して続行" }).click(); + await expect(dialog).not.toBeVisible(); + + await waitForExportNotificationAndClose(page); + }); + + await test.step("確認済みキャラクターでは利用規約が表示されない", async () => { + await mockShowSaveFileDialog(page); + await mockWriteFile(page); + + await page.getByRole("button", { name: "ファイル" }).click(); + await getQuasarMenu(page, "選択音声を書き出し").click(); + + // 利用規約ダイアログが表示されないことを確認 + await page.waitForTimeout(500); + const policyDialog = page.getByText("音声ライブラリ利用規約のご案内"); + await expect(policyDialog).not.toBeVisible(); + + await waitForExportNotificationAndClose(page); + }); + + await test.step("キャラクターを2人追加してテキストを入力", async () => { + await addAudioCells(page, 2); + await fillAudioCell(page, 1, "2番目のテキスト"); + await changeAudioCellCharacter(page, 2, 2); + await fillAudioCell(page, 2, "3番目のテキスト"); + await changeAudioCellCharacter(page, 3, 3); + }); + + await test.step("3人のキャラクターを選択", async () => { + await selectAudioCellRange(page, 1, 3); + }); + + await test.step("3人選択して書き出し時にキャラクター2と3の利用規約のみ表示される", async () => { + await mockShowSaveDirectoryDialog(page); + await mockWriteFile(page); + + await page.getByRole("button", { name: "ファイル" }).click(); + await getQuasarMenu(page, "選択音声を書き出し").click(); + + const dialog = getNewestQuasarDialog(page); + await expect( + dialog.getByText("音声ライブラリ利用規約のご案内"), + ).toBeVisible(); + + // 2つのキャラクターの利用規約が表示されることを確認 + const characterPolicies = dialog.getByRole("listitem"); + await expect(characterPolicies).toHaveCount(2); + }); + + await test.step("確認ボタンを押すと複数音声の書き出しが実行される", async () => { + const dialog = getNewestQuasarDialog(page); + await dialog.getByRole("button", { name: "確認して続行" }).click(); + await expect(dialog).not.toBeVisible(); + + await waitForExportNotificationAndClose(page); + }); + + await test.step("再度3人のキャラクターを選択", async () => { + await selectAudioCellRange(page, 1, 3); + }); + + await test.step("全キャラクター確認済みでは利用規約が表示されない", async () => { + await mockShowSaveDirectoryDialog(page); + await mockWriteFile(page); + + await page.getByRole("button", { name: "ファイル" }).click(); + await getQuasarMenu(page, "選択音声を書き出し").click(); + + // 利用規約ダイアログが表示されないことを確認 + await page.waitForTimeout(500); + const policyDialog = page.getByText("音声ライブラリ利用規約のご案内"); + await expect(policyDialog).not.toBeVisible(); + + await waitForExportNotificationAndClose(page); + }); + }); +}); diff --git "a/tests/e2e/browser/\351\237\263\345\243\260\346\233\270\343\201\215\345\207\272\343\201\227.spec.ts" "b/tests/e2e/browser/\351\237\263\345\243\260\346\233\270\343\201\215\345\207\272\343\201\227.spec.ts" index a25e03feb5..dd31e87baf 100644 --- "a/tests/e2e/browser/\351\237\263\345\243\260\346\233\270\343\201\215\345\207\272\343\201\227.spec.ts" +++ "b/tests/e2e/browser/\351\237\263\345\243\260\346\233\270\343\201\215\345\207\272\343\201\227.spec.ts" @@ -7,11 +7,23 @@ import { mockShowSaveDirectoryDialog, mockWriteFileError, } from "./mockUtility"; -import { fillAudioCell } from "./utils"; +import { fillAudioCell, waitForExportNotificationAndClose } from "./utils"; test.beforeEach(gotoHome); -async function exportSelectedAudioAndSnapshot(page: Page, name: string) { +async function handleVoiceLibraryPolicyDialog(page: Page) { + await test.step("利用規約確認ダイアログの確認ボタンを押す", async () => { + const dialog = getNewestQuasarDialog(page); + await dialog.getByText("音声ライブラリ利用規約のご案内").waitFor(); + await dialog.getByRole("button", { name: "確認して続行" }).click(); + }); +} + +async function exportSelectedAudioAndSnapshot( + page: Page, + name: string, + { isFirstExport = false }: { isFirstExport?: boolean } = {}, +) { const { getFileIds } = await mockShowSaveFileDialog(page); const { getWrittenFileBuffers } = await mockWriteFile(page); @@ -20,12 +32,11 @@ async function exportSelectedAudioAndSnapshot(page: Page, name: string) { await getQuasarMenu(page, "選択音声を書き出し").click(); }); - await test.step("書き出し完了の通知を確認して閉じる", async () => { - const notify = page.locator("#q-notify"); - await expect(notify.getByText("音声を書き出しました")).toBeVisible(); - await notify.getByRole("button", { name: "閉じる" }).click(); - await expect(notify).not.toBeVisible(); - }); + if (isFirstExport) { + await handleVoiceLibraryPolicyDialog(page); + } + + await waitForExportNotificationAndClose(page); await test.step("音声ファイルのバイナリをスナップショット", async () => { const fileId = (await getFileIds())[0]; @@ -48,7 +59,9 @@ test.describe("音声書き出し", () => { test("各パラメータを変更して音声書き出し", async ({ page }) => { test.skip(process.platform !== "win32", "Windows以外のためスキップします"); // NOTE: 音声スナップショットが完全一致しないため - await exportSelectedAudioAndSnapshot(page, "デフォルト"); + await exportSelectedAudioAndSnapshot(page, "デフォルト", { + isFirstExport: true, + }); const parameters = [ ["話速", "1.5"], @@ -91,6 +104,8 @@ test.describe("音声書き出し", () => { await getQuasarMenu(page, "選択音声を書き出し").click(); }); + await handleVoiceLibraryPolicyDialog(page); + await test.step("エラーダイアログを確認して閉じる", async () => { const dialog = page.getByRole("dialog", { name: "書き出しに失敗しました。", @@ -114,6 +129,8 @@ test.describe("音声書き出し", () => { await getQuasarMenu(page, "音声書き出し").click(); }); + await handleVoiceLibraryPolicyDialog(page); + await test.step("結果ダイアログを確認して閉じる", async () => { const dialog = getNewestQuasarDialog(page); await expect(dialog.getByText("音声書き出し結果")).toBeVisible(); @@ -138,6 +155,8 @@ test.describe("音声書き出し", () => { await getQuasarMenu(page, "音声を繋げて書き出し").click(); }); + await handleVoiceLibraryPolicyDialog(page); + await test.step("エラーダイアログを確認して閉じる", async () => { const dialog = page.getByRole("dialog", { name: "書き出しに失敗しました。", diff --git "a/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-updatenotificationdialog--opened-light-storybook-win32.png" "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-updatenotificationdialog--opened-light-storybook-win32.png" index 2b6f1c8ab5..4175c8c3af 100644 Binary files "a/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-updatenotificationdialog--opened-light-storybook-win32.png" and "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-updatenotificationdialog--opened-light-storybook-win32.png" differ diff --git "a/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--long-url-dark-storybook-win32.png" "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--long-url-dark-storybook-win32.png" new file mode 100644 index 0000000000..be210c382d Binary files /dev/null and "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--long-url-dark-storybook-win32.png" differ diff --git "a/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--long-url-light-storybook-win32.png" "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--long-url-light-storybook-win32.png" new file mode 100644 index 0000000000..4af033be1f Binary files /dev/null and "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--long-url-light-storybook-win32.png" differ diff --git "a/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--opened-dark-storybook-win32.png" "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--opened-dark-storybook-win32.png" new file mode 100644 index 0000000000..dbc2c620de Binary files /dev/null and "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--opened-dark-storybook-win32.png" differ diff --git "a/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--opened-light-storybook-win32.png" "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--opened-light-storybook-win32.png" new file mode 100644 index 0000000000..7e98ec3448 Binary files /dev/null and "b/tests/e2e/storybook/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/components-dialog-voicelibrarypolicydialog--opened-light-storybook-win32.png" differ diff --git a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap index 31edbbd37f..f86d86a070 100644 --- a/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap +++ b/tests/unit/backend/common/__snapshots__/configManager.spec.ts.snap @@ -229,6 +229,7 @@ exports[`0.13.0からマイグレーションできる 1`] = ` "showTextLineNumber": false, "splitTextWhenPaste": "PERIOD_AND_NEW_LINE", "splitterPosition": {}, + "termConfirmedCharacterIds": [], "toolbarSetting": [ "PLAY_CONTINUOUSLY", "STOP",