From 8a4cdd289944343ca81f64f2ec9377b777fc1ecf Mon Sep 17 00:00:00 2001 From: sabonerune <102559104+sabonerune@users.noreply.github.com> Date: Sat, 6 Jul 2024 20:18:25 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=E3=83=97=E3=83=AA=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=82=92?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=E3=81=97=E3=81=9F=E6=99=82HMR(=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E3=83=AA=E3=83=AD=E3=83=BC=E3=83=89)=E3=81=8C?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=97=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=20(#2157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: プリロードスクリプトを変更した時HMR(自動リロード)が機能しない問題を修正 * Apply suggestions from code review --------- Co-authored-by: Hiroshiba --- src/backend/electron/main.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index edb8441d06..1744eacfff 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -1029,8 +1029,11 @@ app.on("web-contents-created", (e, contents) => { // ナビゲーションを無効化 contents.on("will-navigate", (event) => { - log.error(`ナビゲーションは無効化されています。url: ${event.url}`); - event.preventDefault(); + // preloadスクリプト変更時のホットリロードを許容する + if (contents.getURL() !== event.url) { + log.error(`ナビゲーションは無効化されています。url: ${event.url}`); + event.preventDefault(); + } }); }); From 3ce6c7ec4739c6a7b2ae9e752dceab634ecc14da Mon Sep 17 00:00:00 2001 From: sabonerune <102559104+sabonerune@users.noreply.github.com> Date: Sat, 6 Jul 2024 20:19:33 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20app=E3=83=97=E3=83=AD=E3=83=88?= =?UTF-8?q?=E3=82=B3=E3=83=AB=E3=81=A7fetch=E5=8F=AF=E8=83=BD=E3=81=AA?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E3=82=92?= =?UTF-8?q?=E5=88=B6=E9=99=90=E3=81=99=E3=82=8B=20(#2153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: appプロトコルでfetch可能なディレクトリを制限する * fix: 参考URL追加 * fix: メンテナンス性向上のため条件を簡略化 Co-authored-by: Hiroshiba --------- Co-authored-by: Hiroshiba --- src/backend/electron/main.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index 1744eacfff..96b6ff18b4 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -3,6 +3,7 @@ import path from "path"; import fs from "fs"; +import { pathToFileURL } from "url"; import { app, protocol, @@ -428,8 +429,23 @@ async function createWindow() { // ソフトウェア起動時はプロトコルを app にする if (process.env.VITE_DEV_SERVER_URL == undefined) { protocol.handle("app", (request) => { - const filePath = path.join(__dirname, new URL(request.url).pathname); - return net.fetch(`file://${filePath}`); + // 読み取り先のファイルがインストールディレクトリ内であることを確認する + // ref: https://www.electronjs.org/ja/docs/latest/api/protocol#protocolhandlescheme-handler + const { pathname } = new URL(request.url); + const pathToServe = path.resolve(path.join(__dirname, pathname)); + const relativePath = path.relative(__dirname, pathToServe); + const isUnsafe = + path.isAbsolute(relativePath) || + relativePath.startsWith("..") || + relativePath === ""; + if (isUnsafe) { + log.error(`Bad Request URL: ${request.url}`); + return new Response("bad", { + status: 400, + headers: { "content-type": "text/html" }, + }); + } + return net.fetch(pathToFileURL(pathToServe).toString()); }); } From cdb31d9f8dc45ac68bdd6ae92ce20aea2c55b64f Mon Sep 17 00:00:00 2001 From: Hiroshiba Date: Sat, 6 Jul 2024 20:20:33 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E3=80=8C=E9=9F=B3=E5=A3=B0=E3=82=92?= =?UTF-8?q?=E3=81=A4=E3=81=AA=E3=81=92=E3=81=A6=E6=9B=B8=E3=81=8D=E5=87=BA?= =?UTF-8?q?=E3=81=97=E3=80=8D=E3=81=AE=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=92=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B=20(#2131?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * generateWriteErrorMessage移動 * エラーメッセージを追加 * CONNECT_AND_EXPORT_TEXTも --- src/helpers/fileHelper.ts | 24 +++++++++ src/store/audio.ts | 107 ++++++++++++++++++-------------------- 2 files changed, 74 insertions(+), 57 deletions(-) create mode 100644 src/helpers/fileHelper.ts diff --git a/src/helpers/fileHelper.ts b/src/helpers/fileHelper.ts new file mode 100644 index 0000000000..6b8aa83568 --- /dev/null +++ b/src/helpers/fileHelper.ts @@ -0,0 +1,24 @@ +import { ResultError } from "@/type/result"; + +/** ファイル書き込み時のエラーメッセージを生成する */ +export function generateWriteErrorMessage(writeFileResult: ResultError) { + const code = writeFileResult.code?.toUpperCase(); + + if (code?.startsWith("ENOSPC")) { + return "空き容量が足りません。"; + } + + if (code?.startsWith("EACCES")) { + return "ファイルにアクセスする許可がありません。"; + } + + if (code?.startsWith("EBUSY")) { + return "ファイルが開かれています。"; + } + + if (code?.startsWith("ENOENT")) { + return "ファイルが見つかりません。"; + } + + return `何らかの理由で失敗しました。${writeFileResult.message}`; +} diff --git a/src/store/audio.ts b/src/store/audio.ts index 79bc6cf240..343ae6e8f8 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -57,6 +57,7 @@ import { import { AudioQuery, AccentPhrase, Speaker, SpeakerInfo } from "@/openapi"; import { base64ImageToUri, base64ToUri } from "@/helpers/base64Helper"; import { getValueOrThrow, ResultError } from "@/type/result"; +import { generateWriteErrorMessage } from "@/helpers/fileHelper"; function generateAudioKey() { return AudioKey(crypto.randomUUID()); @@ -166,25 +167,6 @@ export async function writeTextFile(obj: { }); } -function generateWriteErrorMessage(writeFileResult: ResultError) { - if (!writeFileResult.code) { - return `何らかの理由で失敗しました。${writeFileResult.message}`; - } - const code = writeFileResult.code.toUpperCase(); - - if (code.startsWith("ENOSPC")) { - return "空き容量が足りません。"; - } - - if (code.startsWith("EACCES")) { - return "ファイルにアクセスする許可がありません。"; - } - - if (code.startsWith("EBUSY")) { - return "ファイルが開かれています。"; - } -} - // TODO: GETTERに移動する。 export function getCharacterInfo( state: State, @@ -1522,8 +1504,6 @@ export const audioStore = createPartialStore({ const labs: string[] = []; const texts: string[] = []; - let labOffset = 0; - const base64Encoder = (blob: Blob): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -1544,6 +1524,7 @@ export const audioStore = createPartialStore({ const totalCount = state.audioKeys.length; let finishedCount = 0; + let labOffset = 0; for (const audioKey of state.audioKeys) { let fetchAudioResult: FetchAudioResult; try { @@ -1565,21 +1546,21 @@ export const audioStore = createPartialStore({ return { result: "WRITE_ERROR", path: filePath }; } encodedBlobs.push(encodedBlob); + // 大して処理能力を要しないので、生成設定のon/offにかかわらず生成してしまう const lab = await generateLabFromAudioQuery(audioQuery, labOffset); - if (lab == undefined) { - return { result: "WRITE_ERROR", path: filePath }; - } labs.push(lab); + + // 最終音素の終了時刻を取得する + const splitLab = lab.split(" "); + labOffset = Number(splitLab[splitLab.length - 2]); + texts.push( extractExportText(state.audioItems[audioKey].text, { enableMemoNotation: state.enableMemoNotation, enableRubyNotation: state.enableRubyNotation, }), ); - // 最終音素の終了時刻を取得する - const splitLab = lab.split(" "); - labOffset = Number(splitLab[splitLab.length - 2]); } const connectedWav = await dispatch("CONNECT_AUDIO", { @@ -1589,40 +1570,48 @@ export const audioStore = createPartialStore({ return { result: "ENGINE_ERROR", path: filePath }; } - const writeFileResult = await window.backend.writeFile({ - filePath, - buffer: await connectedWav.arrayBuffer(), - }); - if (!writeFileResult.ok) { - window.backend.logError(writeFileResult.error); - return { result: "WRITE_ERROR", path: filePath }; - } + try { + await window.backend + .writeFile({ + filePath, + buffer: await connectedWav.arrayBuffer(), + }) + .then(getValueOrThrow); - if (state.savingSetting.exportLab) { - const labResult = await writeTextFile({ - // `generateLabFromAudioQuery`で生成される文字列はすべて改行で終わるので、追加で改行を挟む必要はない - text: labs.join(""), - filePath: filePath.replace(/\.wav$/, ".lab"), - }); - if (!labResult.ok) { - window.backend.logError(labResult.error); - return { result: "WRITE_ERROR", path: filePath }; + if (state.savingSetting.exportLab) { + await writeTextFile({ + // `generateLabFromAudioQuery`で生成される文字列はすべて改行で終わるので、追加で改行を挟む必要はない + text: labs.join(""), + filePath: filePath.replace(/\.wav$/, ".lab"), + }).then(getValueOrThrow); } - } - if (state.savingSetting.exportText) { - const textResult = await writeTextFile({ - text: texts.join("\n"), - filePath: filePath.replace(/\.wav$/, ".txt"), - encoding: state.savingSetting.fileEncoding, - }); - if (!textResult.ok) { - window.backend.logError(textResult.error); - return { result: "WRITE_ERROR", path: filePath }; + if (state.savingSetting.exportText) { + await writeTextFile({ + text: texts.join("\n"), + filePath: filePath.replace(/\.wav$/, ".txt"), + encoding: state.savingSetting.fileEncoding, + }).then(getValueOrThrow); } - } - return { result: "SUCCESS", path: filePath }; + return { result: "SUCCESS", path: filePath }; + } catch (e) { + window.backend.logError(e); + if (e instanceof ResultError) { + return { + result: "WRITE_ERROR", + path: filePath, + errorMessage: generateWriteErrorMessage(e), + }; + } + return { + result: "UNKNOWN_ERROR", + path: filePath, + errorMessage: + (e instanceof Error ? e.message : String(e)) || + "不明なエラーが発生しました。", + }; + } }, ), }, @@ -1701,7 +1690,11 @@ export const audioStore = createPartialStore({ }); if (!result.ok) { window.backend.logError(result.error); - return { result: "WRITE_ERROR", path: filePath }; + return { + result: "WRITE_ERROR", + path: filePath, + errorMessage: generateWriteErrorMessage(new ResultError(result)), + }; } return { result: "SUCCESS", path: filePath }; From 5ef5d650d60dd8ef624b33ee8bac3fa86f5f527a Mon Sep 17 00:00:00 2001 From: sabonerune <102559104+sabonerune@users.noreply.github.com> Date: Sun, 7 Jul 2024 09:15:37 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20tsconfig=E3=82=92Vite=E3=81=AE?= =?UTF-8?q?=E6=8E=A8=E5=A5=A8=E3=81=99=E3=82=8B=E8=A8=AD=E5=AE=9A=E3=81=AB?= =?UTF-8?q?=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B=20(#2162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/vuex.ts | 8 +++----- tsconfig.json | 6 ++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/store/vuex.ts b/src/store/vuex.ts index c3a5ad7b77..37ab417996 100644 --- a/src/store/vuex.ts +++ b/src/store/vuex.ts @@ -35,20 +35,18 @@ export class Store< > extends BaseStore { constructor(options: StoreOptions) { super(options as OriginalStoreOptions); - // @ts-expect-error Storeの型を書き換えている影響で未初期化として判定される this.actions = dotNotationDispatchProxy(this.dispatch.bind(this)); this.mutations = dotNotationCommitProxy( - // @ts-expect-error Storeの型を書き換えている影響で未初期化として判定される this.commit.bind(this) as Commit, ); } - readonly getters!: G; + declare readonly getters: G; // @ts-expect-error Storeの型を非互換な型で書き換えているためエラー - dispatch: Dispatch; + declare dispatch: Dispatch; // @ts-expect-error Storeの型を非互換な型で書き換えているためエラー - commit: Commit; + declare commit: Commit; /** * ドット記法用のActionを直接呼べる。エラーになる場合はdispatchを使う。 * 詳細 https://github.com/VOICEVOX/voicevox/issues/2088 diff --git a/tsconfig.json b/tsconfig.json index aaa6b2178a..fe3a92f753 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,16 @@ { "compilerOptions": { - "target": "ES6", + "target": "esnext", "module": "esnext", "strict": true, "jsx": "preserve", + "jsxImportSource": "vue", "importHelpers": true, "moduleResolution": "bundler", "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, + "isolatedModules": true, "resolveJsonModule": true, "sourceMap": true, "baseUrl": ".", @@ -16,7 +18,7 @@ "paths": { "@/*": ["src/*"] }, - "lib": ["esnext", "dom", "dom.iterable", "scripthost"] + "lib": ["esnext", "dom", "dom.iterable"] }, "include": [ "src/**/*.ts", From 1f39897f28ffaf93cd5df3e391a4c5a22e1d37cf Mon Sep 17 00:00:00 2001 From: White-Green <43771790+White-Green@users.noreply.github.com> Date: Sun, 7 Jul 2024 10:18:57 +0900 Subject: [PATCH 5/5] =?UTF-8?q?applyPatches=E3=82=92=E8=87=AA=E5=89=8D?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=AB=E7=BD=AE=E3=81=8D=E6=8F=9B=E3=81=88?= =?UTF-8?q?=E3=82=8B=20(#2052)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "applyPatchesをimmerのものを利用するように変更 (#362)" This reverts commit c4f96ff8158467fb4f77b71d303b002492c265e7. * npm install * implement applyPatches * drop rfc6902 * doc * ignore type error * fix typo * fix error by structuredClone * refactor * support Map and Set * add note comment * support Map/Set on clone * assert equals on clone * support original classes * shrink changes * use internal applyPatches for testing * simplify tests * remove tests for function * add tests for patches * add tests for Map * add tests for error * format * add test for remove from array * add test for "-" path * add tests for error * Revert "use internal applyPatches for testing" This reverts commit 7bedef38f446915ea534a6170f956fac0d74449e. * rename immerPatch into immerPatchUtility * use splice on remove from array * fix add operation into array * refactor tests * add test for insert element into array * drop support for userClass * drop support for un-cloneable value * 微調整 --------- Co-authored-by: Hiroshiba Kazuyuki --- src/store/command.ts | 18 +- src/store/immerPatchUtility.ts | 144 ++++++ tests/unit/store/immerPatchUtility.spec.ts | 525 +++++++++++++++++++++ 3 files changed, 673 insertions(+), 14 deletions(-) create mode 100644 src/store/immerPatchUtility.ts create mode 100644 tests/unit/store/immerPatchUtility.spec.ts diff --git a/src/store/command.ts b/src/store/command.ts index 0ddebbadc5..83de848ed3 100644 --- a/src/store/command.ts +++ b/src/store/command.ts @@ -1,12 +1,8 @@ import { toRaw } from "vue"; import { enablePatches, enableMapSet, Immer } from "immer"; -// immerの内部関数であるgetPlugin("Patches").applyPatches_はexportされていないので -// ビルド前のsrcからソースコードを読み込んで使う必要がある -import { enablePatches as enablePatchesImpl } from "immer/src/plugins/patches"; -import { enableMapSet as enableMapSetImpl } from "immer/src/plugins/mapset"; -import { getPlugin } from "immer/src/utils/plugins"; import { Command, CommandStoreState, CommandStoreTypes, State } from "./type"; +import { applyPatches } from "@/store/immerPatchUtility"; import { createPartialStore, Mutation, @@ -15,14 +11,8 @@ import { } from "@/store/vuex"; import { EditorType } from "@/type/preload"; -// ビルド後のモジュールとビルド前のモジュールは別のスコープで変数を持っているので -// enable * も両方叩く必要がある。 enablePatches(); enableMapSet(); -enablePatchesImpl(); -enableMapSetImpl(); -// immerのPatchをmutableに適応する内部関数 -const applyPatchesImpl = getPlugin("Patches").applyPatches_; const immer = new Immer(); immer.setAutoFreeze(false); @@ -60,7 +50,7 @@ export const createCommandMutation = ): Mutation => (state: S, payload: M[K]): void => { const command = recordPatches(payloadRecipe)(state, payload); - applyPatchesImpl(state, command.redoPatches); + applyPatches(state, command.redoPatches); state.undoCommands[editor].push(command); state.redoCommands[editor].splice(0); }; @@ -112,7 +102,7 @@ export const commandStore = createPartialStore({ const command = state.undoCommands[editor].pop(); if (command != null) { state.redoCommands[editor].push(command); - applyPatchesImpl(state, command.undoPatches); + applyPatches(state, command.undoPatches); } }, action({ commit, dispatch }, { editor }: { editor: EditorType }) { @@ -130,7 +120,7 @@ export const commandStore = createPartialStore({ const command = state.redoCommands[editor].pop(); if (command != null) { state.undoCommands[editor].push(command); - applyPatchesImpl(state, command.redoPatches); + applyPatches(state, command.redoPatches); } }, action({ commit, dispatch }, { editor }: { editor: EditorType }) { diff --git a/src/store/immerPatchUtility.ts b/src/store/immerPatchUtility.ts new file mode 100644 index 0000000000..b8ca53c093 --- /dev/null +++ b/src/store/immerPatchUtility.ts @@ -0,0 +1,144 @@ +import { Patch } from "immer"; +import { ExhaustiveError } from "@/type/utility"; + +/** + * produceWithPatchesにより生成された複数のパッチをオブジェクトに適用する。 + * + * @param {T} target パッチを適用する対象オブジェクト + * @param {Patch[]} patches 適用するパッチの配列 + * @template T 対象オブジェクトの型(任意) + */ +export function applyPatches(target: T, patches: Patch[]) { + for (const patch of patches) { + applyPatch(target, patch); + } +} + +function isObject(value: unknown): value is object { + return typeof value === "object" && value != null; +} + +// 値を再帰的に複製する。 +// ユーザー定義クラスのインスタンスや関数など、複製しない値で例外を発生させる。 +function clone(value: T): T { + // function等、単純にcloneできない値の取り扱いを禁止する + if (!isObject(value)) return structuredClone(value); + + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) + throw new Error("unsupported type"); + return value.map((v) => clone(v)) as T; + } + + if (value instanceof Map) { + if (Object.getPrototypeOf(value) !== Map.prototype) + throw new Error("unsupported type"); + const result = new Map(); + for (const [k, v] of value.entries()) { + result.set(clone(k), clone(v)); + } + return result as T; + } + + if (value instanceof Set) { + if (Object.getPrototypeOf(value) !== Set.prototype) + throw new Error("unsupported type"); + const result = new Set(); + for (const v of value.values()) { + result.add(clone(v)); + } + return result as T; + } + + // object以外の自前classは現状利用していないため不具合予防として一旦禁止 + // 適切な動作テストの後制限を緩めることを妨げるものではない + if (Object.getPrototypeOf(value) !== Object.prototype) + throw new Error("unsupported type"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any = Object.create(Object.getPrototypeOf(value)); + for (const [k, v] of Object.entries(value)) { + result[k] = clone(v); + } + return result as T; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function get(value: unknown, key: string | number): any { + if (value instanceof Map) { + return value.get(key); + } + // @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う + return value[key]; +} + +function add(value: unknown, key: string | number, v: unknown): void { + if (value instanceof Map) { + value.set(key, v); + } else if (value instanceof Set) { + value.add(v); + } else if (Array.isArray(value)) { + if (typeof key === "number") { + value.splice(key, 0, v); + } else if (key === "-") { + value.push(v); + } else { + throw new Error("unsupported key"); + } + } else { + // @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う + value[key] = v; + } +} + +function replace(value: unknown, key: string | number, v: unknown): void { + if (value instanceof Map) { + value.set(key, v); + } else if (value instanceof Set) { + value.add(v); + } else { + // @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う + value[key] = v; + } +} + +function remove(value: unknown, key: string | number, v: unknown): void { + if (value instanceof Map) { + value.delete(key); + } else if (value instanceof Set) { + value.delete(v); + } else if (Array.isArray(value) && typeof key === "number") { + value.splice(key, 1); + } else { + // @ts-expect-error produceWithPatchesにより生成されたPatchを適用するため、valueはany型として扱う + delete value[key]; + } +} + +/** + * produceWithPatchesにより生成された単一のパッチをオブジェクトに適用する。 + * + * @param {T} target パッチを適用する対象オブジェクト + * @param {Patch} patch 適用するパッチ + * @template T 対象オブジェクトの型(任意) + */ +function applyPatch(target: T, patch: Patch) { + const { path, value, op } = patch; + for (const p of patch.path.slice(0, path.length - 1)) { + target = get(target, p); + } + const v = clone(value); + switch (op) { + case "add": + add(target, path[path.length - 1], v); + break; + case "replace": + replace(target, path[path.length - 1], v); + break; + case "remove": + remove(target, path[path.length - 1], v); + break; + default: + throw new ExhaustiveError(op); + } +} diff --git a/tests/unit/store/immerPatchUtility.spec.ts b/tests/unit/store/immerPatchUtility.spec.ts new file mode 100644 index 0000000000..107932e00f --- /dev/null +++ b/tests/unit/store/immerPatchUtility.spec.ts @@ -0,0 +1,525 @@ +import { enableMapSet, enablePatches, Immer, Patch } from "immer"; +import { applyPatches } from "@/store/immerPatchUtility"; + +describe("object", () => { + test("add/remove - 1", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: { a: number; b: number; c?: number } = { a: 1, b: 2 }; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.c = 3; + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([{ op: "add", path: ["c"], value: 3 }]); + expect(undoPatches).toStrictEqual([{ op: "remove", path: ["c"] }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual({ a: 1, b: 2, c: 3 }); + applyPatches(object, undoPatches); + expect(object).toStrictEqual({ a: 1, b: 2 }); + }); + + test("add/remove - 2", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: { a: number; b: number; c?: number } = { a: 1, b: 2, c: 3 }; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + delete obj.c; + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([{ op: "remove", path: ["c"] }]); + expect(undoPatches).toStrictEqual([{ op: "add", path: ["c"], value: 3 }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual({ a: 1, b: 2 }); + applyPatches(object, undoPatches); + expect(object).toStrictEqual({ a: 1, b: 2, c: 3 }); + }); + + test("replace", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: { a: number; b: number } = { a: 1, b: 2 }; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.a = 3; + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "replace", path: ["a"], value: 3 }, + ]); + expect(undoPatches).toStrictEqual([ + { op: "replace", path: ["a"], value: 1 }, + ]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual({ a: 3, b: 2 }); + applyPatches(object, undoPatches); + expect(object).toStrictEqual({ a: 1, b: 2 }); + }); +}); + +describe("array", () => { + test("add/replace - 1", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: number[] = [1, 2, 3]; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.push(4); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([{ op: "add", path: [3], value: 4 }]); + expect(undoPatches).toStrictEqual([ + { op: "replace", path: ["length"], value: 3 }, + ]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual([1, 2, 3, 4]); + applyPatches(object, undoPatches); + expect(object).toStrictEqual([1, 2, 3]); + }); + + test("add/replace - 2", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: number[] = [1, 2, 3]; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.splice(1, 0, 4); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "replace", path: [1], value: 4 }, + { op: "replace", path: [2], value: 2 }, + { op: "add", path: [3], value: 3 }, + ]); + expect(undoPatches).toStrictEqual([ + { op: "replace", path: [1], value: 2 }, + { op: "replace", path: [2], value: 3 }, + { op: "replace", path: ["length"], value: 3 }, + ]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual([1, 4, 2, 3]); + applyPatches(object, undoPatches); + expect(object).toStrictEqual([1, 2, 3]); + }); + + test("add/replace - 3", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: number[] = [1, 2, 3]; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.unshift(4); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "replace", path: [0], value: 4 }, + { op: "replace", path: [1], value: 1 }, + { op: "replace", path: [2], value: 2 }, + { op: "add", path: [3], value: 3 }, + ]); + expect(undoPatches).toStrictEqual([ + { op: "replace", path: [0], value: 1 }, + { op: "replace", path: [1], value: 2 }, + { op: "replace", path: [2], value: 3 }, + { op: "replace", path: ["length"], value: 3 }, + ]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual([4, 1, 2, 3]); + applyPatches(object, undoPatches); + expect(object).toStrictEqual([1, 2, 3]); + }); + + test("add/replace - 4", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: number[] = [1, 2, 3]; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.splice(1, 1); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "replace", path: [1], value: 3 }, + { op: "replace", path: ["length"], value: 2 }, + ]); + expect(undoPatches).toStrictEqual([ + { op: "replace", path: [1], value: 2 }, + { op: "add", path: [2], value: 3 }, + ]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual([1, 3]); + applyPatches(object, undoPatches); + expect(object).toStrictEqual([1, 2, 3]); + }); + + test("add/replace - 5", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: number[] = [1, 2, 3]; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.pop(); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "replace", path: ["length"], value: 2 }, + ]); + expect(undoPatches).toStrictEqual([{ op: "add", path: [2], value: 3 }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual([1, 2]); + applyPatches(object, undoPatches); + expect(object).toStrictEqual([1, 2, 3]); + }); + + test("add - 1", () => { + // pathとして"-"を渡した際の挙動はRFC6902に規定されているが、現バージョンのimmerはこのpathを生成しないため専用のテストケースを用意する + const object = [1, 2, 3]; + const patch: Patch[] = [{ op: "add", path: ["-"], value: 4 }]; + applyPatches(object, patch); + expect(object).toStrictEqual([1, 2, 3, 4]); + }); + + test("add - 2", () => { + // pathとして配列のlength未満の値を渡した際はinsertが行われることが期待される(RFC6902にてそのように規定されており、immerのapplyPatchesもそのような動作をする) + // 現バージョンのimmerはこのpathを生成しないため専用のテストケースを用意する + const object = [1, 2, 3]; + const patch: Patch[] = [{ op: "add", path: [1], value: 4 }]; + applyPatches(object, patch); + expect(object).toStrictEqual([1, 4, 2, 3]); + }); + + test("replace", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: number[] = [1, 2, 3]; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj[1] = 4; + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([{ op: "replace", path: [1], value: 4 }]); + expect(undoPatches).toStrictEqual([{ op: "replace", path: [1], value: 2 }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual([1, 4, 3]); + applyPatches(object, undoPatches); + expect(object).toStrictEqual([1, 2, 3]); + }); + + // immer 9.0.21において{ op: "remove" }を持つPatchがArrayに対して発行されないため、専用のテストケースを用意する + test("remove - 1", () => { + const object = [1, 2, 3]; + const patch: Patch[] = [{ op: "remove", path: [1] }]; + applyPatches(object, patch); + expect(object).toStrictEqual([1, 3]); + }); + + test("remove - 2", () => { + const object = [1, 2, 3]; + const patch: Patch[] = [{ op: "remove", path: [2] }]; + applyPatches(object, patch); + expect(object).toStrictEqual([1, 2]); + }); + + test("remove - 3", () => { + const object = [1, 2, 3]; + const patch: Patch[] = [{ op: "remove", path: [0] }]; + applyPatches(object, patch); + expect(object).toStrictEqual([2, 3]); + }); +}); + +describe("complexObject", () => { + test("complexObject - 1", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: { + a: number; + b: number; + c: { d: number; e?: { f: string; g: [number, number] } }; + } = { a: 1, b: 2, c: { d: 3 } }; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.c.e = { f: "4", g: [5, 6] }; + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "add", path: ["c", "e"], value: { f: "4", g: [5, 6] } }, + ]); + expect(undoPatches).toStrictEqual([{ op: "remove", path: ["c", "e"] }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual({ + a: 1, + b: 2, + c: { d: 3, e: { f: "4", g: [5, 6] } }, + }); + applyPatches(object, undoPatches); + expect(object).toStrictEqual({ a: 1, b: 2, c: { d: 3 } }); + }); + + test("complexObject - 2", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: { + a: number; + b: number; + c: { d: number; e: { f: string; g: [number, number] } }; + } = { a: 1, b: 2, c: { d: 3, e: { f: "4", g: [5, 6] } } }; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.c.e.g[0] = 7; + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "replace", path: ["c", "e", "g", 0], value: 7 }, + ]); + expect(undoPatches).toStrictEqual([ + { op: "replace", path: ["c", "e", "g", 0], value: 5 }, + ]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual({ + a: 1, + b: 2, + c: { d: 3, e: { f: "4", g: [7, 6] } }, + }); + applyPatches(object, undoPatches); + expect(object).toStrictEqual({ + a: 1, + b: 2, + c: { d: 3, e: { f: "4", g: [5, 6] } }, + }); + }); + + test("complexObject - 3", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + + const object: { + a: number; + b: number; + c: { d: number; e: { f: string; g?: [number, number] } }; + } = { a: 1, b: 2, c: { d: 3, e: { f: "4", g: [5, 6] } } }; + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + delete obj.c.e.g; + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([ + { op: "remove", path: ["c", "e", "g"] }, + ]); + expect(undoPatches).toStrictEqual([ + { op: "add", path: ["c", "e", "g"], value: [5, 6] }, + ]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual({ a: 1, b: 2, c: { d: 3, e: { f: "4" } } }); + applyPatches(object, undoPatches); + expect(object).toStrictEqual({ + a: 1, + b: 2, + c: { d: 3, e: { f: "4", g: [5, 6] } }, + }); + }); +}); + +describe("Map", () => { + test("add/remove", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + enableMapSet(); + + const object: Map = new Map([ + [1, 2], + [3, 4], + ]); + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.set(5, 6); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([{ op: "add", path: [5], value: 6 }]); + expect(undoPatches).toStrictEqual([{ op: "remove", path: [5] }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual( + new Map([ + [1, 2], + [3, 4], + [5, 6], + ]), + ); + applyPatches(object, undoPatches); + expect(object).toStrictEqual( + new Map([ + [1, 2], + [3, 4], + ]), + ); + }); + + test("replace", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + enableMapSet(); + + const object: Map = new Map([ + [1, 2], + [3, 4], + ]); + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.set(3, 5); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([{ op: "replace", path: [3], value: 5 }]); + expect(undoPatches).toStrictEqual([{ op: "replace", path: [3], value: 4 }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual( + new Map([ + [1, 2], + [3, 5], + ]), + ); + applyPatches(object, undoPatches); + expect(object).toStrictEqual( + new Map([ + [1, 2], + [3, 4], + ]), + ); + }); +}); + +describe("Set", () => { + test("add/remove", () => { + const immer = new Immer(); + immer.setAutoFreeze(false); + enablePatches(); + enableMapSet(); + + const object: Set = new Set([1, 2, 3]); + const [, redoPatches, undoPatches] = immer.produceWithPatches( + object, + (obj) => { + obj.delete(2); + }, + ); + // テストケースの可視化 + expect(redoPatches).toStrictEqual([{ op: "remove", path: [1], value: 2 }]); + expect(undoPatches).toStrictEqual([{ op: "add", path: [1], value: 2 }]); + + applyPatches(object, redoPatches); + expect(object).toStrictEqual(new Set([1, 3])); + applyPatches(object, undoPatches); + expect(object).toStrictEqual(new Set([1, 2, 3])); + }); +}); + +test("expect-throws", () => { + // pathを辿っている途中で存在しないプロパティにアクセスしようとした場合例外が発生する + expect(() => { + applyPatches({}, [{ op: "add", path: ["a", "b"], value: 1 }]); + }).toThrow(); + expect(() => { + applyPatches({ a: {} }, [ + { op: "remove", path: ["a", "b", "c"], value: 1 }, + ]); + }).toThrow(); + expect(() => { + applyPatches({ a: [] }, [ + { op: "replace", path: ["a", "b", "c", "d"], value: 1 }, + ]); + }).toThrow(); + expect(() => { + applyPatches([], [{ op: "add", path: [0, "a"], value: 1 }]); + }).toThrow(); + expect(() => { + applyPatches([{}], [{ op: "remove", path: [0, "a", "b"], value: 1 }]); + }).toThrow(); + expect(() => { + applyPatches({ a: [] }, [ + { op: "replace", path: ["a", 0, "b", "c"], value: 1 }, + ]); + }).toThrow(); +}); + +describe("unsupported", () => { + test("userClass", () => { + class MyClass {} + + expect(() => { + applyPatches({}, [{ op: "add", path: ["a"], value: new MyClass() }]); + }).toThrow(); + }); + + test("un-cloneable", () => { + expect(() => { + applyPatches({}, [{ op: "add", path: ["a"], value: () => {} }]); + }).toThrow(); + }); +});