import assert from "node:assert/strict"; import { test } from "vitest"; import type { PublicationAggregate, PublicPreview, ValidationReport, WorkingCopy, WorkingCopyDetail, } from "../../../src/features/tech-log/contracts/studio/contract.ts"; import { deriveDocumentState, deriveNextAction, derivePreviewState, deriveValidationState, } from "../../../src/features/tech-log/domain/studio/document-state.ts"; import { createLocalId, createNewItemId, } from "../../../src/features/tech-log/domain/studio/local-id.ts"; const now = "2026-08-14T12:00:00.000Z"; const validUntil = "2026-08-14T12:30:00.000Z"; const expiredAt = "2026-08-14T12:00:00.000Z"; const futureExpiry = "2026-08-14T12:45:00.000Z"; function documentAt( version = 7, overrides: Partial = {}, ): WorkingCopy { return { id: "11111111-1111-4111-8111-111111111111", kind: "CASE", version, title: "Fetch Join 경계", slug: "fetch-join-boundary", summary: "목록 경계를 검증합니다.", topicId: "22222222-2222-4222-8222-222222222222", projectId: null, relations: [], updatedAt: now, problem: "문제", conclusion: "결론", environment: "PostgreSQL", reproduction: "재현", lastVerifiedOn: "2026-08-14", bodyMarkdown: "## 본문", ...overrides, } as WorkingCopy; } function validationAt( version = 7, overrides: Partial = {}, ): ValidationReport { return { validationId: "33333333-3333-4333-8333-333333333333", documentId: "11111111-1111-4111-8111-111111111111", validatedVersion: version, status: "VALID", issues: [], validatedAt: "2026-08-14T11:50:00.000Z", validUntil, dependencyRevision: "catalog-1", ...overrides, }; } function previewAt( version = 7, validationId = validationAt(version).validationId, overrides: Partial = {}, ): PublicPreview { return { previewId: "44444444-4444-4444-8444-444444444444", documentId: "11111111-1111-4111-8111-111111111111", previewVersion: version, validationId, createdAt: "2026-08-14T11:55:00.000Z", expiresAt: futureExpiry, renderModel: { kind: "CASE", title: "Fetch Join 경계", slug: "fetch-join-boundary", summary: "목록 경계를 검증합니다.", topic: { id: "22222222-2222-4222-8222-222222222222", label: "JPA", publicPath: null, }, project: null, relations: [], blocks: [], }, ...overrides, } as PublicPreview; } function publishedAt(version = 7): PublicationAggregate { return { publicationId: "55555555-5555-4555-8555-555555555555", documentId: "11111111-1111-4111-8111-111111111111", status: "PUBLISHED", publishedVersion: version, publicationRevision: 1, latestEventId: "66666666-6666-4666-8666-666666666666", publicPath: "/cases/fetch-join-boundary", updatedAt: now, }; } type InputOverrides = Partial[0]>; function input(overrides: InputOverrides = {}) { return { document: documentAt(), validation: validationAt(), preview: previewAt(), publication: null, now, dependencyRevision: "catalog-1", ...overrides, }; } test("same published version is NONE before stale validation checks", () => { assert.equal( deriveNextAction({ document: documentAt(7), validation: validationAt(7, { validUntil: expiredAt }), preview: previewAt(7, "other-validation", { expiresAt: expiredAt }), publication: publishedAt(7), now, dependencyRevision: "catalog-2", }), "NONE", ); }); test("validation freshness requires the saved version, a future expiry, and matching dependencies", () => { assert.deepEqual(deriveValidationState(input({ validation: null })), { result: "NOT_RUN", freshness: "NONE", }); assert.deepEqual( deriveValidationState(input({ validation: validationAt(6) })), { result: "VALID", freshness: "STALE" }, ); assert.deepEqual( deriveValidationState( input({ validation: validationAt(7, { validUntil: now }) }), ), { result: "VALID", freshness: "STALE" }, ); assert.deepEqual( deriveValidationState(input({ dependencyRevision: "catalog-2" })), { result: "VALID", freshness: "STALE" }, ); assert.deepEqual(deriveValidationState(input()), { result: "VALID", freshness: "CURRENT", }); }); test("preview expiry takes precedence and all remaining freshness checks are enforced", () => { const validation = validationAt(); assert.equal(derivePreviewState(input({ preview: null })), "NONE"); assert.equal( derivePreviewState( input({ preview: previewAt(6, validation.validationId) }), ), "STALE", ); assert.equal( derivePreviewState(input({ preview: previewAt(7, "other-validation") })), "STALE", ); assert.equal( derivePreviewState( input({ preview: previewAt(6, "other-validation", { expiresAt: now }), }), ), "EXPIRED", ); assert.equal(derivePreviewState(input()), "CURRENT"); assert.equal( derivePreviewState( input({ validation: validationAt(7, { validUntil: now }) }), ), "STALE", ); assert.equal( derivePreviewState(input({ dependencyRevision: "catalog-2" })), "STALE", ); }); test("save-reset, dependency staleness, invalid reports, warnings and preview gaps are ordered", () => { assert.equal( deriveNextAction( input({ document: documentAt(7, { title: "", slug: "", summary: "" }) }), ), "CONTINUE_EDITING", ); assert.equal(deriveNextAction(input({ validation: null })), "VALIDATE"); assert.equal( deriveNextAction(input({ dependencyRevision: "catalog-2" })), "VALIDATE", ); assert.equal( deriveNextAction( input({ validation: validationAt(7, { status: "INVALID" }) }), ), "FIX_VALIDATION", ); assert.equal( deriveNextAction( input({ validation: validationAt(7, { status: "WARNINGS" }), preview: null, }), ), "CREATE_PREVIEW", ); assert.equal( deriveNextAction( input({ validation: validationAt(7, { status: "WARNINGS" }) }), ), "PUBLISH", ); assert.deepEqual( deriveValidationState( input({ validation: validationAt(7, { status: "WARNINGS" }), dependencyRevision: "catalog-2", }), ), { result: "WARNINGS", freshness: "STALE" }, ); assert.equal( deriveNextAction( input({ validation: validationAt(7, { status: "WARNINGS" }), dependencyRevision: "catalog-2", }), ), "VALIDATE", ); }); test("saving retains an old preview as stale and returns to validation", () => { const previousValidation = validationAt(7); const previousPreview = previewAt(7, previousValidation.validationId); const state = deriveDocumentState( input({ document: documentAt(8), validation: null, preview: previousPreview, }), ); assert.deepEqual(state.validation, { result: "NOT_RUN", freshness: "NONE", }); assert.equal(state.preview, "STALE"); assert.equal(state.nextAction, "VALIDATE"); }); test("unpublished or old-version publications follow the normal next-action flow", () => { assert.equal( deriveNextAction( input({ publication: { ...publishedAt(), status: "UNPUBLISHED" } }), ), "PUBLISH", ); assert.equal( deriveNextAction(input({ publication: publishedAt(6) })), "PUBLISH", ); }); test("deriveDocumentState is consistent with a WorkingCopyDetail aggregate", () => { const detail: WorkingCopyDetail = { document: documentAt(), currentValidation: validationAt(), latestPreview: previewAt(), currentPublication: publishedAt(), dependencyRevision: "catalog-1", // `deriveDocumentState` computes `nextAction`; it never reads it back off // `WorkingCopyDetail`. This mirrors the expected `explicit` result below. nextAction: "NONE", }; const explicit = deriveDocumentState({ document: detail.document, validation: detail.currentValidation, preview: detail.latestPreview, publication: detail.currentPublication, dependencyRevision: detail.dependencyRevision, now, }); const fromDetail = deriveDocumentState({ ...detail, now }); assert.deepEqual(explicit, { validation: { result: "VALID", freshness: "CURRENT" }, preview: "CURRENT", nextAction: "NONE", }); assert.deepEqual(fromDetail, explicit); }); test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs when present", () => { assert.equal( createLocalId("preview", null, () => 1234, () => 0.25), "preview-1234-250000000", ); assert.equal( createLocalId("save", { randomUUID: () => "fixture-uuid" }), "save-fixture-uuid", ); }); /* 이 두 함수가 나뉜 이유는 하나는 화면 안에만 머물고 다른 하나는 계약을 건너가기 때문이다. 한때 새 관계·규칙·선택지의 id 를 `createLocalId` 로 만들었고, 그래서 `relation-` 같은 값이 요청 본문에 실렸다. 계약은 그 자리에 uuid 를 요구하므로 서버는 파싱조차 못 하고 400 을 돌려주었다 — 편집기에는 "saveStudioDocument broke its contract." 한 줄만 남고 저장은 통째로 실패했다. 목록에 고를 대상이 하나도 없던 시절에는 아무도 이 경로를 지나지 않아 드러나지 않았다. */ const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; test("new list item IDs are bare UUIDs, because they cross the contract", () => { assert.equal( createNewItemId({ randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" }), "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607", ); // Web Crypto 가 없는 환경에서도 형식은 uuid 여야 한다 — 값은 서버가 새로 부여하지만, 형식이 // 어긋나면 요청 자체가 거절된다. assert.match(createNewItemId(null, () => 0.5), UUID_SHAPE); assert.match(createNewItemId(null, () => 0), UUID_SHAPE); assert.match(createNewItemId(null, () => 0.999), UUID_SHAPE); }); test("prefixed local IDs never satisfy the contract's UUID shape", () => { assert.equal( UUID_SHAPE.test(createLocalId("relation", { randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" })), false, ); });