Vendors the canonical studio-v1.yaml, generates types via an isolated `pnpm dlx` toolchain (openapi-typescript needs TypeScript 5's classic compiler API; this repo pins TypeScript 7.0.2 per VD-01, whose root export has none), and adds an offline drift gate that checks the vendored yaml/generated types/canonical-source.json against each other without touching the sibling design-package repo or the network. Regenerating from canonical surfaces real, new required fields on existing schemas (WorkingCopyDetail.nextAction, PreviewDetail/PublicPreview .dependencyRevision, StudioDashboard.totals.needsValidation, PublicationSnapshot.contentFormatVersion/rendererContractVersion) and a new required EvidenceFigureBlock.asset. The mock gateway and fixtures are updated to satisfy the former; the latter exposes a real authoring- vs-rendering conflation in the content-format parser (it declared its output as the server's fully-resolved PublicRenderModel type, which it has no asset catalog to satisfy). Split that boundary: the parser now produces an authoring block type omitting the resolved asset, and each of its three consumers (the mock gateway, the Studio instant preview, and the static Case demo page) attaches the resolved descriptor from its own asset source through a shared, pure domain-level resolver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
320 lines
8.6 KiB
TypeScript
320 lines
8.6 KiB
TypeScript
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 } 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> = {},
|
|
): 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> = {},
|
|
): 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> = {},
|
|
): 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<Parameters<typeof deriveNextAction>[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",
|
|
);
|
|
});
|