From 8dcf002921631ca0272ef7d5d45d666c8008d146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 19 Jun 2026 11:09:20 +0900 Subject: [PATCH] =?UTF-8?q?test:=201.0=20result=20=EA=B3=84=EC=95=BD=20fix?= =?UTF-8?q?ture=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/public/api.md | 13 ++ docs/standard/result-contract.md | 12 +- llms.txt | 5 + .../tests/public/result-contract.test.ts | 158 ++++++++++++++++++ .../tests/public/signature-contract.test-d.ts | 77 +++++++++ 5 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 packages/json-document/tests/public/result-contract.test.ts diff --git a/docs/public/api.md b/docs/public/api.md index 30ad3a16..a620412d 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -266,6 +266,19 @@ doc.paste({ after: "/lists/0/cards/0" }); Pointer 배열을 copy/cut하면 clipboard payload도 배열입니다. 여러 source를 담은 clipboard buffer는 array 삽입 target에 기본으로 펼쳐집니다. +성공 result shape는 method family별로 다릅니다. + +| Method | 성공 result | +| --- | --- | +| `insert`, `replace`, `delete`, `move`, `patch`, `commit` | `{ ok: true }`; 실제 patch는 `doc.lastPatch`, subscriber, history에 기록 | +| `copy` | `{ ok: true, payload, source, sources }` | +| `cut` | `{ ok: true, value, applied, payload, source, sources }` | +| `paste` | `{ ok: true, value, applied }` | +| `duplicate` | `{ ok: true, value, applied, duplicatedTo }` | + +`applied`는 이미 commit된 JSON Patch입니다. 다시 `commit`하지 않습니다. 실패 +result는 `reason` 문구가 아니라 stable `code`로 분기합니다. + ## history History는 document patch와 inverse patch를 기록합니다. diff --git a/docs/standard/result-contract.md b/docs/standard/result-contract.md index 0112260b..77ddf775 100644 --- a/docs/standard/result-contract.md +++ b/docs/standard/result-contract.md @@ -133,8 +133,9 @@ Clipboard와 structural command result는 `ok` discriminant를 공유하지만 A | Family | 성공 payload | | --- | --- | | read/query/entries | 읽은 값 또는 pointer 목록 | -| copy | clipboard `payload`, source pointer | -| cut | next `value`, `applied`, clipboard `payload`, source pointer | +| `insert`, `replace`, `delete`, `move` | `JSONResult` 성공 `{ ok: true }`; 실제 commit patch는 `doc.lastPatch`, subscriber, history entry에 기록 | +| copy | clipboard `payload`, primary `source`, all `sources` | +| cut | next `value`, `applied`, clipboard `payload`, primary `source`, all `sources` | | paste | next `value`, `applied` | | duplicate | next `value`, `applied`, `duplicatedTo` | | undo/redo | top-level document command는 `JSONCapabilityResult` | @@ -142,6 +143,12 @@ Clipboard와 structural command result는 `ok` discriminant를 공유하지만 A Clipboard family와 structural result의 실패 diagnostic field는 `reason`이다. Stable branch key는 `code`다. +`applied`를 가진 성공 result의 `value`는 commit 후 document value다. 같은 +document instance에서 읽는 `doc.value`와 같은 최종 state를 가리킨다. 반대로 +`insert`, `replace`, `delete`, `move`, `patch`, `commit`은 low-level +`JSONResult` family라 성공 result 자체에 `applied`를 싣지 않는다. 이 family의 +호출자는 `doc.lastPatch`나 subscriber metadata를 통해 실제 applied patch를 읽는다. + `discriminator_mismatch`는 추가 정보를 제공한다. ```ts @@ -188,6 +195,7 @@ throw 여부가 아니라 result contract의 `code`로 실패를 분류해야 - `ok` discriminant 제거 또는 의미 변경. - 기존 error code 제거, rename, 의미 변경. - 실패 result의 `code` 제거. +- `copy`/`cut`의 primary `source`와 all `sources` 의미 변경. - `violations[].path`를 JSON Pointer가 아닌 형식으로 변경. - `schema-slot`과 `document-result` violation path 기준 변경. - 성공 mutation result에서 `applied` 의미를 실제 commit patch가 아닌 것으로 변경. diff --git a/llms.txt b/llms.txt index 58be2cb8..2738d206 100644 --- a/llms.txt +++ b/llms.txt @@ -197,6 +197,11 @@ const duplicated = doc.duplicate("/lists/0/cards/0", { ``` `duplicate`, `doc.cut`, `doc.paste`는 즉시 mutate한다. 성공 결과의 `applied`는 이미 적용된 patch record이므로 다시 `commit`하지 않는다. +`insert`, `replace`, `delete`, `move`, `patch`, `commit`의 성공 result는 `JSONResult` family인 `{ ok: true }`다. 실제 applied patch는 `doc.lastPatch`, subscriber, history entry에서 읽는다. +`copy` 성공 result는 `{ ok: true, payload, source, sources }`다. +`cut` 성공 result는 `{ ok: true, value, applied, payload, source, sources }`다. +`paste` 성공 result는 `{ ok: true, value, applied }`다. +`duplicate` 성공 result는 `{ ok: true, value, applied, duplicatedTo }`다. ## 선택 diff --git a/packages/json-document/tests/public/result-contract.test.ts b/packages/json-document/tests/public/result-contract.test.ts new file mode 100644 index 00000000..f9225322 --- /dev/null +++ b/packages/json-document/tests/public/result-contract.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "vitest"; +import * as z from "zod"; + +import { createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; + +const Item = z.object({ + id: z.string(), + title: z.string(), +}); + +const Schema = z.object({ + items: z.array(Item), + meta: z.record(z.string(), z.string()), + title: z.string(), +}); + +type Value = z.output; + +const initial: Value = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + meta: { owner: "core" }, + title: "Document", +}; + +function createDoc() { + return createJSONDocument(Schema, initial, { + history: 20, + selection: { mode: "multiple", initial: ["/items/0"] }, + }); +} + +function expectOkKeys(result: unknown, keys: ReadonlyArray) { + expect(result).toMatchObject({ ok: true }); + expect(Object.keys(result as Record).sort()).toEqual([...keys].sort()); +} + +describe("json-document 1.0 result contract", () => { + test("locks JSONResult mutation shape and lastPatch handoff", () => { + const doc = createDoc(); + + const insertPatch: JSONPatchOperation[] = [ + { op: "add", path: "/items/2", value: { id: "c", title: "C" } }, + ]; + const inserted = doc.insert("/items/-", { id: "c", title: "C" }); + expect(inserted).toEqual({ ok: true }); + expect(doc.lastPatch).toEqual(insertPatch); + + const replaced = doc.replace("/items/2/title", "C1"); + expect(replaced).toEqual({ ok: true }); + expect(doc.lastPatch).toEqual([{ op: "replace", path: "/items/2/title", value: "C1" }]); + + const moved = doc.move("/items/2", { before: "/items/0" }); + expect(moved).toEqual({ ok: true }); + expect(doc.lastPatch).toEqual([{ op: "move", from: "/items/2", path: "/items/0" }]); + + const deleted = doc.delete("/items/0"); + expect(deleted).toEqual({ ok: true }); + expect(doc.lastPatch).toEqual([{ op: "remove", path: "/items/0" }]); + }); + + test("locks clipboard and structural command success payloads", () => { + const doc = createDoc(); + + const copied = doc.copy(["/items/0", "/items/1"]); + expectOkKeys(copied, ["ok", "payload", "source", "sources"]); + if (!copied.ok) return; + expect(copied).toMatchObject({ + payload: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + source: "/items/0", + sources: ["/items/0", "/items/1"], + }); + expect(doc.value).toEqual(initial); + expect(doc.lastPatch).toEqual([]); + + const pasted = doc.paste("/items/-"); + expectOkKeys(pasted, ["ok", "value", "applied"]); + if (!pasted.ok) return; + expect(pasted.applied).toEqual([ + { op: "add", path: "/items/2", value: { id: "a", title: "A" } }, + { op: "add", path: "/items/3", value: { id: "b", title: "B" } }, + ]); + expect(pasted.value).toBe(doc.value); + expect(doc.lastPatch).toEqual(pasted.applied); + + const duplicated = doc.duplicate("/items/0", { + rekey: { fields: ["id"], strategy: "suffix" }, + }); + expectOkKeys(duplicated, ["ok", "value", "applied", "duplicatedTo"]); + if (!duplicated.ok) return; + expect(duplicated.duplicatedTo).toBe("/items/1"); + expect(duplicated.applied).toEqual([ + { + op: "add", + path: "/items/1", + value: { id: "a-copy", title: "A" }, + }, + ]); + expect(duplicated.value).toBe(doc.value); + expect(doc.lastPatch).toEqual(duplicated.applied); + + const cut = doc.cut("/items/1"); + expectOkKeys(cut, ["ok", "value", "applied", "payload", "source", "sources"]); + if (!cut.ok) return; + expect(cut).toMatchObject({ + applied: [{ op: "remove", path: "/items/1" }], + payload: { id: "a-copy", title: "A" }, + source: "/items/1", + sources: ["/items/1"], + }); + expect(cut.value).toBe(doc.value); + expect(doc.lastPatch).toEqual(cut.applied); + }); + + test("locks failure code payloads without depending on reason text", () => { + const doc = createDoc(); + + expect(doc.at("items/0")).toMatchObject({ + ok: false, + code: "invalid_pointer", + pointer: "items/0", + }); + expect(doc.query("$.items[")).toMatchObject({ + ok: false, + code: "invalid_query", + }); + expect(doc.canUndo()).toMatchObject({ + ok: false, + code: "empty_stack", + }); + expect(doc.canReplace("/items/0/id", 1)).toMatchObject({ + ok: false, + code: "schema_violation", + violations: [{ path: "/items/0/id", message: expect.any(String) }], + }); + expect(doc.copy([])).toMatchObject({ + ok: false, + code: "empty_selection", + }); + expect(doc.clipboard.write({ fn: () => undefined })).toMatchObject({ + ok: false, + code: "not_serializable", + }); + expect(doc.paste("/items/-")).toMatchObject({ + ok: false, + code: "empty_clipboard", + }); + expect(doc.duplicate("/meta/owner")).toMatchObject({ + ok: false, + code: "missing_new_key", + }); + }); +}); diff --git a/packages/json-document/tests/public/signature-contract.test-d.ts b/packages/json-document/tests/public/signature-contract.test-d.ts index 04b757cd..36088e60 100644 --- a/packages/json-document/tests/public/signature-contract.test-d.ts +++ b/packages/json-document/tests/public/signature-contract.test-d.ts @@ -6,11 +6,16 @@ import { applyPatch, applyPatchToTrustedState, createJSONDocument, + type ClipboardCopyError, type ClipboardCopyOptions, type ClipboardCopyResult, + type ClipboardCutError, type ClipboardCutOptions, type ClipboardCutResult, + type ClipboardEmpty, + type ClipboardPasteError, type ClipboardPasteResult, + type ClipboardReadResult, type ClipboardState, type EntriesResult, type JSONCapabilityResult, @@ -101,6 +106,59 @@ type FixtureApplyResult = { applied: ReadonlyArray; }; +type ExpectedJSONResultCode = + | "invalid_pointer" + | "path_not_found" + | "move_into_self" + | "schema_violation" + | "test_failed" + | "not_serializable"; + +type ExpectedCapabilityErrorCode = + | ExpectedJSONResultCode + | "preflight_failed" + | "discriminator_mismatch" + | "rekey_failed" + | "missing_new_key" + | "key_conflict" + | "empty_selection" + | "empty_scope" + | "empty_match" + | "cursor_boundary" + | "syntax_error" + | "empty_stack" + | "apply_failed" + | "empty_clipboard" + | "missing_length" + | "multi_pointer_range" + | "overlapping_ranges" + | "not_string" + | "point_not_in_order"; + +type ExpectedCopyErrorCode = + | "empty_selection" + | "invalid_pointer" + | "path_not_found" + | "not_serializable"; + +type ExpectedCutErrorCode = ExpectedCopyErrorCode | ExpectedJSONResultCode | "preflight_failed"; +type ExpectedPasteErrorCode = + | "empty_selection" + | "not_serializable" + | "rekey_failed" + | ExpectedJSONResultCode + | "preflight_failed"; +type ExpectedDuplicateErrorCode = + | "empty_selection" + | "invalid_pointer" + | "path_not_found" + | "missing_new_key" + | "key_conflict" + | "not_serializable" + | "rekey_failed" + | ExpectedJSONResultCode + | "preflight_failed"; + interface ExpectedPatchHelpers { applyOperation(schema: FixtureSchema, state: FixtureRow, op: JSONPatchOperation): FixtureApplyResult; applyPatch(schema: FixtureSchema, state: FixtureRow, ops: ReadonlyArray): FixtureApplyResult; @@ -189,6 +247,25 @@ type _applyPatchToTrustedStateSignature = Expect< Equal, ExpectedPatchHelpers["applyPatchToTrustedState"]> >; +type _jsonResultCodeCatalog = Expect< + Equal["code"], ExpectedJSONResultCode> +>; +type _capabilityResultCodeCatalog = Expect< + Equal["code"], ExpectedCapabilityErrorCode> +>; +type _copyErrorCodeCatalog = Expect>; +type _cutErrorCodeCatalog = Expect>; +type _pasteErrorCodeCatalog = Expect< + Equal +>; +type _pasteEmptyCodeCatalog = Expect>; +type _readEmptyCodeCatalog = Expect< + Equal["code"], "empty_clipboard"> +>; +type _duplicateErrorCodeCatalog = Expect< + Equal, { ok: false }>["code"], ExpectedDuplicateErrorCode> +>; + type _documentKeys = Expect, keyof ExpectedJSONDocument>>; type _documentAcceptsExpectedSurface = Expect< IsAssignable, ExpectedJSONDocument>