build: generate the TechLog Studio contract from canonical source
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac555a85e8
commit
639e1a49c9
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "vitest";
|
||||
|
||||
import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" };
|
||||
|
||||
const YAML_PATH = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
||||
|
||||
test("vendored contract matches the recorded canonical digest", () => {
|
||||
const bytes = readFileSync(YAML_PATH);
|
||||
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
assert.equal(digest, canonicalSource.digest);
|
||||
});
|
||||
|
||||
test("canonical source records the pinned revision and version", () => {
|
||||
assert.equal(canonicalSource.packageId, "tech-log-studio-contract");
|
||||
assert.equal(canonicalSource.version, "2.0.0");
|
||||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||||
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
||||
assert.match(canonicalSource.digest, /^sha256:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("canonical source lists all 19 operationIds", () => {
|
||||
assert.equal(canonicalSource.operationIds.length, 19);
|
||||
assert.ok(canonicalSource.operationIds.includes("uploadStudioAsset"));
|
||||
assert.ok(canonicalSource.operationIds.includes("getStudioSession"));
|
||||
});
|
||||
|
||||
test("vendored contract declares the CSRF header", () => {
|
||||
const yaml = readFileSync(YAML_PATH, "utf8");
|
||||
assert.ok(yaml.includes("X-CSRF-TOKEN"));
|
||||
});
|
||||
@@ -4,7 +4,11 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getEvidenceAsset } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||
import {
|
||||
getEvidenceAsset,
|
||||
isSupportedEvidenceKey,
|
||||
resolveEvidenceAssetDescriptor,
|
||||
} from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
||||
import { parseCaseContent } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
||||
import { Callout } from "../../../src/features/tech-log/presentation/shared/public-render/callout.tsx";
|
||||
@@ -72,6 +76,21 @@ const renderDependencies = {
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// `parseCaseContent` returns authoring blocks (no asset catalog access); the
|
||||
// renderer under test expects the resolved `CaseRenderBlock` shape, so this
|
||||
// mirrors what `adapters/mock/project-public-render-model.ts` does in production.
|
||||
function resolvedBlocks(
|
||||
blocks: ReturnType<typeof parseCaseContent>,
|
||||
): components["schemas"]["CaseRenderBlock"][] {
|
||||
return blocks.map((block) => {
|
||||
if (block.type !== "EVIDENCE_FIGURE") return block;
|
||||
if (!isSupportedEvidenceKey(block.key)) {
|
||||
throw new Error(`Unknown local evidence asset: ${block.key}`);
|
||||
}
|
||||
return { ...block, asset: resolveEvidenceAssetDescriptor(block.key) };
|
||||
});
|
||||
}
|
||||
|
||||
function caseModel(
|
||||
overrides: Partial<components["schemas"]["CasePublicRenderModel"]> = {},
|
||||
): components["schemas"]["CasePublicRenderModel"] {
|
||||
@@ -151,7 +170,7 @@ select * from feed_item;
|
||||
|
||||
const view = render(
|
||||
<CaseBodyRenderer
|
||||
blocks={blocks}
|
||||
blocks={resolvedBlocks(blocks)}
|
||||
resolveEvidenceAsset={getEvidenceAsset}
|
||||
/>,
|
||||
);
|
||||
@@ -338,8 +357,10 @@ describe("shared Public record renderer", () => {
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
publicPath: "/cases/collection-fetch-join-pagination",
|
||||
reproduction: "Dataset: 수정 중인 데이터셋",
|
||||
bodyBlocks: parseCaseContent(
|
||||
"## *강조* **강함** `코드` {#rich-heading}\n\n본문",
|
||||
bodyBlocks: resolvedBlocks(
|
||||
parseCaseContent(
|
||||
"## *강조* **강함** `코드` {#rich-heading}\n\n본문",
|
||||
),
|
||||
),
|
||||
})}
|
||||
/>,
|
||||
|
||||
@@ -44,7 +44,22 @@ const blocks: CaseRenderBlock[] = [
|
||||
rows: [],
|
||||
},
|
||||
{ type: "CALLOUT", tone: "warning", label: "주의", content: [] },
|
||||
{ type: "EVIDENCE_FIGURE", key: "fetch-plan", alt: "Fetch plan", caption: "Measured fetch plan", zoom: true },
|
||||
{
|
||||
type: "EVIDENCE_FIGURE",
|
||||
key: "fetch-plan",
|
||||
alt: "Fetch plan",
|
||||
caption: "Measured fetch plan",
|
||||
zoom: true,
|
||||
asset: {
|
||||
assetId: "44444444-4444-4444-8444-444444444441",
|
||||
assetKey: "fetch-plan",
|
||||
mediaType: "image/svg+xml",
|
||||
publicPath: "/media/fetch-plan.svg",
|
||||
width: 1080,
|
||||
height: 420,
|
||||
decorative: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type RequiredStudioGatewayOperation =
|
||||
|
||||
@@ -285,6 +285,9 @@ test("deriveDocumentState is consistent with a WorkingCopyDetail aggregate", ()
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user