fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다

Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-24 18:03:11 +09:00
co-authored by Claude Opus 5
parent e5770dfbc8
commit fd73bc88a1
17 changed files with 117 additions and 80 deletions
+5 -5
View File
@@ -38,28 +38,28 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
"setDigest": "sha256:7832d43886cf12e6569ffd28e3f033d8a64a88e673e31449726d6dc450fa62ae",
"packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "83148b2"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "83148b2"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "83148b2"
}
]
}
@@ -142,17 +142,12 @@ export function getLatestEntries(): LatestRecordEntry[] {
};
}),
);
const releaseEntries = releases.map((release) => ({
id: `release-${release.version}`,
entryType: "RELEASE" as const,
title: release.title,
summary: release.summary,
path: release.path,
publishedAt: release.publishedAt,
topic: "TechLog",
project: "TechLog",
}));
return [...activities, ...releaseEntries].sort((left, right) =>
/*
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
*/
return [...activities].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
@@ -2,7 +2,7 @@
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"sourceRevision": "06ae075",
"sourceRevision": "83148b2",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -2,7 +2,7 @@
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"sourceRevision": "06ae075",
"sourceRevision": "83148b2",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"sourceRevision": "06ae075",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"sourceRevision": "83148b2",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -1167,7 +1167,7 @@ export interface components {
/** @enum {string} */
status: "PROPOSED" | "ADOPTED";
/** Format: date */
decidedOn: string;
decidedOn: string | null;
statement: string;
rationale: string;
consequences: components["schemas"]["OrderedText"][];
@@ -1572,7 +1572,10 @@ components:
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -284,12 +284,16 @@ export function projectWorkingCopy(
case "PROJECT_DECISION":
if (!input.decisionStatus) fail("Decision status is required");
if (!input.decidedOn) fail("Decision date is required");
/*
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
*/
return {
...base,
kind: "PROJECT_DECISION",
status: input.decisionStatus,
decidedOn: input.decidedOn,
decidedOn: input.decidedOn ?? null,
statement: input.statement,
rationale: input.rationale,
consequences: ordered(input.consequences),
@@ -16,6 +16,16 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
const detailsRef = useRef<HTMLDetailsElement>(null);
useEffect(() => {
/*
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
못 그리게 두지 않는다.
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
목차를 받게 되면서 드러났다.
*/
if (typeof IntersectionObserver === "undefined") return undefined;
const elements = headings
.map((heading) => document.getElementById(heading.id))
.filter((element): element is HTMLElement => Boolean(element));
@@ -435,7 +435,11 @@ function ProjectDecisionDocument({
<header>
<div>
<span>{model.status}</span>
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
{model.decidedOn ? (
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
) : (
<span> </span>
)}
</div>
<h2>{model.title}</h2>
<p><PlainText text={model.statement} /></p>
@@ -41,12 +41,19 @@ export function HomeFocusEditor() {
useEffect(() => {
let cancelled = false;
void Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]).then(
([current, projectPage, catalog]) => {
/*
try/catch 로 감싼다. `Promise.all([gateway.foo()])` 은 `foo` 가 거절하는 것만 잡는다 —
호출 자체가 동기적으로 던지면(예: 그 표면을 갖추지 않은 게이트웨이) 배열을 만드는 중에
터지므로 rejection handler 를 지나지 못한다. 그러면 이 섹션이 아니라 대시보드 전체가
빈 화면이 된다. 한 칸의 실패가 화면을 통째로 날리지 않게 한다.
*/
void (async () => {
try {
const [current, projectPage, catalog] = await Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setFocus(current);
setProjects(projectPage.items ?? []);
@@ -55,11 +62,10 @@ export function HomeFocusEditor() {
setQuestionId(current.openQuestionId ?? "");
setDecisionId(current.recentDecisionId ?? "");
setError("");
},
() => {
} catch {
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
},
);
}
})();
return () => {
cancelled = true;
};
@@ -122,13 +122,15 @@ export function ProjectEditor() {
useEffect(() => {
let cancelled = false;
void Promise.all([
managementGateway.getProject(projectId),
managementGateway.listProjectActivities(projectId),
managementGateway.listTopics(),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]).then(
([loaded, activityList, topicList, catalog]) => {
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
void (async () => {
try {
const [loaded, activityList, topicList, catalog] = await Promise.all([
managementGateway.getProject(projectId),
managementGateway.listProjectActivities(projectId),
managementGateway.listTopics(),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setProject(loaded);
setDraft(toDraft(loaded));
@@ -136,11 +138,10 @@ export function ProjectEditor() {
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
setRecords(catalog.items ?? []);
setError("");
},
() => {
} catch {
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
},
);
}
})();
return () => {
cancelled = true;
};
@@ -205,13 +205,22 @@ describe("TechLog canonical Public documents", () => {
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
/*
목차가 본문의 절 제목을 그대로 다시 적으므로 같은 글이 두 자리에 나온다. 여기서 확인하려는
것은 "그 글이 문서에 있는가" 이므로 첫 자리로 충분하다.
*/
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
expect(firstEvidence).toBeVisible();
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
`${kind}/${topic}/${project}`,
);
if (path === "/cases/collection-fetch-join-pagination") {
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
/*
Case 는 한 배치를 쓴다. 예전에는 픽스처 문서 하나만 `.case-meta` 를 받고 나머지 Case 는
`.public-document-header dl` 로 떨어졌으므로 여기도 경로로 갈랐다. 이제 유형으로 가른다.
*/
if (kind === "Case") {
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
} else {
const metadata = container.querySelector(".public-document-header dl");
expect(metadata).toHaveTextContent(`유형${kind}`);
@@ -258,12 +267,12 @@ describe("TechLog canonical Public documents", () => {
expect(image).toHaveAttribute("loading", "lazy");
});
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => {
it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
const generic = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/redis-adapter-ttl-boundary",
);
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page");
expect(screen.getByRole("main")).toHaveClass("case-page");
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
"정책과 저장 명령의 주인을 구분하기",
);
@@ -252,35 +252,26 @@ describe("TechLog project screens", () => {
]);
});
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
/*
활동은 로그다 — 언제 무엇을 올렸는지만 적는다. 각 줄에 그 기록으로 가는 링크가 있으면 같은
글에 닿는 길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
글을 읽는 자리는 기록 화면 하나로 둔다.
*/
it("keeps project activity ordered and leaves reading to the records screen", async () => {
const { container } = await renderPublicRoute(
"TECH_LOG_PROJECT_ACTIVITY",
"/projects/backend-skeleton/activity",
);
expect(
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
id: item.id,
href: item.querySelector("a")?.getAttribute("href"),
label: item.querySelector("a")?.textContent,
})),
).toEqual([
{
id: "fetch-join-case-published",
href: "/cases/collection-fetch-join-pagination",
label: "연결된 공개 기록 읽기",
},
{
id: "storage-contract",
href: "/projects/backend-skeleton/activity#storage-contract",
label: "이 활동 위치 열기",
},
{
id: "redis-case-published",
href: "/cases/redis-adapter-ttl-boundary",
label: "연결된 공개 기록 읽기",
},
const items = Array.from(
container.querySelectorAll(".project-activity-list > li > article"),
);
expect(items.map((item) => item.id)).toEqual([
"fetch-join-case-published",
"storage-contract",
"redis-case-published",
]);
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
});
});
+11 -3
View File
@@ -386,7 +386,12 @@ describe("shared Public record renderer", () => {
);
});
it("uses generic Case markup and does not present generatedAt as publication", () => {
/*
Case 렌더러가 둘이었을 때 이 테스트는 "축약본" 쪽을 지켰다. 어느 쪽을 쓸지는 슬러그 비교가
정했고, 설계 픽스처 문서 하나만 breadcrumb 과 목차가 있는 배치를 받았다 — 실제로 작성한 Case 는
전부 축약본이었고 오른쪽 목차가 어디에도 나오지 않았다. 배치는 이제 하나다.
*/
it("uses the one Case layout and does not present generatedAt as publication", () => {
renderInRouter(
<PublicRecordRenderer
{...renderDependencies}
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
);
const main = screen.getByRole("main");
expect(main).toHaveClass("shell", "public-document-page");
expect(main).toHaveClass("case-page");
expect(main).toHaveAttribute("id", "main-content");
expect(screen.getByText("게시 전")).toBeVisible();
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
expect(main).toHaveTextContent("게시 전");
expect(main).not.toHaveTextContent("2035.05.06");
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
"문제문제결론결론",
);
expect(screen.getByText("일반 본문")).toBeVisible();
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
expect(screen.queryByLabelText("문서 목차")).toBeNull();
});
it("renders supplied Reference and Question preview fields and empty copy", () => {
@@ -39,7 +39,9 @@ const expectedRoutes = [
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
["TECH_LOG_STUDIO_PROJECT_EDIT", "/studio/projects/:id", "STUDIO", "TechLogDocumentIdParams", null],
["TECH_LOG_STUDIO_RELEASES", "/studio/releases", "STUDIO", null, null],
["TECH_LOG_STUDIO_RELEASE_EDIT", "/studio/releases/:id", "STUDIO", "TechLogDocumentIdParams", null],
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
] as const;
@@ -72,13 +74,15 @@ const expectedTitles = {
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
TECH_LOG_STUDIO_ASSETS: "Asset",
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
TECH_LOG_STUDIO_PROJECT_EDIT: "프로젝트 편집",
TECH_LOG_STUDIO_RELEASES: "릴리즈",
TECH_LOG_STUDIO_RELEASE_EDIT: "릴리즈 편집",
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
NOT_FOUND: "페이지를 찾을 수 없습니다.",
} as const;
describe("TechLog route boundary contract", () => {
it("freezes the standalone 29-route inventory before runtime installation", () => {
it("freezes the standalone 31-route inventory before runtime installation", () => {
expect(
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
definition.routeId,
@@ -153,7 +157,7 @@ describe("TechLog route boundary contract", () => {
for (const locale of ["ko-KR", "en-US"] as const) {
const catalog: Readonly<Record<string, string>> =
TECH_LOG_MESSAGE_CATALOGS[locale];
expect(Object.keys(catalog)).toHaveLength(60);
expect(Object.keys(catalog)).toHaveLength(64);
for (const [routeId, title] of Object.entries(expectedTitles)) {
expect(catalog[`route.${routeId}.title`]).toBe(title);
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
@@ -79,7 +79,9 @@ describe("TechLog consumer-visible style contract", () => {
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
expect(rootStyle.getPropertyValue("--body-copy")).toBe("42rem");
// 42rem 은 영문 기준 측정값이었다. 한글 본문에서 좁았고, 바로 위의 유형/프로젝트 줄이
// shell 전체를 쓰고 있어 대비가 더 심했다. 읽는 단 전체가 이 값 하나를 따른다.
expect(rootStyle.getPropertyValue("--body-copy")).toBe("56rem");
expect(rootStyle.color).toBe("var(--ink)");
expect(rootStyle.background).toBe("var(--canvas)");
expect(bodyStyle.color).toBe("var(--ink)");