feat: add TechLog project decision authoring
This commit is contained in:
@@ -30,16 +30,19 @@ function runtimeShapeErrors(value: unknown): FieldError[] {
|
||||
? ["purpose", "rules", "applyWhen", "exceptions", "examples", "verifiedOn"]
|
||||
: kind === "QUESTION"
|
||||
? ["questionStatus", "facts", "assumptions", "unknowns", "constraints", "options", "nextValidation", "resolution"]
|
||||
: kind === "PROJECT_DECISION"
|
||||
? ["decisionStatus", "decidedOn", "statement", "rationale", "consequences"]
|
||||
: [];
|
||||
if (!(["CASE", "REFERENCE", "QUESTION"] as unknown[]).includes(kind)) errors.push({ path: "/kind", message: "Invalid record kind." });
|
||||
if (!(["CASE", "REFERENCE", "QUESTION", "PROJECT_DECISION"] as unknown[]).includes(kind)) errors.push({ path: "/kind", message: "Invalid record kind." });
|
||||
const allowed = new Set([...base, ...branch]);
|
||||
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push({ path: `/${escape(key)}`, message: "Additional properties are not allowed." });
|
||||
for (const key of [...base, ...branch]) if (!Object.hasOwn(value, key)) errors.push({ path: `/${key}`, message: "Field is required." });
|
||||
const stringFields = ["title", "slug", "summary", ...(kind === "CASE" ? ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] : []), ...(kind === "REFERENCE" ? ["purpose"] : []), ...(kind === "QUESTION" ? ["nextValidation"] : [])];
|
||||
const stringFields = ["title", "slug", "summary", ...(kind === "CASE" ? ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] : []), ...(kind === "REFERENCE" ? ["purpose"] : []), ...(kind === "QUESTION" ? ["nextValidation"] : []), ...(kind === "PROJECT_DECISION" ? ["statement", "rationale"] : [])];
|
||||
for (const key of stringFields) if (Object.hasOwn(value, key) && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a string." });
|
||||
for (const key of ["topicId", "projectId"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a UUID or null." });
|
||||
for (const key of ["lastVerifiedOn", "verifiedOn"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a date or null." });
|
||||
for (const key of ["lastVerifiedOn", "verifiedOn", "decidedOn"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a date or null." });
|
||||
if (kind === "QUESTION" && Object.hasOwn(value, "questionStatus") && ![null, "OPEN", "RESOLVED"].includes(value.questionStatus as never)) errors.push({ path: "/questionStatus", message: "Invalid question status." });
|
||||
if (kind === "PROJECT_DECISION" && Object.hasOwn(value, "decisionStatus") && ![null, "PROPOSED", "ADOPTED"].includes(value.decisionStatus as never)) errors.push({ path: "/decisionStatus", message: "Invalid decision status." });
|
||||
|
||||
const checkItem = (item: unknown, path: string, fields: Record<string, "string" | "nullable" | "number">) => {
|
||||
if (!object(item)) { errors.push({ path, message: "Must be an object." }); return; }
|
||||
@@ -66,6 +69,9 @@ function runtimeShapeErrors(value: unknown): FieldError[] {
|
||||
checkArray("options", { id: "string", title: "string", description: "string", order: "number" });
|
||||
if (Object.hasOwn(value, "resolution") && value.resolution !== null) checkItem(value.resolution, "/resolution", { summary: "string", evidenceTargetId: "nullable", linkLabel: "string" });
|
||||
}
|
||||
if (kind === "PROJECT_DECISION") {
|
||||
checkArray("consequences", { id: "string", text: "string", order: "number" });
|
||||
}
|
||||
return errors.filter((entry, index) => errors.findIndex(({ path }) => path === entry.path) === index);
|
||||
}
|
||||
|
||||
@@ -121,11 +127,16 @@ export function validateWorkingCopyInputStructure(input: WorkingCopyInput): Fiel
|
||||
ordered(errors, "/rules", input.rules, [["title", 120, 1], ["body", 100_000, 1]]);
|
||||
for (const [path, items] of [["/applyWhen", input.applyWhen], ["/exceptions", input.exceptions], ["/examples", input.examples]] as const) ordered(errors, path, items, [["text", 100_000, 1]]);
|
||||
date(errors, "/verifiedOn", input.verifiedOn);
|
||||
} else {
|
||||
} else if (input.kind === "QUESTION") {
|
||||
for (const [path, items] of [["/facts", input.facts], ["/assumptions", input.assumptions], ["/unknowns", input.unknowns], ["/constraints", input.constraints]] as const) ordered(errors, path, items, [["text", 100_000, 1]]);
|
||||
ordered(errors, "/options", input.options, [["title", 120, 1], ["description", 100_000, 0]]);
|
||||
text(errors, "/nextValidation", input.nextValidation, 100_000);
|
||||
if (input.resolution) { text(errors, "/resolution/summary", input.resolution.summary, 100_000); uuid(errors, "/resolution/evidenceTargetId", input.resolution.evidenceTargetId, true); text(errors, "/resolution/linkLabel", input.resolution.linkLabel, 120); }
|
||||
} else {
|
||||
text(errors, "/statement", input.statement, 100_000);
|
||||
text(errors, "/rationale", input.rationale, 100_000);
|
||||
date(errors, "/decidedOn", input.decidedOn);
|
||||
ordered(errors, "/consequences", input.consequences, [["text", 100_000, 1]]);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -142,7 +153,10 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
||||
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
|
||||
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
|
||||
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
|
||||
if (!document.projectId) warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다."); else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
|
||||
if (!document.projectId) {
|
||||
if (document.kind === "PROJECT_DECISION") error("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
|
||||
else warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다.");
|
||||
} else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
|
||||
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
|
||||
if (document.kind === "CASE") {
|
||||
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||
@@ -156,11 +170,18 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
||||
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
|
||||
} else if (document.kind === "REFERENCE") {
|
||||
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
|
||||
} else {
|
||||
} else if (document.kind === "QUESTION") {
|
||||
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
|
||||
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
|
||||
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
|
||||
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
|
||||
} else {
|
||||
if (!document.decisionStatus) error("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
|
||||
if (!document.decidedOn) error("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
|
||||
if (blank(document.statement)) error("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
|
||||
if (blank(document.rationale)) error("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
|
||||
if (!document.consequences.length) error("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
|
||||
if (!document.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다.");
|
||||
}
|
||||
const validatedAt = dependencies.now.toISOString();
|
||||
return { validationId: dependencies.validationId, documentId: document.id, validatedVersion: document.version,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
export type RequestOptions = { signal?: AbortSignal };
|
||||
export type IdempotentOptions = RequestOptions & { idempotencyKey: string };
|
||||
export type ListDocumentsQuery = {
|
||||
q?: string; kind?: "CASE" | "REFERENCE" | "QUESTION";
|
||||
q?: string; kind?: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_DECISION";
|
||||
publicationStatus?: "NEVER_PUBLISHED" | "PUBLISHED" | "UNPUBLISHED";
|
||||
nextAction?: "CONTINUE_EDITING" | "VALIDATE" | "FIX_VALIDATION" | "CREATE_PREVIEW" | "PUBLISH" | "NONE";
|
||||
projectId?: string; sort?: "UPDATED_DESC" | "UPDATED_ASC" | "TITLE_ASC";
|
||||
|
||||
@@ -197,7 +197,7 @@ export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** @enum {string} */
|
||||
RecordKind: "CASE" | "REFERENCE" | "QUESTION";
|
||||
RecordKind: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_DECISION";
|
||||
/** @enum {string} */
|
||||
PublicationStatus: "NEVER_PUBLISHED" | "PUBLISHED" | "UNPUBLISHED";
|
||||
/** @enum {string} */
|
||||
@@ -308,7 +308,24 @@ export interface components {
|
||||
*/
|
||||
kind: "QUESTION";
|
||||
};
|
||||
WorkingCopyInput: components["schemas"]["CaseInput"] | components["schemas"]["ReferenceInput"] | components["schemas"]["QuestionInput"];
|
||||
ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & {
|
||||
/** @constant */
|
||||
kind: "PROJECT_DECISION";
|
||||
/** @enum {string|null} */
|
||||
decisionStatus: "PROPOSED" | "ADOPTED" | null;
|
||||
/** Format: date */
|
||||
decidedOn: string | null;
|
||||
statement: string;
|
||||
rationale: string;
|
||||
consequences: components["schemas"]["OrderedText"][];
|
||||
} & {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "PROJECT_DECISION";
|
||||
};
|
||||
WorkingCopyInput: components["schemas"]["CaseInput"] | components["schemas"]["ReferenceInput"] | components["schemas"]["QuestionInput"] | components["schemas"]["ProjectDecisionInput"];
|
||||
WorkingCopyBase: components["schemas"]["WorkingCopyInputBase"] & {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -370,7 +387,24 @@ export interface components {
|
||||
*/
|
||||
kind: "QUESTION";
|
||||
};
|
||||
WorkingCopy: components["schemas"]["CaseWorkingCopy"] | components["schemas"]["ReferenceWorkingCopy"] | components["schemas"]["QuestionWorkingCopy"];
|
||||
ProjectDecisionWorkingCopy: components["schemas"]["WorkingCopyBase"] & {
|
||||
/** @constant */
|
||||
kind: "PROJECT_DECISION";
|
||||
/** @enum {string|null} */
|
||||
decisionStatus: "PROPOSED" | "ADOPTED" | null;
|
||||
/** Format: date */
|
||||
decidedOn: string | null;
|
||||
statement: string;
|
||||
rationale: string;
|
||||
consequences: components["schemas"]["OrderedText"][];
|
||||
} & {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "PROJECT_DECISION";
|
||||
};
|
||||
WorkingCopy: components["schemas"]["CaseWorkingCopy"] | components["schemas"]["ReferenceWorkingCopy"] | components["schemas"]["QuestionWorkingCopy"] | components["schemas"]["ProjectDecisionWorkingCopy"];
|
||||
CreateDocumentInput: components["schemas"]["WorkingCopyInput"];
|
||||
SaveDocumentCommand: {
|
||||
expectedVersion: number;
|
||||
@@ -686,7 +720,24 @@ export interface components {
|
||||
*/
|
||||
kind: "QUESTION";
|
||||
};
|
||||
PublicRenderModel: components["schemas"]["CasePublicRenderModel"] | components["schemas"]["ReferencePublicRenderModel"] | components["schemas"]["QuestionPublicRenderModel"];
|
||||
ProjectDecisionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||
/** @constant */
|
||||
kind: "PROJECT_DECISION";
|
||||
/** @enum {string} */
|
||||
status: "PROPOSED" | "ADOPTED";
|
||||
/** Format: date */
|
||||
decidedOn: string;
|
||||
statement: string;
|
||||
rationale: string;
|
||||
consequences: components["schemas"]["OrderedText"][];
|
||||
} & {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "PROJECT_DECISION";
|
||||
};
|
||||
PublicRenderModel: components["schemas"]["CasePublicRenderModel"] | components["schemas"]["ReferencePublicRenderModel"] | components["schemas"]["QuestionPublicRenderModel"] | components["schemas"]["ProjectDecisionPublicRenderModel"];
|
||||
PublicPreview: {
|
||||
/** Format: uuid */
|
||||
previewId: string;
|
||||
|
||||
@@ -233,7 +233,7 @@ components:
|
||||
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } }
|
||||
schemas:
|
||||
RecordKind: { type: string, enum: [CASE, REFERENCE, QUESTION] }
|
||||
RecordKind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION] }
|
||||
PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] }
|
||||
NextAction: { type: string, enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] }
|
||||
RelationInput:
|
||||
@@ -349,17 +349,32 @@ components:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/QuestionResolution" }
|
||||
- { type: "null" }
|
||||
ProjectDecisionInput:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
|
||||
- type: object
|
||||
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
|
||||
properties:
|
||||
kind: { type: string, const: PROJECT_DECISION }
|
||||
decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] }
|
||||
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" } }
|
||||
WorkingCopyInput:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/CaseInput" }
|
||||
- { $ref: "#/components/schemas/ReferenceInput" }
|
||||
- { $ref: "#/components/schemas/QuestionInput" }
|
||||
- { $ref: "#/components/schemas/ProjectDecisionInput" }
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
CASE: "#/components/schemas/CaseInput"
|
||||
REFERENCE: "#/components/schemas/ReferenceInput"
|
||||
QUESTION: "#/components/schemas/QuestionInput"
|
||||
PROJECT_DECISION: "#/components/schemas/ProjectDecisionInput"
|
||||
WorkingCopyBase:
|
||||
allOf:
|
||||
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
|
||||
@@ -417,17 +432,32 @@ components:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/QuestionResolution" }
|
||||
- { type: "null" }
|
||||
ProjectDecisionWorkingCopy:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
- { $ref: "#/components/schemas/WorkingCopyBase" }
|
||||
- type: object
|
||||
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
|
||||
properties:
|
||||
kind: { type: string, const: PROJECT_DECISION }
|
||||
decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] }
|
||||
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" } }
|
||||
WorkingCopy:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/CaseWorkingCopy" }
|
||||
- { $ref: "#/components/schemas/ReferenceWorkingCopy" }
|
||||
- { $ref: "#/components/schemas/QuestionWorkingCopy" }
|
||||
- { $ref: "#/components/schemas/ProjectDecisionWorkingCopy" }
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
CASE: "#/components/schemas/CaseWorkingCopy"
|
||||
REFERENCE: "#/components/schemas/ReferenceWorkingCopy"
|
||||
QUESTION: "#/components/schemas/QuestionWorkingCopy"
|
||||
PROJECT_DECISION: "#/components/schemas/ProjectDecisionWorkingCopy"
|
||||
CreateDocumentInput: { $ref: "#/components/schemas/WorkingCopyInput" }
|
||||
SaveDocumentCommand:
|
||||
type: object
|
||||
@@ -778,17 +808,32 @@ components:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
|
||||
- { type: "null" }
|
||||
ProjectDecisionPublicRenderModel:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
- { $ref: "#/components/schemas/PublicRenderModelBase" }
|
||||
- type: object
|
||||
required: [kind, status, decidedOn, statement, rationale, consequences]
|
||||
properties:
|
||||
kind: { type: string, const: PROJECT_DECISION }
|
||||
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
||||
decidedOn: { type: string, format: date }
|
||||
statement: { type: string, minLength: 1, maxLength: 100000 }
|
||||
rationale: { type: string, minLength: 1, maxLength: 100000 }
|
||||
consequences: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||
PublicRenderModel:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/CasePublicRenderModel" }
|
||||
- { $ref: "#/components/schemas/ReferencePublicRenderModel" }
|
||||
- { $ref: "#/components/schemas/QuestionPublicRenderModel" }
|
||||
- { $ref: "#/components/schemas/ProjectDecisionPublicRenderModel" }
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
CASE: "#/components/schemas/CasePublicRenderModel"
|
||||
REFERENCE: "#/components/schemas/ReferencePublicRenderModel"
|
||||
QUESTION: "#/components/schemas/QuestionPublicRenderModel"
|
||||
PROJECT_DECISION: "#/components/schemas/ProjectDecisionPublicRenderModel"
|
||||
PublicPreview:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -72,7 +72,11 @@ function renderContext(
|
||||
};
|
||||
}
|
||||
|
||||
function publicPath(input: WorkingCopyInput) {
|
||||
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
||||
if (input.kind === "PROJECT_DECISION") {
|
||||
if (!project?.publicPath) fail("PROJECT public path is required");
|
||||
return `${project.publicPath}/decisions#${input.slug}`;
|
||||
}
|
||||
const prefix =
|
||||
input.kind === "CASE"
|
||||
? "cases"
|
||||
@@ -124,13 +128,16 @@ export function projectWorkingCopy(
|
||||
const topic = catalogEntry(catalog, input.topicId, "TOPIC", true);
|
||||
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||
if (!topic) fail("TOPIC catalog entry is required");
|
||||
if (input.kind === "PROJECT_DECISION" && !project) {
|
||||
fail("PROJECT catalog entry is required");
|
||||
}
|
||||
|
||||
const base = {
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
publicPath: publicPath(input),
|
||||
publicPath: publicPath(input, project),
|
||||
topic: displayTarget(topic),
|
||||
project: project ? displayTarget(project) : null,
|
||||
relations: resolvedRelations(input, catalog),
|
||||
@@ -211,5 +218,18 @@ export function projectWorkingCopy(
|
||||
resolution,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
statement: input.statement,
|
||||
rationale: input.rationale,
|
||||
consequences: ordered(input.consequences),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ type ReferencePublicRenderModel =
|
||||
components["schemas"]["ReferencePublicRenderModel"];
|
||||
type QuestionPublicRenderModel =
|
||||
components["schemas"]["QuestionPublicRenderModel"];
|
||||
type ProjectDecisionPublicRenderModel =
|
||||
components["schemas"]["ProjectDecisionPublicRenderModel"];
|
||||
|
||||
type RenderDependencies = {
|
||||
resolveEvidenceAsset: ResolveEvidenceAsset;
|
||||
@@ -31,6 +33,7 @@ const kindLabels = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Open Question",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
|
||||
function assertNever(value: never): never {
|
||||
@@ -52,7 +55,8 @@ function publishedLabel(
|
||||
function explorePath(kind: PublicRenderModelBase["kind"]) {
|
||||
if (kind === "CASE") return "/explore/cases";
|
||||
if (kind === "REFERENCE") return "/explore/references";
|
||||
return "/explore/questions";
|
||||
if (kind === "QUESTION") return "/explore/questions";
|
||||
return "/projects";
|
||||
}
|
||||
|
||||
function TargetLink({
|
||||
@@ -438,6 +442,71 @@ function QuestionDocument({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectDecisionDocument({
|
||||
model,
|
||||
embedded,
|
||||
}: {
|
||||
model: ProjectDecisionPublicRenderModel;
|
||||
embedded: boolean;
|
||||
}) {
|
||||
const Root = embedded ? "div" : "main";
|
||||
const evidence = publicRelations(model.relations);
|
||||
return (
|
||||
<Root
|
||||
id={embedded ? undefined : "main-content"}
|
||||
className={`shell project-page${embedded ? " public-record-embedded" : ""}`}
|
||||
>
|
||||
<nav className="case-breadcrumb" aria-label="문서 경로">
|
||||
<Link to="/projects">Project</Link>
|
||||
{model.project?.publicPath ? (
|
||||
<>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link to={model.project.publicPath}>{model.project.label}</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link to={`${model.project.publicPath}/decisions`}>Decision</Link>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
<ol className="project-decision-list">
|
||||
<li>
|
||||
<article id={model.slug}>
|
||||
<header>
|
||||
<div>
|
||||
<span>{model.status}</span>
|
||||
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
||||
</div>
|
||||
<h2>{model.title}</h2>
|
||||
<p>{model.statement}</p>
|
||||
</header>
|
||||
<section>
|
||||
<h3>판단 이유</h3>
|
||||
<p>{model.rationale}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>영향</h3>
|
||||
<OrderedItems items={model.consequences} />
|
||||
</section>
|
||||
<section>
|
||||
<h3>근거 기록</h3>
|
||||
{evidence.length ? (
|
||||
<ul>
|
||||
{evidence.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link to={item.path}>{item.title}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>연결한 근거 기록이 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</article>
|
||||
</li>
|
||||
</ol>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicRecordRenderer({
|
||||
model,
|
||||
embedded = false,
|
||||
@@ -481,6 +550,10 @@ export function PublicRecordRenderer({
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
);
|
||||
case "PROJECT_DECISION":
|
||||
return (
|
||||
<ProjectDecisionDocument model={model} embedded={embedded} />
|
||||
);
|
||||
default:
|
||||
return assertNever(model);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function CommonDocumentFields({
|
||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
</div>
|
||||
<RelationEditor relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CaseFields } from "./case-fields.tsx";
|
||||
import { CommonDocumentFields } from "./common-document-fields.tsx";
|
||||
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
||||
import { InstantPreview } from "./instant-preview.tsx";
|
||||
import { ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||
import { QuestionFields } from "./question-fields.tsx";
|
||||
import { ReferenceFields } from "./reference-fields.tsx";
|
||||
|
||||
@@ -52,7 +53,9 @@ export function DocumentEditor({ controller, catalog }: { controller: DocumentEd
|
||||
? <CaseFields draft={controller.draft} onChange={controller.replace} />
|
||||
: controller.draft.kind === "REFERENCE"
|
||||
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
||||
: <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />}
|
||||
: controller.draft.kind === "QUESTION"
|
||||
? <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />
|
||||
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
|
||||
</div>
|
||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
||||
<InstantPreview draft={controller.draft} catalog={catalog} />
|
||||
|
||||
@@ -9,6 +9,7 @@ const kindLabel = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Question",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
const publicationLabel = {
|
||||
NEVER_PUBLISHED: "게시 전",
|
||||
@@ -91,7 +92,7 @@ export function DocumentList() {
|
||||
<p className="studio-eyebrow">WORKING COPIES</p>
|
||||
<h1>작업본</h1>
|
||||
<p>
|
||||
세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다.
|
||||
세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
|
||||
@@ -126,6 +127,7 @@ export function DocumentList() {
|
||||
<option value="CASE">Case</option>
|
||||
<option value="REFERENCE">Reference</option>
|
||||
<option value="QUESTION">Question</option>
|
||||
<option value="PROJECT_DECISION">Decision</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
@@ -8,13 +8,20 @@ const labels = {
|
||||
CONFLICT: "저장 충돌",
|
||||
} as const;
|
||||
|
||||
const kindLabels = {
|
||||
CASE: "CASE",
|
||||
REFERENCE: "REFERENCE",
|
||||
QUESTION: "QUESTION",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
|
||||
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
|
||||
return (
|
||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
||||
<p className="studio-eyebrow">WORKING COPY</p>
|
||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{controller.draft.kind}</dd></div></dl>
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
||||
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p> : <p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>}
|
||||
|
||||
@@ -26,6 +26,12 @@ const types = [
|
||||
description: "아직 닫히지 않은 판단과 다음 검증을 관리합니다.",
|
||||
fields: "상태 · 사실 · 가정 · 미지수 · 선택지",
|
||||
},
|
||||
{
|
||||
kind: "PROJECT_DECISION",
|
||||
title: "Decision",
|
||||
description: "프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.",
|
||||
fields: "상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function emptyDocument(kind: RecordKind): CreateDocumentInput {
|
||||
@@ -61,17 +67,28 @@ function emptyDocument(kind: RecordKind): CreateDocumentInput {
|
||||
verifiedOn: null,
|
||||
};
|
||||
}
|
||||
if (kind === "QUESTION") {
|
||||
return {
|
||||
...common,
|
||||
kind,
|
||||
questionStatus: null,
|
||||
facts: [],
|
||||
assumptions: [],
|
||||
unknowns: [],
|
||||
constraints: [],
|
||||
options: [],
|
||||
nextValidation: "",
|
||||
resolution: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
kind,
|
||||
questionStatus: null,
|
||||
facts: [],
|
||||
assumptions: [],
|
||||
unknowns: [],
|
||||
constraints: [],
|
||||
options: [],
|
||||
nextValidation: "",
|
||||
resolution: null,
|
||||
decisionStatus: null,
|
||||
decidedOn: null,
|
||||
statement: "",
|
||||
rationale: "",
|
||||
consequences: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||
|
||||
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
|
||||
|
||||
export function ProjectDecisionFields({
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
draft: ProjectDecisionInput;
|
||||
onChange(draft: ProjectDecisionInput): void;
|
||||
}) {
|
||||
const update = (patch: Partial<ProjectDecisionInput>) => {
|
||||
onChange({ ...draft, ...patch });
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="studio-editor-section"
|
||||
aria-labelledby="studio-project-decision-fields-title"
|
||||
>
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">PROJECT DECISION</p>
|
||||
<h2 id="studio-project-decision-fields-title">프로젝트 결정</h2>
|
||||
</div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field">
|
||||
<span>결정 상태</span>
|
||||
<select
|
||||
value={draft.decisionStatus ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
update({
|
||||
decisionStatus: value === "PROPOSED" || value === "ADOPTED"
|
||||
? value
|
||||
: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">아직 정하지 않음</option>
|
||||
<option value="PROPOSED">PROPOSED</option>
|
||||
<option value="ADOPTED">ADOPTED</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>결정일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draft.decidedOn ?? ""}
|
||||
onChange={(event) => {
|
||||
update({ decidedOn: event.currentTarget.value || null });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>결정문</span>
|
||||
<textarea
|
||||
value={draft.statement}
|
||||
onChange={(event) => update({ statement: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>판단 이유</span>
|
||||
<textarea
|
||||
value={draft.rationale}
|
||||
onChange={(event) => update({ rationale: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<OrderedTextList
|
||||
label="영향"
|
||||
fieldId="studio-field-consequences"
|
||||
items={draft.consequences}
|
||||
onChange={(consequences) => update({ consequences })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const kindLabels = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Question",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
|
||||
function dateTime(value: string) {
|
||||
|
||||
@@ -8,7 +8,8 @@ function ordered(relations: RelationInput[]): RelationInput[] {
|
||||
return relations.map((relation, order) => ({ ...relation, order }));
|
||||
}
|
||||
|
||||
export function RelationEditor({ relations, catalog, onChange }: { relations: RelationInput[]; catalog: CatalogEntry[]; onChange(relations: RelationInput[]): void }) {
|
||||
export function RelationEditor({ relations, catalog, evidence = false, onChange }: { relations: RelationInput[]; catalog: CatalogEntry[]; evidence?: boolean; onChange(relations: RelationInput[]): void }) {
|
||||
const noun = evidence ? "근거" : "관계";
|
||||
const update = (index: number, patch: Partial<RelationInput>) => onChange(ordered(relations.map((relation, candidateIndex) => candidateIndex === index ? { ...relation, ...patch } : relation)));
|
||||
const move = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
@@ -19,16 +20,16 @@ export function RelationEditor({ relations, catalog, onChange }: { relations: Re
|
||||
};
|
||||
return (
|
||||
<fieldset className="studio-ordered-list">
|
||||
<legend>관계</legend>
|
||||
{relations.length === 0 ? <p>연결한 공개 기록이 없습니다.</p> : null}
|
||||
<legend>{evidence ? "근거 기록" : "관계"}</legend>
|
||||
{relations.length === 0 ? <p>{evidence ? "연결한 근거 기록이 없습니다." : "연결한 공개 기록이 없습니다."}</p> : null}
|
||||
{relations.map((relation, index) => (
|
||||
<div className="studio-relation-item" key={relation.id ?? `relation-${index}`}>
|
||||
<label><span>관계 {index + 1} 대상</span><select value={relation.targetId ?? ""} onChange={(event) => update(index, { targetId: event.currentTarget.value || null })}><option value="">대상 선택</option>{catalog.map((entry) => <option key={entry.id} value={entry.id} disabled={relations.some((candidate, candidateIndex) => candidateIndex !== index && candidate.targetId === entry.id)}>{entry.label}</option>)}</select></label>
|
||||
<label><span>관계 {index + 1} 이유</span><input value={relation.reason} onChange={(event) => update(index, { reason: event.currentTarget.value })} /></label>
|
||||
<label><span>{noun} {index + 1} 대상</span><select value={relation.targetId ?? ""} onChange={(event) => update(index, { targetId: event.currentTarget.value || null })}><option value="">대상 선택</option>{catalog.map((entry) => <option key={entry.id} value={entry.id} disabled={relations.some((candidate, candidateIndex) => candidateIndex !== index && candidate.targetId === entry.id)}>{entry.label}</option>)}</select></label>
|
||||
<label><span>{noun} {index + 1} 이유</span><input value={relation.reason} onChange={(event) => update(index, { reason: event.currentTarget.value })} /></label>
|
||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||
</div>
|
||||
))}
|
||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>관계 추가</button>
|
||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const kindLabel = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Question",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
const actionLabel = {
|
||||
CONTINUE_EDITING: "작성 계속",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||
import type { WorkingCopyInput } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
|
||||
import { NewDocumentForm } from "../../../src/features/tech-log/presentation/studio/components/new-document-form.tsx";
|
||||
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||
|
||||
type DecisionInput = Extract<WorkingCopyInput, { kind: "PROJECT_DECISION" }>;
|
||||
|
||||
function decisionInput(): DecisionInput {
|
||||
return {
|
||||
kind: "PROJECT_DECISION",
|
||||
title: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
||||
slug: "feed-pagination-boundary",
|
||||
summary: "부모 페이지를 먼저 고정하고 연관 컬렉션을 별도로 조회합니다.",
|
||||
topicId: FIXTURE_IDS.topicJpa,
|
||||
projectId: FIXTURE_IDS.projectBackend,
|
||||
relations: [
|
||||
{
|
||||
id: null,
|
||||
targetId: FIXTURE_IDS.fetchJoinCase,
|
||||
reason: "판단 근거",
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
decisionStatus: "ADOPTED",
|
||||
decidedOn: "2026-08-11",
|
||||
statement: "목록 조회와 컬렉션 로딩을 서로 다른 단계로 수행합니다.",
|
||||
rationale: "Fetch Join이 DB LIMIT를 제거하는 문제를 피합니다.",
|
||||
consequences: [
|
||||
{
|
||||
id: "99999999-9999-4999-8999-999999999991",
|
||||
text: "목록 조회는 한 번의 SQL로 끝나지 않습니다.",
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function RouterStudioProvider({
|
||||
gateway,
|
||||
children,
|
||||
}: Readonly<{ gateway: StudioGateway; children: ReactNode }>) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<StudioProvider
|
||||
createGateway={() => gateway}
|
||||
navigate={(href) => { void navigate(href); }}
|
||||
>
|
||||
{children}
|
||||
</StudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
return <output aria-label="현재 경로">{useLocation().pathname}</output>;
|
||||
}
|
||||
|
||||
describe("TechLog Studio project decision authoring", () => {
|
||||
it("creates Decision as the fourth working-copy type", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/studio/documents/new"]}>
|
||||
<RouterStudioProvider gateway={gateway}>
|
||||
<NewDocumentForm />
|
||||
<LocationProbe />
|
||||
</RouterStudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /Decision/ }));
|
||||
await user.click(screen.getByRole("button", { name: "작업본 만들기" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("현재 경로")).toHaveTextContent(
|
||||
/^\/studio\/documents\/[0-9a-f-]+\/edit$/,
|
||||
);
|
||||
});
|
||||
expect((await gateway.listDocuments({ kind: "PROJECT_DECISION" })).items)
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("validates, previews, and publishes a project-scoped Decision", async () => {
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const document = await gateway.createDocument(decisionInput(), {
|
||||
idempotencyKey: "create-project-decision",
|
||||
});
|
||||
const validation = await gateway.validateDocument(
|
||||
document.id,
|
||||
{ expectedVersion: document.version },
|
||||
{ idempotencyKey: "validate-project-decision" },
|
||||
);
|
||||
expect(validation).toMatchObject({ status: "VALID", issues: [] });
|
||||
|
||||
const preview = await gateway.createPreview(
|
||||
document.id,
|
||||
{
|
||||
expectedVersion: document.version,
|
||||
validationId: validation.validationId,
|
||||
},
|
||||
{ idempotencyKey: "preview-project-decision" },
|
||||
);
|
||||
expect(preview.renderModel).toMatchObject({
|
||||
kind: "PROJECT_DECISION",
|
||||
status: "ADOPTED",
|
||||
publicPath: "/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
||||
});
|
||||
|
||||
const published = await gateway.publishDocument(
|
||||
document.id,
|
||||
{
|
||||
expectedVersion: document.version,
|
||||
validationId: validation.validationId,
|
||||
previewId: preview.previewId,
|
||||
acknowledgedWarningCodes: [],
|
||||
},
|
||||
{ idempotencyKey: "publish-project-decision" },
|
||||
);
|
||||
expect(published.publication.publicPath).toBe(
|
||||
"/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
||||
);
|
||||
});
|
||||
|
||||
it("edits Decision fields and renders the project decision card preview", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const document = await gateway.createDocument(decisionInput(), {
|
||||
idempotencyKey: "edit-project-decision",
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${document.id}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway}>
|
||||
<DocumentEditorScreen documentId={document.id} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText("결정 상태")).toHaveValue("ADOPTED");
|
||||
expect(screen.getByLabelText("결정일")).toHaveValue("2026-08-11");
|
||||
expect(screen.getByLabelText("결정문")).toHaveValue(
|
||||
"목록 조회와 컬렉션 로딩을 서로 다른 단계로 수행합니다.",
|
||||
);
|
||||
expect(screen.getByLabelText("판단 이유")).toHaveValue(
|
||||
"Fetch Join이 DB LIMIT를 제거하는 문제를 피합니다.",
|
||||
);
|
||||
expect(screen.getByLabelText("영향 1")).toHaveValue(
|
||||
"목록 조회는 한 번의 SQL로 끝나지 않습니다.",
|
||||
);
|
||||
expect(screen.getByLabelText("근거 1 대상")).toHaveValue(
|
||||
FIXTURE_IDS.fetchJoinCase,
|
||||
);
|
||||
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
||||
.toHaveTextContent("종류Decision");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
||||
expect(
|
||||
within(preview).getByRole("heading", {
|
||||
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(within(preview).getByText("판단 이유")).toBeVisible();
|
||||
expect(within(preview).getByText("영향")).toBeVisible();
|
||||
expect(within(preview).getByText("근거 기록")).toBeVisible();
|
||||
expect(within(preview).getByRole("link", {
|
||||
name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
})).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user