fix: collapse the evidence-key gate and resolver into one decision
Three fix rounds each rebuilt the gate as a separate expression that merely agreed with the resolver on the inputs that round's tests used. Different expressions cannot agree in general, so the defect class stayed open while each reported instance closed. `findResolvableAsset(assets, key)` is now the single place that decides which Asset an evidence key resolves to. Every gate is `Boolean(findResolvableAsset(...)) || legacyKey(key)` via one shared composition, and every resolver returns what it returns: - validate-working-copy: the key gate and the decorative lookup (a last-wins Map against the resolver's first-wins find, so alt could be judged against a different Asset than the one rendered) - adapters/mock/project-public-render-model: gate and resolver - instant-preview: gate and descriptor resolver - createAssetCatalogResolver: the pixels, a fourth expression nobody had listed -- one Asset's caption could sit over another Asset's image Duplicate assetKeys are a contract violation but reachable through a paged list, so the choice is total and order-independent: newest updatedAt wins, tie-broken by id. InstantPreview's gate is no longer looser than the others. The un-loaded-asset case it was loosened for blanks either way; all the looseness bought was catalog-only keys rendering an empty gap with no message while validation said EVIDENCE_UNSUPPORTED. The test that pinned that divergence now asserts the consistent behaviour, and the false comment claiming a fix that did not exist is gone. idempotent() now maps a deterministic content failure to VALIDATION_STALE/409 instead of offering a retry that fails identically, and no longer caches uncharacterized internal failures -- reporting one as retryable while freezing it in the ledger meant the retry could never re-run. Adds tests/features/tech-log/evidence-key-agreement.test.ts: 144 adversarial (asset list, key) combinations asserting the agreement itself rather than examples. It reported 53 disagreements against the previous code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
783e9b2cf1
commit
073fda87eb
@@ -2,6 +2,7 @@ import { StudioGatewayError } from "../../application/ports/studio-gateway-error
|
|||||||
import type { IdempotentOptions, RequestOptions, StudioGateway } from "../../application/ports/studio-gateway.ts";
|
import type { IdempotentOptions, RequestOptions, StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||||
import type { components } from "../../contracts/studio/generated.ts";
|
import type { components } from "../../contracts/studio/generated.ts";
|
||||||
import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
|
import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
|
||||||
|
import { ContentFormatError } from "../../domain/content-format/parse-case-content.ts";
|
||||||
import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts";
|
import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts";
|
||||||
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
||||||
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
||||||
@@ -63,6 +64,34 @@ function uuid(value: unknown, path: string): asserts value is string {
|
|||||||
if (typeof value !== "string" || !UUID.test(value)) throw requestError([{ path, message: "Must be a UUID." }]);
|
if (typeof value !== "string" || !UUID.test(value)) throw requestError([{ path, message: "Must be a UUID." }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies whatever escaped a handler into the problem this port reports,
|
||||||
|
* and whether that outcome may be replayed from the idempotency ledger.
|
||||||
|
*
|
||||||
|
* A `ContentFormatError` is deterministic: the document no longer projects
|
||||||
|
* against the dependencies as they stand (an Asset deleted between validate
|
||||||
|
* and preview is the live case). Reporting that as `STUDIO_UNAVAILABLE`/500
|
||||||
|
* `retryable: true` invites a retry that fails identically -- the honest
|
||||||
|
* signal is `VALIDATION_STALE`/409, "re-validate, then try again", which is
|
||||||
|
* not a retry of this request at all. Anything else is an uncharacterized
|
||||||
|
* internal defect: we cannot claim it is deterministic, so it is reported as
|
||||||
|
* retryable and deliberately left out of the ledger.
|
||||||
|
*/
|
||||||
|
function failureOf(error: unknown): { problem: ProblemDetails; replayable: boolean } {
|
||||||
|
if (error instanceof StudioGatewayError) return { problem: error.problem, replayable: true };
|
||||||
|
if (error instanceof ContentFormatError) {
|
||||||
|
const detail = error.issues.map((issue) => `${issue.line}:${issue.column} ${issue.detail}`).join("; ");
|
||||||
|
return {
|
||||||
|
problem: gatewayProblem(409, "VALIDATION_STALE", `The document no longer renders against current dependencies: ${detail}`).problem,
|
||||||
|
replayable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
problem: gatewayProblem(500, "STUDIO_UNAVAILABLE", error instanceof Error ? error.message : "Studio가 요청을 처리하지 못했습니다.", { retryable: true }).problem,
|
||||||
|
replayable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function exactSet(left: string[], right: string[]) {
|
function exactSet(left: string[], right: string[]) {
|
||||||
return new Set(left).size === left.length && new Set(right).size === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
|
return new Set(left).size === left.length && new Set(right).size === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
|
||||||
}
|
}
|
||||||
@@ -87,25 +116,18 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
|||||||
}
|
}
|
||||||
try { const value = work(); state.idempotency.set(key, { fingerprint, outcome: { kind: "success", value: clone(value) } }); return clone(value); }
|
try { const value = work(); state.idempotency.set(key, { fingerprint, outcome: { kind: "success", value: clone(value) } }); return clone(value); }
|
||||||
catch (error) {
|
catch (error) {
|
||||||
// Fix round 3. This port's contract is `StudioGatewayError` only --
|
// This port's contract is `StudioGatewayError` only -- nothing else may
|
||||||
// nothing else may cross it. Round 2 fixed one way a raw `Error`
|
// cross it -- so anything `work()` throws is normalized here.
|
||||||
// reached here (`createPreview`'s resolver); the class recurred a
|
const { problem, replayable } = failureOf(error);
|
||||||
// third time via a different Asset shape, and each time the actual
|
// Only outcomes we know are deterministic go in the ledger. Replaying a
|
||||||
// symptom at the port boundary was the same: an unwrapped exception
|
// cached result is the ledger's whole point when re-running would
|
||||||
// escaping `work()`. Rather than keep chasing individual internal
|
// produce the same answer, and it is exactly wrong when it would not:
|
||||||
// causes, anything that isn't already a `StudioGatewayError` is
|
// an uncharacterized internal failure is reported as retryable, so the
|
||||||
// normalized into one here, so a future internal defect degrades to a
|
// retry has to actually re-run rather than replay a frozen 500. (No
|
||||||
// proper problem response instead of a bare `Error`.
|
// handler here mutates state before throwing an uncharacterized error,
|
||||||
const studioError = error instanceof StudioGatewayError
|
// so re-running cannot double-apply an effect.)
|
||||||
? error
|
if (replayable) state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(problem) } });
|
||||||
: gatewayProblem(
|
throw new StudioGatewayError(clone(problem));
|
||||||
500,
|
|
||||||
"STUDIO_UNAVAILABLE",
|
|
||||||
error instanceof Error ? error.message : "Studio가 요청을 처리하지 못했습니다.",
|
|
||||||
{ retryable: true },
|
|
||||||
);
|
|
||||||
state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(studioError.problem) } });
|
|
||||||
throw new StudioGatewayError(clone(studioError.problem));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { components } from "../../contracts/studio/generated.ts";
|
|||||||
import type { Asset } from "../../contracts/studio/contract.ts";
|
import type { Asset } from "../../contracts/studio/contract.ts";
|
||||||
import {
|
import {
|
||||||
evidenceCatalogEntriesFromAssets,
|
evidenceCatalogEntriesFromAssets,
|
||||||
supportsEvidenceKeyFromReadyAssets,
|
findResolvableAsset,
|
||||||
|
supportsResolvableEvidenceKey,
|
||||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||||
import {
|
import {
|
||||||
projectWorkingCopy as projectWorkingCopyWithEvidence,
|
projectWorkingCopy as projectWorkingCopyWithEvidence,
|
||||||
@@ -17,67 +18,41 @@ type ResolvedAsset = components["schemas"]["ResolvedAsset"];
|
|||||||
type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
|
type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
|
||||||
|
|
||||||
// The domain projection has no asset catalog access, so a CASE projection's
|
// The domain projection has no asset catalog access, so a CASE projection's
|
||||||
// EVIDENCE_FIGURE blocks come back without a resolved `asset`. This adapter owns
|
// EVIDENCE_FIGURE blocks come back without a resolved `asset`. This adapter
|
||||||
// the asset catalog (`adapters/static/evidence-assets.ts`, plus -- fix round 1
|
// owns the asset catalog (`adapters/static/evidence-assets.ts` for the one
|
||||||
// (I2) -- whatever `Asset`s the caller passes, merged via
|
// legacy key, plus whatever `Asset`s the caller passes), so it resolves the
|
||||||
// `evidenceCatalogEntriesFromAssets`, the same function `validate-working-copy.ts`
|
|
||||||
// and Instant Preview use, so this mock's own `createPreview` agrees with
|
|
||||||
// `validateDocument` about which evidence keys exist), so it resolves the
|
|
||||||
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
|
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
|
||||||
//
|
//
|
||||||
// Fix round 3 (I2 regression, third instance). Gate 1 (`supportsEvidenceKey`
|
// The key gate and the resolver below are the same expression over the same
|
||||||
// below) is built from `supportsEvidenceKeyFromReadyAssets` -- the exact
|
// inputs: both ask `findResolvableAsset(assets, key)` first and fall back to
|
||||||
// predicate the resolver below uses (`assetKey === key && READY &&
|
// the same `isSupportedEvidenceKey` legacy check. Nothing else decides which
|
||||||
// publicPath truthy`) -- not a catalog lookup. Round 1 gated on "is there
|
// Asset a key resolves to.
|
||||||
// any EVIDENCE catalog row for it" (too loose: rows that exist for
|
|
||||||
// something other than media, e.g. a QUESTION resolution target, passed).
|
|
||||||
// Round 2 narrowed to a catalog synthesized *from* Assets, but that catalog
|
|
||||||
// lookup (`evidenceCatalogEntryFor`, matching `id || label || publicPath`)
|
|
||||||
// is still a different expression than the resolver's own condition: a
|
|
||||||
// `READY` Asset with `publicPath: null`/`""`, or one whose `publicPath`
|
|
||||||
// happens to satisfy the legacy `/media/${someOtherKey}.svg` convention for
|
|
||||||
// a key that is not its own `assetKey`, passed that gate while the resolver
|
|
||||||
// below could not produce real pixels for it -- the same
|
|
||||||
// `validateDocument`-says-VALID / `createPreview`-throws disagreement, twice.
|
|
||||||
// Gate 2 (`evidenceCatalogEntryFor` below) keeps reading the merged catalog.
|
|
||||||
export function projectWorkingCopy(
|
export function projectWorkingCopy(
|
||||||
input: ProjectArguments[0],
|
input: ProjectArguments[0],
|
||||||
catalog: ProjectArguments[1],
|
catalog: ProjectArguments[1],
|
||||||
context: ProjectArguments[2],
|
context: ProjectArguments[2],
|
||||||
assets: readonly Asset[] = [],
|
assets: readonly Asset[] = [],
|
||||||
) {
|
) {
|
||||||
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
|
|
||||||
const supportsEvidenceKey = (key: string) =>
|
|
||||||
isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(assets)(key);
|
|
||||||
const model = projectWorkingCopyWithEvidence(
|
const model = projectWorkingCopyWithEvidence(
|
||||||
input,
|
input,
|
||||||
evidenceCatalog,
|
[...catalog, ...evidenceCatalogEntriesFromAssets(assets)],
|
||||||
context,
|
context,
|
||||||
supportsEvidenceKey,
|
supportsResolvableEvidenceKey(assets, isSupportedEvidenceKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
return resolveCaseEvidenceAssets(model, resolveAssetDescriptor(assets));
|
return resolveCaseEvidenceAssets(model, resolveAssetDescriptor(assets));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fix round 3. Total -- never throws -- the same discipline
|
* Total -- never throws. Tries the same two sources the key gate above tries,
|
||||||
* `instant-preview.tsx`'s resolver already followed. This class of bug has
|
* in the same order, over the same `assets`; the final placeholder is what a
|
||||||
* now recurred twice as "gate 1 passes a key the resolver then cannot
|
* key neither source can supply degrades to, rather than a bare `Error` put
|
||||||
* resolve, and the resolver throws a raw, unwrapped `Error`". With gate 1
|
* through the gateway port.
|
||||||
* above built from this function's exact condition, that specific
|
|
||||||
* disagreement cannot happen through `projectWorkingCopy`'s own interface
|
|
||||||
* any more -- but a resolver that structurally cannot throw is worth more
|
|
||||||
* than a resolver plus a comment asserting it never will (round 2's comment
|
|
||||||
* here made exactly that claim, and was wrong). Kept as defence in depth:
|
|
||||||
* if a future change reintroduces a mismatch, this degrades to a
|
|
||||||
* placeholder instead of putting a bare `Error` through the gateway port.
|
|
||||||
*/
|
*/
|
||||||
function resolveAssetDescriptor(assets: readonly Asset[]) {
|
function resolveAssetDescriptor(assets: readonly Asset[]) {
|
||||||
return (key: string): ResolvedAsset => {
|
return (key: string): ResolvedAsset => {
|
||||||
const asset = assets.find(
|
const asset = findResolvableAsset(assets, key);
|
||||||
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
|
if (asset) {
|
||||||
);
|
|
||||||
if (asset?.publicPath) {
|
|
||||||
return {
|
return {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
assetKey: asset.assetKey,
|
assetKey: asset.assetKey,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { components } from "../../contracts/studio/generated.ts";
|
|||||||
import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||||
import {
|
import {
|
||||||
evidenceCatalogEntriesFromAssets,
|
evidenceCatalogEntriesFromAssets,
|
||||||
supportsEvidenceKeyFromReadyAssets,
|
findResolvableAsset,
|
||||||
|
supportsResolvableEvidenceKey,
|
||||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||||
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
||||||
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
|
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
|
||||||
@@ -17,23 +18,16 @@ export type ValidationDependencies = {
|
|||||||
now: Date; validationId: string; dependencyRevision: string;
|
now: Date; validationId: string; dependencyRevision: string;
|
||||||
catalog: ReadonlyArray<CatalogEntry>; documents: ReadonlyArray<WorkingCopy>;
|
catalog: ReadonlyArray<CatalogEntry>; documents: ReadonlyArray<WorkingCopy>;
|
||||||
/**
|
/**
|
||||||
* Fix round 1 (I2). Without this, the mock's validate step only ever
|
* The Assets the session has loaded. Without them the mock's validate step
|
||||||
* recognized the one hardcoded legacy evidence key -- disconnected from
|
* would only recognize the one hardcoded legacy evidence key, disconnected
|
||||||
* the Asset system (Tasks 6/7) the Picker (Task 10) actually inserts keys
|
* from the Asset system the Picker inserts keys from, and every
|
||||||
* from -- so every Picker-inserted directive previewed live and then
|
* Picker-inserted directive would preview live and then fail validation.
|
||||||
* failed validation. Merged into `catalog` via
|
|
||||||
* `evidenceCatalogEntriesFromAssets`, the same function Instant Preview
|
|
||||||
* uses, so both paths agree on which keys a document may reference.
|
|
||||||
*
|
*
|
||||||
* Fix round 2: only used for gate 2 (the merged-catalog lookup) and the
|
* Every question asked of this array -- may the document reference the key,
|
||||||
* `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey`
|
* and is the referenced Asset decorative -- goes through
|
||||||
* below) intentionally does NOT read the merged catalog -- see that
|
* `findResolvableAsset`, the same function the preview projection resolves
|
||||||
* variable's comment.
|
* with, so validation can never judge a different Asset than the one that
|
||||||
*
|
* renders.
|
||||||
* Fix round 3: gate 1 also stopped reading a catalog derived from these
|
|
||||||
* assets at all -- it now calls `supportsEvidenceKeyFromReadyAssets`
|
|
||||||
* directly against this array, the same predicate the resolver uses, so
|
|
||||||
* the two can no longer disagree.
|
|
||||||
*/
|
*/
|
||||||
assets: ReadonlyArray<Asset>;
|
assets: ReadonlyArray<Asset>;
|
||||||
};
|
};
|
||||||
@@ -188,27 +182,18 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
|||||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||||
else try {
|
else try {
|
||||||
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
||||||
// Fix round 3 (I2 regression, third instance). Gate 1 must ask "does a
|
// The key gate is the preview projection's gate, verbatim: a resolvable
|
||||||
// real, resolvable Asset (or the legacy key) back this" -- not "is
|
// Asset or the legacy static key. The catalog check below is a separate
|
||||||
// there a catalog row for it" (round 1's bug) and not "is there a
|
// question ("does some catalog carry a row for this key"), and the
|
||||||
// catalog row synthesized *from* an Asset for it" either (round 2's
|
// decorative lookup reads the *same* Asset the projection resolves --
|
||||||
// bug: `evidenceCatalogEntryFor` matches on `id || label ||
|
// it used to be a last-wins `Map` against a first-wins `find`, so two
|
||||||
// publicPath`, which is still gate 2's question over fewer rows, and a
|
// READY Assets sharing one key could have alt judged against one Asset
|
||||||
// `READY` Asset with a null/empty `publicPath`, or one whose
|
// and rendered from another.
|
||||||
// `publicPath` happens to satisfy the legacy convention for a
|
const supportsEvidenceKey = supportsResolvableEvidenceKey(dependencies.assets, isSupportedEvidenceKey);
|
||||||
// *different* key, passed it while the resolver could not produce
|
|
||||||
// real pixels for it). Gate 1 is now built directly from
|
|
||||||
// `supportsEvidenceKeyFromReadyAssets` -- the exact predicate the
|
|
||||||
// resolver uses (`assetKey === key && READY && publicPath truthy`) --
|
|
||||||
// so the two cannot diverge. Gate 2 (`evidenceCatalogEntryFor` below)
|
|
||||||
// still reads the merged catalog; only gate 1 changed.
|
|
||||||
const supportsEvidenceKey = (key: string) =>
|
|
||||||
isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(dependencies.assets)(key);
|
|
||||||
const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset]));
|
|
||||||
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
||||||
if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
||||||
else if (!evidenceCatalogEntryFor(evidenceCatalog, block.key)) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
|
else if (!evidenceCatalogEntryFor(evidenceCatalog, block.key)) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
|
||||||
else if (block.alt.trim().length === 0 && !readyAssetsByKey.get(block.key)?.decorative) {
|
else if (block.alt.trim().length === 0 && !findResolvableAsset(dependencies.assets, block.key)?.decorative) {
|
||||||
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로는 실제
|
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로는 실제
|
||||||
// Asset.decorative로 같은 판정을 한다(decorative Asset은 대체
|
// Asset.decorative로 같은 판정을 한다(decorative Asset은 대체
|
||||||
// 텍스트가 없어도 통과한다 -- asset-picker.tsx가 그렇게 directive를
|
// 텍스트가 없어도 통과한다 -- asset-picker.tsx가 그렇게 directive를
|
||||||
|
|||||||
@@ -1,50 +1,94 @@
|
|||||||
import type { components } from "../../contracts/studio/generated.ts";
|
import type { components } from "../../contracts/studio/generated.ts";
|
||||||
import type { Asset } from "../../contracts/studio/contract.ts";
|
import type { Asset } from "../../contracts/studio/contract.ts";
|
||||||
import type { SupportsEvidenceKey } from "../public-render-content.ts";
|
import type { SupportsEvidenceKey } from "../public-render-content.ts";
|
||||||
import { evidenceCatalogEntryFor } from "./project-public-render-model.ts";
|
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single place that bridges the Asset system (Tasks 6/7) and the
|
* An `Asset` that can actually produce pixels for an evidence key: `READY`,
|
||||||
* document catalog `projectWorkingCopy` (Instant Preview) and
|
* and carrying a real `publicPath`. The narrowed `publicPath` is what lets a
|
||||||
* `validateWorkingCopy` (mock validation) gate `EVIDENCE_FIGURE` blocks
|
* caller build a `ResolvedAsset` without re-testing (or defaulting) the field
|
||||||
* against. Fix round 1 (I2): both paths previously derived their own,
|
* the gate already tested.
|
||||||
* independently-written notion of "does a loaded Asset make this key
|
*/
|
||||||
* referenceable" -- Instant Preview learned to accept a freshly loaded
|
export type ResolvableAsset = Asset & { publicPath: string };
|
||||||
* Asset, but the mock's validate step still only recognized the one
|
|
||||||
* hardcoded legacy key, so a directive the Asset Picker inserted could
|
/**
|
||||||
* preview perfectly and then fail validation. Both paths now call this one
|
* The one place that decides which `Asset` an evidence key resolves to.
|
||||||
* function instead.
|
|
||||||
*
|
*
|
||||||
* Fix round 2 (I2 regression): gate 1 ("may this document reference this key
|
* Every "can this key be rendered" question is `Boolean(findResolvableAsset(...))`
|
||||||
* at all") and gate 2 ("does some catalog actually carry it") must ask
|
* and every "what does it render" answer is the `Asset` this returns, so the
|
||||||
* different questions. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)`
|
* two cannot disagree. Fix rounds 1-3 each rebuilt the gate as a *separate*
|
||||||
* for BOTH gates, so any `EVIDENCE` catalog row backing something other than
|
* expression that merely agreed with the resolver on the inputs that round's
|
||||||
* an Asset (e.g. the pre-existing "Fetch Join Case" row that exists for
|
* tests used -- a catalog lookup over merged rows, then over synthesized rows,
|
||||||
* QUESTION resolution targets, not media) passed gate 1 even though no Asset
|
* then `assets.some(a => P(a) && Q(a))` against a resolver doing
|
||||||
* and no legacy key back it. Round 2 narrowed gate 1 to
|
* `Q(first a satisfying P)`. The last pair still disagreed whenever two
|
||||||
* `supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))` --
|
* `READY` Assets shared one `assetKey` and the first had no `publicPath`:
|
||||||
* still `evidenceCatalogEntryFor` under the hood, matching on
|
* `some` found the second, `find` returned the first, and the document
|
||||||
* `id || label || publicPath`.
|
* validated clean while the render model carried `publicPath: ""`.
|
||||||
*
|
*
|
||||||
* Fix round 3 (same regression, third instance): `evidenceCatalogEntryFor`
|
* ## Which Asset wins when several share a key
|
||||||
* is gate 2's question, asked over fewer rows -- it is still not the same
|
*
|
||||||
* predicate as the resolver's actual success condition (`assetKey === key &&
|
* The contract calls `assetKey` immutable and forbids reuse after publication
|
||||||
* managementStatus === "READY" && Boolean(publicPath)`), so a `READY` Asset
|
* history, so two `Asset`s sharing one is a backend contract violation -- but
|
||||||
* with `publicPath: null`/`""`, or one whose `publicPath` happens to match
|
* "that cannot happen" is exactly the reasoning that let this bug recur three
|
||||||
* `/media/${someOtherKey}.svg` for a key that isn't its own `assetKey`,
|
* times, and a list assembled from paged `listAssets` responses can carry the
|
||||||
* still passed gate 1 while the resolver could not produce real pixels for
|
* shape regardless. The choice made here is therefore **total** (every key
|
||||||
* it. Gate 1 must therefore be built directly from
|
* gets an answer) and **order-independent** (the answer does not depend on how
|
||||||
* `supportsEvidenceKeyFromReadyAssets` below -- the resolver's own
|
* the caller happened to sort or page the array): among the resolvable
|
||||||
* condition, not a catalog lookup -- never through
|
* candidates, the most recently updated one wins, ties broken by the greater
|
||||||
* `evidenceCatalogEntryFor`/`supportsEvidenceKeyIn`. Gate 2 keeps using the
|
* `id`. Newest-wins matches what a reader expects of a re-uploaded asset;
|
||||||
* merged catalog via `evidenceCatalogEntryFor` as before. See
|
* `id` is a total, stable tiebreak so equal timestamps still yield one answer.
|
||||||
* `validate-working-copy.ts` and `adapters/mock/project-public-render-model.ts`
|
* Plain string comparison, not `localeCompare` -- both fields are ASCII
|
||||||
* for the compositions; `instant-preview.tsx` deliberately keeps gate 1 on
|
* (RFC 3339 timestamps, UUIDs) and must not vary with the ambient locale.
|
||||||
* the merged catalog (its resolver can never throw, so a loose gate there
|
*/
|
||||||
* only ever degrades to a placeholder, and a strict assets-only gate was
|
export function findResolvableAsset(
|
||||||
* itself a regression -- see that file's comment).
|
assets: readonly Asset[],
|
||||||
|
key: string,
|
||||||
|
): ResolvableAsset | undefined {
|
||||||
|
let winner: ResolvableAsset | undefined;
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (asset.assetKey !== key) continue;
|
||||||
|
if (asset.managementStatus !== "READY") continue;
|
||||||
|
if (!asset.publicPath) continue;
|
||||||
|
const candidate = asset as ResolvableAsset;
|
||||||
|
if (!winner || outranks(candidate, winner)) winner = candidate;
|
||||||
|
}
|
||||||
|
return winner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outranks(candidate: ResolvableAsset, incumbent: ResolvableAsset): boolean {
|
||||||
|
if (candidate.updatedAt !== incumbent.updatedAt) {
|
||||||
|
return candidate.updatedAt > incumbent.updatedAt;
|
||||||
|
}
|
||||||
|
return candidate.id > incumbent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The evidence-key gate every caller uses. A key is referenceable when a real
|
||||||
|
* Asset resolves it, or when it is the caller's own legacy static key -- the
|
||||||
|
* one key that predates the Asset gateway and is served from a hardcoded
|
||||||
|
* registry instead. `presentation/` may not import `adapters/`, so each layer
|
||||||
|
* passes its own copy of that predicate rather than sharing an import; the
|
||||||
|
* composition itself lives here so it cannot drift between callers.
|
||||||
|
*/
|
||||||
|
export function supportsResolvableEvidenceKey(
|
||||||
|
assets: readonly Asset[],
|
||||||
|
isLegacyEvidenceKey: (key: string) => boolean,
|
||||||
|
): SupportsEvidenceKey {
|
||||||
|
return (key: string) =>
|
||||||
|
Boolean(findResolvableAsset(assets, key)) || isLegacyEvidenceKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges the Asset system into the document catalog the projection's
|
||||||
|
* *catalog* check (a separate question: "does some catalog carry a row for
|
||||||
|
* this key at all") reads. Only `READY` Assets synthesize a row, so an asset
|
||||||
|
* under review never makes a key referenceable.
|
||||||
|
*
|
||||||
|
* `id` is deliberately not `asset.id`: the catalog check also matches on `id`,
|
||||||
|
* so reusing the real Asset UUID would let anyone who has seen it elsewhere
|
||||||
|
* type it directly as a directive key. Prefixed so it can never collide with
|
||||||
|
* a real `asset.id`.
|
||||||
*/
|
*/
|
||||||
export function evidenceCatalogEntriesFromAssets(
|
export function evidenceCatalogEntriesFromAssets(
|
||||||
assets: readonly Asset[],
|
assets: readonly Asset[],
|
||||||
@@ -52,13 +96,6 @@ export function evidenceCatalogEntriesFromAssets(
|
|||||||
return assets
|
return assets
|
||||||
.filter((asset) => asset.managementStatus === "READY")
|
.filter((asset) => asset.managementStatus === "READY")
|
||||||
.map((asset) => ({
|
.map((asset) => ({
|
||||||
// Not `asset.id` (fix round 2): `evidenceCatalogEntryFor` also matches
|
|
||||||
// on `id`, so reusing the real Asset UUID here would let anyone who
|
|
||||||
// has seen that UUID elsewhere (an API response, an admin view) type
|
|
||||||
// it directly as a directive key and have it resolve — a path the
|
|
||||||
// Picker never produces (it always emits `assetKey`) and that widens
|
|
||||||
// the reference surface for no benefit. Prefixed so it can never
|
|
||||||
// collide with a real `asset.id` a caller might paste in.
|
|
||||||
id: `asset-evidence:${asset.id}`,
|
id: `asset-evidence:${asset.id}`,
|
||||||
type: "EVIDENCE",
|
type: "EVIDENCE",
|
||||||
label: asset.assetKey,
|
label: asset.assetKey,
|
||||||
@@ -66,44 +103,3 @@ export function evidenceCatalogEntriesFromAssets(
|
|||||||
dependencyRevision: asset.updatedAt,
|
dependencyRevision: asset.updatedAt,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `SupportsEvidenceKey` built from the same rule the catalog-entry gate
|
|
||||||
* uses. Deliberately kept -- `instant-preview.tsx`'s gate 1 still uses this
|
|
||||||
* over the merged catalog on purpose (see that file), but fix round 3
|
|
||||||
* removed it from `validate-working-copy.ts` and
|
|
||||||
* `adapters/mock/project-public-render-model.ts`'s gate 1: it answers gate
|
|
||||||
* 2's question, not "can a real Asset resolve this key", and reusing it for
|
|
||||||
* gate 1 is what let this bug recur twice.
|
|
||||||
*/
|
|
||||||
export function supportsEvidenceKeyIn(
|
|
||||||
catalog: ReadonlyArray<CatalogEntry>,
|
|
||||||
): SupportsEvidenceKey {
|
|
||||||
return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fix round 3. The resolver's own success condition, extracted so gate 1 can
|
|
||||||
* be built from the *exact* predicate that decides whether a key actually
|
|
||||||
* produces pixels, instead of a catalog lookup that can diverge from it
|
|
||||||
* (`evidenceCatalogEntryFor` matches on `id || label || publicPath` against
|
|
||||||
* whatever `evidenceCatalogEntriesFromAssets` happened to synthesize, which
|
|
||||||
* is not the same expression as "does some `READY` Asset's own `assetKey`
|
|
||||||
* equal this key and does it have real `publicPath`"). Two shapes where they
|
|
||||||
* disagreed: a `READY` Asset with `publicPath: null`/`""`, and a `READY`
|
|
||||||
* Asset whose `publicPath` happens to satisfy the legacy
|
|
||||||
* `/media/${someOtherKey}.svg` convention for a key that is not its own
|
|
||||||
* `assetKey`. Both passed the catalog-based gate while the resolver could
|
|
||||||
* not produce a real descriptor for them.
|
|
||||||
*/
|
|
||||||
export function supportsEvidenceKeyFromReadyAssets(
|
|
||||||
assets: readonly Asset[],
|
|
||||||
): SupportsEvidenceKey {
|
|
||||||
return (key: string) =>
|
|
||||||
assets.some(
|
|
||||||
(asset) =>
|
|
||||||
asset.assetKey === key &&
|
|
||||||
asset.managementStatus === "READY" &&
|
|
||||||
Boolean(asset.publicPath),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
|
import { findResolvableAsset } from "../../../domain/content-format/asset-evidence-catalog.ts";
|
||||||
import type {
|
import type {
|
||||||
EvidenceAsset,
|
EvidenceAsset,
|
||||||
ResolveEvidenceAsset,
|
ResolveEvidenceAsset,
|
||||||
@@ -92,21 +93,22 @@ function resolveWith(
|
|||||||
return legacy ?? MISSING_EVIDENCE_ASSET;
|
return legacy ?? MISSING_EVIDENCE_ASSET;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Instant Preview: resolves against the Asset list the editor has loaded. */
|
/**
|
||||||
|
* Instant Preview: resolves against the Asset list the editor has loaded.
|
||||||
|
* `findResolvableAsset` is the single decision -- the same one the preview's
|
||||||
|
* key gate and its block-descriptor resolver make -- so the image shown here
|
||||||
|
* is always the Asset the gate accepted. Building a local `Map` here instead
|
||||||
|
* was last-wins where the descriptor resolver was first-wins, which meant two
|
||||||
|
* READY Assets sharing one `assetKey` could put one Asset's caption over
|
||||||
|
* another Asset's image.
|
||||||
|
*/
|
||||||
export function createAssetCatalogResolver(
|
export function createAssetCatalogResolver(
|
||||||
assets: readonly Asset[],
|
assets: readonly Asset[],
|
||||||
): ResolveEvidenceAsset {
|
): ResolveEvidenceAsset {
|
||||||
const byKey = new Map<string, Asset>();
|
|
||||||
for (const asset of assets) {
|
|
||||||
// QUARANTINED/REJECTED must never render on any surface.
|
|
||||||
if (asset.managementStatus !== "READY") continue;
|
|
||||||
byKey.set(asset.assetKey, asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (key: string) =>
|
return (key: string) =>
|
||||||
resolveWith(key, (lookupKey) => {
|
resolveWith(key, (lookupKey) => {
|
||||||
const asset = byKey.get(lookupKey);
|
const asset = findResolvableAsset(assets, lookupKey);
|
||||||
if (!asset || asset.publicPath === null) return undefined;
|
if (!asset) return undefined;
|
||||||
return {
|
return {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
assetKey: asset.assetKey,
|
assetKey: asset.assetKey,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { components } from "../../../contracts/studio/generated.ts";
|
|||||||
import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||||
import {
|
import {
|
||||||
evidenceCatalogEntriesFromAssets,
|
evidenceCatalogEntriesFromAssets,
|
||||||
supportsEvidenceKeyIn,
|
findResolvableAsset,
|
||||||
|
supportsResolvableEvidenceKey,
|
||||||
} from "../../../domain/content-format/asset-evidence-catalog.ts";
|
} from "../../../domain/content-format/asset-evidence-catalog.ts";
|
||||||
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
|
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
|
||||||
import {
|
import {
|
||||||
@@ -27,19 +28,15 @@ function isLegacyStaticEvidenceKey(key: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
|
* Total -- never throws. Resolves through `findResolvableAsset`, the same
|
||||||
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
|
* function this file's key gate and `createAssetCatalogResolver` (which
|
||||||
* for the pixels actually shown; that comes from `resolveEvidenceAsset` below
|
* supplies the pixels) use, so the descriptor attached to a block and the
|
||||||
* (`createAssetCatalogResolver`). So this must never throw: a key the Asset Picker
|
* image rendered for it always come from one Asset.
|
||||||
* (Task 10) inserts, with no catalog match yet, still gets a placeholder descriptor
|
|
||||||
* instead of blanking the whole preview behind an error panel.
|
|
||||||
*/
|
*/
|
||||||
function resolveAssetDescriptor(assets: readonly Asset[]) {
|
function resolveAssetDescriptor(assets: readonly Asset[]) {
|
||||||
return (key: string): ResolvedAsset => {
|
return (key: string): ResolvedAsset => {
|
||||||
const asset = assets.find(
|
const asset = findResolvableAsset(assets, key);
|
||||||
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
|
if (asset) {
|
||||||
);
|
|
||||||
if (asset?.publicPath) {
|
|
||||||
return {
|
return {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
assetKey: asset.assetKey,
|
assetKey: asset.assetKey,
|
||||||
@@ -94,27 +91,12 @@ export function InstantPreview({
|
|||||||
draft,
|
draft,
|
||||||
effectiveCatalog,
|
effectiveCatalog,
|
||||||
{ mode: "PREVIEW", publishedAt: null },
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
// Gate 1 deliberately asks the SAME question gate 2 asks here
|
// The same expression `validate-working-copy.ts` and the mock's
|
||||||
// (`supportsEvidenceKeyIn` over the merged catalog) -- unlike
|
// preview projection use: a resolvable Asset, or the legacy static
|
||||||
// `validate-working-copy.ts`/`adapters/mock/project-public-render-model.ts`,
|
// key. A key this rejects fails the projection, and the error panel
|
||||||
// where reusing gate 2's question for gate 1 was the bug across fix
|
// below names it -- the same key `validateDocument` reports
|
||||||
// rounds 1-3 (see `asset-evidence-catalog.ts`'s module doc). The
|
// `EVIDENCE_UNSUPPORTED` for.
|
||||||
// difference: `resolveAssetDescriptor` below is total and cannot
|
supportsResolvableEvidenceKey(assets, isLegacyStaticEvidenceKey),
|
||||||
// throw, so a gate 1 that passes a key the resolver cannot fully
|
|
||||||
// resolve only ever degrades that one figure to a placeholder --
|
|
||||||
// never a crash, and there is no port-contract to violate here.
|
|
||||||
// Fix round 2 narrowed this to an assets-only check (mirroring the
|
|
||||||
// mock's gate at the time), which was itself a regression: a CASE
|
|
||||||
// referencing an Asset the editor has not yet loaded -- outside the
|
|
||||||
// Picker's 50-item page, or simply because `assets` is still `[]`
|
|
||||||
// while `listAssets` is in flight on first mount -- failed gate 1
|
|
||||||
// entirely and blanked the WHOLE preview, for every block in the
|
|
||||||
// document, not just the one unresolved reference. Reverted to the
|
|
||||||
// merged catalog (fix round 3): a key any part of the backend
|
|
||||||
// already considers legitimate (document catalog OR a loaded Asset)
|
|
||||||
// passes gate 1, and the resolver fills in real pixels when it has
|
|
||||||
// them or a placeholder when it does not.
|
|
||||||
supportsEvidenceKeyIn(effectiveCatalog),
|
|
||||||
),
|
),
|
||||||
resolveAssetDescriptor(assets),
|
resolveAssetDescriptor(assets),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
stateForUploaded,
|
stateForUploaded,
|
||||||
} from "../../../src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx";
|
} from "../../../src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx";
|
||||||
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
|
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
|
||||||
|
import { InstantPreview } from "../../../src/features/tech-log/presentation/studio/components/instant-preview.tsx";
|
||||||
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
||||||
@@ -914,7 +915,14 @@ test("a READY asset whose publicPath satisfies a different key's legacy conventi
|
|||||||
// re-evaluated fresh inside createPreview against the now-mutated assets,
|
// re-evaluated fresh inside createPreview against the now-mutated assets,
|
||||||
// correctly rejects -- and that internal ContentFormatError must not leave
|
// correctly rejects -- and that internal ContentFormatError must not leave
|
||||||
// the port unwrapped.
|
// the port unwrapped.
|
||||||
test("idempotent() wraps a raw domain error into a StudioGatewayError, never lets it escape the port", async () => {
|
//
|
||||||
|
// Fix round 4 amends the expected code. Round 3 mapped this to
|
||||||
|
// STUDIO_UNAVAILABLE/500 with `retryable: true`, which invites a retry that
|
||||||
|
// fails identically -- the Asset is gone, the projection will refuse again.
|
||||||
|
// A deterministic content failure between validate and preview is exactly
|
||||||
|
// what VALIDATION_STALE/409 means, and it is not retryable without
|
||||||
|
// re-validating first.
|
||||||
|
test("idempotent() maps a deterministic content failure to VALIDATION_STALE, never lets a raw error escape the port", async () => {
|
||||||
const asset = assetFixture({ assetKey: "race-check" });
|
const asset = assetFixture({ assetKey: "race-check" });
|
||||||
const assets = new Map<string, Asset>([[asset.assetKey, asset]]);
|
const assets = new Map<string, Asset>([[asset.assetKey, asset]]);
|
||||||
const gateway = createMockStudioGateway({ assets });
|
const gateway = createMockStudioGateway({ assets });
|
||||||
@@ -947,20 +955,35 @@ test("idempotent() wraps a raw domain error into a StudioGatewayError, never let
|
|||||||
|
|
||||||
assets.delete("race-check");
|
assets.delete("race-check");
|
||||||
|
|
||||||
|
const expectStale = (error: unknown) => {
|
||||||
|
assert.ok(
|
||||||
|
error instanceof StudioGatewayError,
|
||||||
|
`expected a StudioGatewayError, got ${String(error)}`,
|
||||||
|
);
|
||||||
|
assert.equal(error.code, "VALIDATION_STALE");
|
||||||
|
assert.equal(error.status, 409);
|
||||||
|
assert.equal(error.retryable, false);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
gateway.createPreview(
|
gateway.createPreview(
|
||||||
created.id,
|
created.id,
|
||||||
{ expectedVersion: created.version, validationId: report.validationId },
|
{ expectedVersion: created.version, validationId: report.validationId },
|
||||||
{ idempotencyKey: "race-preview" },
|
{ idempotencyKey: "race-preview" },
|
||||||
),
|
),
|
||||||
(error: unknown) => {
|
expectStale,
|
||||||
assert.ok(
|
);
|
||||||
error instanceof StudioGatewayError,
|
|
||||||
`expected a StudioGatewayError, got ${String(error)}`,
|
// Deterministic outcomes stay in the ledger: a same-key retry replays the
|
||||||
);
|
// same 409 rather than re-running work that cannot succeed.
|
||||||
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
await assert.rejects(
|
||||||
return true;
|
gateway.createPreview(
|
||||||
},
|
created.id,
|
||||||
|
{ expectedVersion: created.version, validationId: report.validationId },
|
||||||
|
{ idempotencyKey: "race-preview" },
|
||||||
|
),
|
||||||
|
expectStale,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1010,25 +1033,37 @@ test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Low note (reviewer): round 2's assets-only gate 1 in InstantPreview made a
|
// --- Fix round 4 ---
|
||||||
// CASE referencing a document-catalog-backed key blank the WHOLE preview
|
//
|
||||||
// behind the error panel whenever that key's Asset was not (yet) in the
|
// Rounds 1-3 each rebuilt gate 1 as a *separate expression* that happened to
|
||||||
// loaded `assets` array -- outside the Picker's 50-item page, or simply
|
// agree with the resolver on the inputs the round's tests used. They cannot
|
||||||
// because `assets` is still `[]` while `listAssets` is in flight. Fix round
|
// agree in general, because they were never the same function. Round 4
|
||||||
// 3 reverted InstantPreview's gate 1 to the merged catalog; this pins that
|
// collapses "can this key be rendered" and "which Asset does it render" into
|
||||||
// such a document now degrades the one unresolved figure to a placeholder
|
// one exported decision, `findResolvableAsset`, and puts every caller
|
||||||
// instead of failing the whole preview.
|
// (validation's gate, validation's decorative lookup, the mock projection's
|
||||||
test("Instant Preview degrades to a placeholder for a document-catalog-backed key with no loaded Asset, instead of blanking the whole preview", async () => {
|
// gate and resolver, InstantPreview's gate, resolver, and pixel resolver) on
|
||||||
const user = userEvent.setup();
|
// it. The tests below are the shapes that broke the round-3 pair.
|
||||||
const gateway = createMockStudioGateway();
|
|
||||||
const assetGateway = gatewayOf([]);
|
|
||||||
|
|
||||||
|
// `MockStudioDependencies.assets` is keyed by whatever the caller chooses --
|
||||||
|
// the gateway only ever reads `.values()`. Keying by `id` here is what lets a
|
||||||
|
// test express the shape the whole round is about: two Assets that share one
|
||||||
|
// `assetKey`.
|
||||||
|
function duplicateKeyAssets(...assets: Asset[]): Map<string, Asset> {
|
||||||
|
return new Map(assets.map((asset) => [asset.id, asset]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewFor(
|
||||||
|
assets: Map<string, Asset>,
|
||||||
|
bodyMarkdown: string,
|
||||||
|
slugSuffix: string,
|
||||||
|
) {
|
||||||
|
const gateway = createMockStudioGateway({ assets });
|
||||||
const created = await gateway.createDocument(
|
const created = await gateway.createDocument(
|
||||||
{
|
{
|
||||||
kind: "CASE",
|
kind: "CASE",
|
||||||
title: "카탈로그로만 뒷받침되는 근거 미리보기",
|
title: `중복 키 확인 (${slugSuffix})`,
|
||||||
slug: "catalog-only-evidence-preview-check",
|
slug: `duplicate-key-${slugSuffix}`,
|
||||||
summary: "로드된 Asset 없이 카탈로그만으로 뒷받침되는 키의 미리보기 동작을 확인합니다.",
|
summary: "하나의 assetKey를 공유하는 Asset이 둘일 때의 동작을 확인합니다.",
|
||||||
topicId: FIXTURE_IDS.topicJpa,
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
projectId: FIXTURE_IDS.projectBackend,
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
relations: [],
|
relations: [],
|
||||||
@@ -1037,25 +1072,276 @@ test("Instant Preview degrades to a placeholder for a document-catalog-backed ke
|
|||||||
environment: "env",
|
environment: "env",
|
||||||
reproduction: "repro",
|
reproduction: "repro",
|
||||||
lastVerifiedOn: "2026-08-14",
|
lastVerifiedOn: "2026-08-14",
|
||||||
bodyMarkdown: `## 제목\n\n:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`,
|
bodyMarkdown,
|
||||||
},
|
},
|
||||||
{ idempotencyKey: "catalog-only-evidence-preview-create" },
|
{ idempotencyKey: `duplicate-key-create-${slugSuffix}` },
|
||||||
|
);
|
||||||
|
const report = await gateway.validateDocument(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version },
|
||||||
|
{ idempotencyKey: `duplicate-key-validate-${slugSuffix}` },
|
||||||
|
);
|
||||||
|
const preview = await gateway.createPreview(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version, validationId: report.validationId },
|
||||||
|
{ idempotencyKey: `duplicate-key-preview-${slugSuffix}` },
|
||||||
|
);
|
||||||
|
const model = preview.renderModel;
|
||||||
|
assert.equal(model.kind, "CASE");
|
||||||
|
const figure = model.kind === "CASE"
|
||||||
|
? model.bodyBlocks.find((block) => block.type === "EVIDENCE_FIGURE")
|
||||||
|
: undefined;
|
||||||
|
assert.ok(figure && figure.type === "EVIDENCE_FIGURE", "expected an EVIDENCE_FIGURE block");
|
||||||
|
return { report, asset: figure.asset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The third live instance the reviewer found: two READY Assets share one
|
||||||
|
// `assetKey`, the first carries `publicPath: null`, the second a real path.
|
||||||
|
// `∃a. P(a) ∧ Q(a)` says yes (the second one). `Q(first a satisfying P)` says
|
||||||
|
// no, and the resolver falls through to the generic placeholder -- so
|
||||||
|
// `validateDocument` returns VALID with zero issues, `createPreview` succeeds,
|
||||||
|
// and `publishDocument` would snapshot a render model carrying
|
||||||
|
// `publicPath: ""` with nothing anywhere reporting an error.
|
||||||
|
test("two READY assets sharing one assetKey: the gate and the resolver agree, and the preview never carries an empty publicPath", async () => {
|
||||||
|
const withoutPath = assetFixture({
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
|
||||||
|
assetKey: "dup-null-first",
|
||||||
|
publicPath: null,
|
||||||
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const withPath = assetFixture({
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
|
||||||
|
assetKey: "dup-null-first",
|
||||||
|
publicPath: "/media/dup-null-first.svg",
|
||||||
|
updatedAt: "2026-08-14T00:00:01.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report, asset } = await previewFor(
|
||||||
|
duplicateKeyAssets(withoutPath, withPath),
|
||||||
|
':::evidence key="dup-null-first" alt="근거" caption="근거" zoom="false"\n:::',
|
||||||
|
"null-first",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
|
||||||
|
assert.equal(asset.publicPath, "/media/dup-null-first.svg");
|
||||||
|
assert.equal(asset.assetId, withPath.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Order-independence. The same two resolvable Assets in either array order
|
||||||
|
// must resolve to the same one; `find`-based first-wins picks whichever the
|
||||||
|
// caller happened to list first.
|
||||||
|
test("duplicate resolvable assets resolve to the same asset in either array order", async () => {
|
||||||
|
const older = assetFixture({
|
||||||
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1",
|
||||||
|
assetKey: "dup-order",
|
||||||
|
publicPath: "/media/dup-order-older.svg",
|
||||||
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const newer = assetFixture({
|
||||||
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2",
|
||||||
|
assetKey: "dup-order",
|
||||||
|
publicPath: "/media/dup-order-newer.svg",
|
||||||
|
updatedAt: "2026-08-14T00:00:01.000Z",
|
||||||
|
});
|
||||||
|
const body = ':::evidence key="dup-order" alt="근거" caption="근거" zoom="false"\n:::';
|
||||||
|
|
||||||
|
const forward = await previewFor(duplicateKeyAssets(older, newer), body, "order-forward");
|
||||||
|
const reverse = await previewFor(duplicateKeyAssets(newer, older), body, "order-reverse");
|
||||||
|
|
||||||
|
assert.deepEqual(forward.asset, reverse.asset);
|
||||||
|
assert.equal(forward.asset.assetId, newer.id);
|
||||||
|
assert.equal(forward.asset.publicPath, "/media/dup-order-newer.svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The third rule with the same disease: `readyAssetsByKey` was built with
|
||||||
|
// `new Map(...)` (last-wins) while the resolver used `find` (first-wins), so
|
||||||
|
// EVIDENCE_ALT_REQUIRED could be judged against a different Asset than the
|
||||||
|
// one actually rendered. Here the rendered Asset is `decorative` (empty alt
|
||||||
|
// is legitimate for it) but the last-wins map judges the other one.
|
||||||
|
test("EVIDENCE_ALT_REQUIRED is judged against the asset that actually renders, not a different one sharing the key", async () => {
|
||||||
|
const rendered = assetFixture({
|
||||||
|
id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc1",
|
||||||
|
assetKey: "alt-mismatch",
|
||||||
|
publicPath: "/media/alt-mismatch-rendered.svg",
|
||||||
|
decorative: true,
|
||||||
|
updatedAt: "2026-08-14T00:00:02.000Z",
|
||||||
|
});
|
||||||
|
const shadow = assetFixture({
|
||||||
|
id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc2",
|
||||||
|
assetKey: "alt-mismatch",
|
||||||
|
publicPath: "/media/alt-mismatch-shadow.svg",
|
||||||
|
decorative: false,
|
||||||
|
updatedAt: "2026-08-14T00:00:01.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report, asset } = await previewFor(
|
||||||
|
duplicateKeyAssets(rendered, shadow),
|
||||||
|
':::evidence key="alt-mismatch" alt="" caption="근거" zoom="false"\n:::',
|
||||||
|
"alt-mismatch",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
!report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
|
||||||
|
JSON.stringify(report.issues),
|
||||||
|
);
|
||||||
|
assert.equal(asset.assetId, rendered.id);
|
||||||
|
assert.equal(asset.decorative, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PREVIEW_CATALOG = [
|
||||||
|
{ id: "topic-jpa", type: "TOPIC", label: "JPA", publicPath: "/topics/jpa", dependencyRevision: "r1" },
|
||||||
|
{
|
||||||
|
id: "resolution-evidence",
|
||||||
|
type: "EVIDENCE",
|
||||||
|
label: "결론 근거",
|
||||||
|
publicPath: "/cases/some-case",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
] as never[];
|
||||||
|
|
||||||
|
function caseDraft(bodyMarkdown: string) {
|
||||||
|
return {
|
||||||
|
kind: "CASE",
|
||||||
|
title: "미리보기 게이트 확인",
|
||||||
|
slug: "preview-gate-check",
|
||||||
|
summary: "요약",
|
||||||
|
topicId: "topic-jpa",
|
||||||
|
projectId: null,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInstantPreview(bodyMarkdown: string, assets: readonly Asset[]) {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
|
<MemoryRouter>
|
||||||
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
|
<StudioProvider createGateway={() => createMockStudioGateway()}>
|
||||||
<DocumentEditorScreen documentId={created.id} />
|
<InstantPreview draft={caseDraft(bodyMarkdown)} catalog={PREVIEW_CATALOG} assets={assets} />
|
||||||
</StudioProvider>
|
</StudioProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await screen.findByLabelText("본문 Markdown");
|
// Round 3 loosened InstantPreview's gate back to the merged catalog, arguing
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
// the un-loaded-asset case would otherwise blank the preview. It blanks
|
||||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
// either way -- an un-loaded Asset contributes no catalog row either. All the
|
||||||
|
// looseness bought was keys the fetched EVIDENCE catalog carries: those
|
||||||
|
// rendered a caption with an empty gap and no message while validation said
|
||||||
|
// EVIDENCE_UNSUPPORTED. Every caller is now on the same expression, so the
|
||||||
|
// preview refuses exactly what validation refuses -- and says so.
|
||||||
|
test("Instant Preview refuses a key only the document catalog carries, exactly as mock validation does", () => {
|
||||||
|
const body = ':::evidence key="resolution-evidence" alt="근거" caption="근거" zoom="false"\n:::';
|
||||||
|
renderInstantPreview(body, []);
|
||||||
|
|
||||||
assert.equal(within(panel).queryByRole("alert"), null);
|
const alert = screen.getByRole("alert");
|
||||||
|
assert.match(alert.textContent ?? "", /resolution-evidence/);
|
||||||
|
|
||||||
|
const report = validateWorkingCopy(
|
||||||
|
{ ...(caseDraft(body) as object), id: "88888888-8888-4888-8888-888888888882", version: 1, updatedAt: "2026-08-14T00:00:00.000Z" } as never,
|
||||||
|
{
|
||||||
|
now: new Date("2026-08-14T01:00:00.000Z"),
|
||||||
|
validationId: "preview-gate-consistency",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
catalog: PREVIEW_CATALOG,
|
||||||
|
documents: [],
|
||||||
|
assets: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
within(panel).getByRole("heading", { level: 1, name: "카탈로그로만 뒷받침되는 근거 미리보기" }),
|
report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"),
|
||||||
|
JSON.stringify(report.issues),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The pixels come from `createAssetCatalogResolver` (a last-wins `Map`) while
|
||||||
|
// the block descriptor came from `resolveAssetDescriptor` (a first-wins
|
||||||
|
// `find`) -- a fourth expression in the same path, disagreeing with the third
|
||||||
|
// whenever two READY Assets share a key.
|
||||||
|
test("Instant Preview renders the same asset the gate and the descriptor picked when two assets share a key", () => {
|
||||||
|
const older = assetFixture({
|
||||||
|
id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd1",
|
||||||
|
assetKey: "dup-pixels",
|
||||||
|
publicPath: "/media/dup-pixels-older.svg",
|
||||||
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const newer = assetFixture({
|
||||||
|
id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd2",
|
||||||
|
assetKey: "dup-pixels",
|
||||||
|
publicPath: "/media/dup-pixels-newer.svg",
|
||||||
|
updatedAt: "2026-08-14T00:00:01.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderInstantPreview(
|
||||||
|
':::evidence key="dup-pixels" alt="중복 키 그림" caption="근거" zoom="false"\n:::',
|
||||||
|
[newer, older],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(screen.queryByRole("alert"), null);
|
||||||
|
assert.equal(
|
||||||
|
screen.getByAltText("중복 키 그림").getAttribute("src"),
|
||||||
|
"/media/dup-pixels-newer.svg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The idempotency ledger must not freeze an *uncharacterized* internal
|
||||||
|
// failure: the port reports it as retryable, so a same-key retry has to
|
||||||
|
// actually re-run the work instead of replaying the cached 500.
|
||||||
|
test("a same-key retry re-runs after an uncharacterized internal failure instead of replaying a cached 500", async () => {
|
||||||
|
let remainingFailures = 1;
|
||||||
|
const gateway = createMockStudioGateway({
|
||||||
|
dependencyRevision: {
|
||||||
|
current: () => {
|
||||||
|
if (remainingFailures > 0) {
|
||||||
|
remainingFailures -= 1;
|
||||||
|
throw new Error("의존성 리비전을 읽지 못했습니다.");
|
||||||
|
}
|
||||||
|
return "r1";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await gateway.createDocument(
|
||||||
|
{
|
||||||
|
kind: "CASE",
|
||||||
|
title: "일시적 내부 실패 재시도",
|
||||||
|
slug: "transient-internal-failure-retry",
|
||||||
|
summary: "성격이 규명되지 않은 내부 실패는 원장에 얼려두면 안 됩니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: "## 본문\n\n내용입니다.",
|
||||||
|
},
|
||||||
|
{ idempotencyKey: "transient-create" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
gateway.validateDocument(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version },
|
||||||
|
{ idempotencyKey: "transient-validate" },
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`);
|
||||||
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
||||||
|
assert.equal(error.retryable, true);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = await gateway.validateDocument(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version },
|
||||||
|
{ idempotencyKey: "transient-validate" },
|
||||||
|
);
|
||||||
|
assert.equal(report.documentId, created.id);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||||
import { evidenceCatalogEntriesFromAssets } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts";
|
import {
|
||||||
|
evidenceCatalogEntriesFromAssets,
|
||||||
|
supportsResolvableEvidenceKey,
|
||||||
|
} from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts";
|
||||||
import {
|
import {
|
||||||
ContentFormatError,
|
ContentFormatError,
|
||||||
parseCaseContent,
|
parseCaseContent,
|
||||||
@@ -15,27 +18,21 @@ import type { components } from "../../../src/features/tech-log/contracts/studio
|
|||||||
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fix round 2. Mirrors the gate-1 composition every production caller now
|
* Fix round 4. Calls the production composition itself
|
||||||
* uses (`validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
|
* (`supportsResolvableEvidenceKey`) rather than re-deriving it: every caller
|
||||||
* `instant-preview.tsx`): the legacy key, or an actually-loaded Asset backs
|
* -- `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
|
||||||
* it -- never "is there any EVIDENCE catalog row for it" (that's gate 2,
|
* `instant-preview.tsx` -- builds its key gate from this exact function, so a
|
||||||
* `evidenceCatalogEntryFor`, a separate question `projectWorkingCopy` asks
|
* caller drifting away from it now shows up here too. The question is "does a
|
||||||
* of its own `catalog` argument). Round 1 collapsed gate 1 into gate 2 in
|
* resolvable Asset (or the legacy static key) back this", never "is there any
|
||||||
* every production caller, which let a real EVIDENCE catalog row backing
|
* EVIDENCE catalog row for it" -- that is the projection's separate catalog
|
||||||
* something other than media (e.g. a QUESTION resolution-target row) pass
|
* check against its own `catalog` argument. Tests below that pass no `assets`
|
||||||
* gate 1 with no Asset and no legacy key behind it. Tests below pass no
|
* reduce to the legacy check alone.
|
||||||
* `assets`, so this reduces to the legacy check alone for them -- the same
|
|
||||||
* outcome bare `isSupportedEvidenceKey` gave before, just now visibly tied
|
|
||||||
* to the real composition instead of standing in for it by coincidence.
|
|
||||||
*/
|
*/
|
||||||
function supportsEvidenceKey(
|
function supportsEvidenceKey(
|
||||||
key: string,
|
key: string,
|
||||||
assets: readonly Asset[] = [],
|
assets: readonly Asset[] = [],
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return supportsResolvableEvidenceKey(assets, isSupportedEvidenceKey)(key);
|
||||||
isSupportedEvidenceKey(key) ||
|
|
||||||
Boolean(evidenceCatalogEntryFor(evidenceCatalogEntriesFromAssets(assets), key))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rich = `## 측정 결과 {#measurements}
|
const rich = `## 측정 결과 {#measurements}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// Every caller that answers "can this evidence key be rendered" and "which
|
||||||
|
// Asset does it render" must give the same answer, because they are one
|
||||||
|
// decision (`findResolvableAsset`). Three fix rounds each rebuilt one of them
|
||||||
|
// as a separate expression that agreed with the others only on the inputs
|
||||||
|
// that round's examples used, so this file does not test examples: it
|
||||||
|
// cross-products adversarial Asset lists (duplicate `assetKey`s, mixed
|
||||||
|
// `managementStatus`, null/empty `publicPath`, both array orders, the legacy
|
||||||
|
// static key shadowed by a real Asset) against a set of keys and asserts the
|
||||||
|
// agreement itself, through production entry points only.
|
||||||
|
//
|
||||||
|
// Against fix round 3's code this reported 53 of 144 combinations disagreeing.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "vitest";
|
||||||
|
|
||||||
|
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||||
|
import { projectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts";
|
||||||
|
import { validateWorkingCopy } from "../../../src/features/tech-log/adapters/mock/validate-working-copy.ts";
|
||||||
|
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||||
|
import { createAssetCatalogResolver } from "../../../src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts";
|
||||||
|
import { ContentFormatError } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
||||||
|
|
||||||
|
const LEGACY = "fetch-strategy-boundary";
|
||||||
|
|
||||||
|
function asset(overrides: Partial<Asset>): Asset {
|
||||||
|
return {
|
||||||
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
|
assetKey: "k",
|
||||||
|
kind: "IMAGE",
|
||||||
|
mediaType: "image/svg+xml",
|
||||||
|
originalFilename: "k.svg",
|
||||||
|
byteSize: 1,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
altText: null,
|
||||||
|
decorative: false,
|
||||||
|
managementStatus: "READY",
|
||||||
|
publicPath: "/media/k.svg",
|
||||||
|
usageCount: 0,
|
||||||
|
version: 1,
|
||||||
|
createdAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
} as Asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = (n: number) => `00000000-0000-4000-8000-00000000000${n}`;
|
||||||
|
|
||||||
|
const lists: Array<{ label: string; assets: Asset[] }> = [
|
||||||
|
{ label: "empty", assets: [] },
|
||||||
|
{ label: "single READY", assets: [asset({ id: id(1) })] },
|
||||||
|
{ label: "single READY null path", assets: [asset({ id: id(1), publicPath: null })] },
|
||||||
|
{ label: "single READY empty path", assets: [asset({ id: id(1), publicPath: "" })] },
|
||||||
|
{ label: "single QUARANTINED", assets: [asset({ id: id(1), managementStatus: "QUARANTINED" })] },
|
||||||
|
{ label: "single REJECTED", assets: [asset({ id: id(1), managementStatus: "REJECTED" })] },
|
||||||
|
{ label: "single ARCHIVED", assets: [asset({ id: id(1), managementStatus: "ARCHIVED" })] },
|
||||||
|
{
|
||||||
|
label: "dup: null path first, real second",
|
||||||
|
assets: [asset({ id: id(1), publicPath: null }), asset({ id: id(2), publicPath: "/media/k-2.svg", updatedAt: "2026-08-14T00:00:01.000Z" })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "dup: empty path first, real second",
|
||||||
|
assets: [asset({ id: id(1), publicPath: "" }), asset({ id: id(2), publicPath: "/media/k-2.svg", updatedAt: "2026-08-14T00:00:01.000Z" })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "dup: QUARANTINED newest, READY older",
|
||||||
|
assets: [asset({ id: id(1), managementStatus: "QUARANTINED", updatedAt: "2026-08-14T00:00:09.000Z" }), asset({ id: id(2) })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "dup: two resolvable, different paths",
|
||||||
|
assets: [
|
||||||
|
asset({ id: id(1), publicPath: "/media/k-1.svg", updatedAt: "2026-08-14T00:00:00.000Z" }),
|
||||||
|
asset({ id: id(2), publicPath: "/media/k-2.svg", updatedAt: "2026-08-14T00:00:01.000Z" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "dup: identical updatedAt (id tiebreak)",
|
||||||
|
assets: [asset({ id: id(3), publicPath: "/media/k-3.svg" }), asset({ id: id(2), publicPath: "/media/k-2.svg" })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "dup: decorative split",
|
||||||
|
assets: [
|
||||||
|
asset({ id: id(1), publicPath: "/media/k-1.svg", decorative: true, updatedAt: "2026-08-14T00:00:02.000Z" }),
|
||||||
|
asset({ id: id(2), publicPath: "/media/k-2.svg", decorative: false, updatedAt: "2026-08-14T00:00:01.000Z" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "decoy: other key owns /media/k.svg",
|
||||||
|
assets: [asset({ id: id(1), assetKey: "other", publicPath: "/media/k.svg" })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "legacy key backed by a real READY asset",
|
||||||
|
assets: [asset({ id: id(1), assetKey: LEGACY, publicPath: "/media/legacy-override.svg" })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "legacy key backed by a READY asset with null path",
|
||||||
|
assets: [asset({ id: id(1), assetKey: LEGACY, publicPath: null })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "legacy key: null-path first, real second",
|
||||||
|
assets: [
|
||||||
|
asset({ id: id(1), assetKey: LEGACY, publicPath: null }),
|
||||||
|
asset({ id: id(2), assetKey: LEGACY, publicPath: "/media/legacy-override.svg", updatedAt: "2026-08-14T00:00:01.000Z" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "three-way dup with mixed status and paths",
|
||||||
|
assets: [
|
||||||
|
asset({ id: id(1), publicPath: "" }),
|
||||||
|
asset({ id: id(2), managementStatus: "QUARANTINED", updatedAt: "2026-08-14T00:00:05.000Z" }),
|
||||||
|
asset({ id: id(3), publicPath: "/media/k-3.svg", updatedAt: "2026-08-14T00:00:02.000Z" }),
|
||||||
|
asset({ id: id(4), publicPath: "/media/k-4.svg", updatedAt: "2026-08-14T00:00:01.000Z" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const keys = ["k", "other", LEGACY, "missing"];
|
||||||
|
|
||||||
|
const catalog = [
|
||||||
|
{ id: "topic", type: "TOPIC", label: "T", publicPath: "/t", dependencyRevision: "r1" },
|
||||||
|
{ id: "evidence-row", type: "EVIDENCE", label: "some-label", publicPath: "/cases/x", dependencyRevision: "r1" },
|
||||||
|
{ id: "legacy-row", type: "EVIDENCE", label: LEGACY, publicPath: `/media/${LEGACY}.svg`, dependencyRevision: "r1" },
|
||||||
|
] as never[];
|
||||||
|
|
||||||
|
function draft(key: string, alt: string) {
|
||||||
|
return {
|
||||||
|
kind: "CASE",
|
||||||
|
title: "t",
|
||||||
|
slug: "s",
|
||||||
|
summary: "s",
|
||||||
|
topicId: "topic",
|
||||||
|
projectId: null,
|
||||||
|
relations: [],
|
||||||
|
problem: "p",
|
||||||
|
conclusion: "c",
|
||||||
|
environment: "e",
|
||||||
|
reproduction: "r",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: `:::evidence key="${key}" alt="${alt}" caption="c" zoom="false"\n:::`,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Descriptor = { assetId: string; assetKey: string; publicPath: string; decorative: boolean };
|
||||||
|
|
||||||
|
function project(key: string, alt: string, assets: readonly Asset[]) {
|
||||||
|
try {
|
||||||
|
const model = projectWorkingCopy(draft(key, alt), catalog, { mode: "PREVIEW", publishedAt: null }, assets) as {
|
||||||
|
bodyBlocks: Array<{ type: string; asset?: Descriptor }>;
|
||||||
|
};
|
||||||
|
const figure = model.bodyBlocks.find((block) => block.type === "EVIDENCE_FIGURE");
|
||||||
|
return { accepted: true as const, descriptor: figure!.asset! };
|
||||||
|
} catch (error) {
|
||||||
|
return { accepted: false as const, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(key: string, alt: string, assets: readonly Asset[]) {
|
||||||
|
return validateWorkingCopy(
|
||||||
|
{ ...(draft(key, alt) as object), id: "11111111-1111-4111-8111-111111111111", version: 1, updatedAt: "2026-08-14T00:00:00.000Z" } as never,
|
||||||
|
{ now: new Date("2026-08-14T01:00:00.000Z"), validationId: "v", dependencyRevision: "r1", catalog, documents: [], assets },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("gate, projection resolver, pixel resolver and validation never disagree", () => {
|
||||||
|
let checked = 0;
|
||||||
|
const problems: string[] = [];
|
||||||
|
const check = (condition: boolean, message: string) => {
|
||||||
|
if (!condition) problems.push(message);
|
||||||
|
return condition;
|
||||||
|
};
|
||||||
|
const cases = lists.flatMap(({ label, assets }) => [
|
||||||
|
{ label, assets },
|
||||||
|
{ label: `${label} (reversed)`, assets: [...assets].reverse() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const { label, assets } of cases) {
|
||||||
|
for (const key of keys) {
|
||||||
|
checked += 1;
|
||||||
|
const where = `${label} / ${key}`;
|
||||||
|
const projected = project(key, "a", assets);
|
||||||
|
const report = validate(key, "a", assets);
|
||||||
|
const unsupported = report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED");
|
||||||
|
const pixels = createAssetCatalogResolver(assets)(key);
|
||||||
|
|
||||||
|
// 1. Validation and the preview projection accept exactly the same keys.
|
||||||
|
check(!unsupported === projected.accepted, `${where}: validation and projection disagree`);
|
||||||
|
|
||||||
|
if (!projected.accepted) {
|
||||||
|
check(projected.error instanceof ContentFormatError, `${where}: expected a gate rejection, got ${String(projected.error)}`);
|
||||||
|
check(
|
||||||
|
projected.error instanceof ContentFormatError &&
|
||||||
|
/supported local evidence key not found/.test(projected.error.issues[0]!.detail),
|
||||||
|
`${where}: rejection is not the gate's`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Nothing the gate accepts may render as an empty path.
|
||||||
|
check(projected.descriptor.publicPath !== "", `${where}: accepted a key that renders as an empty publicPath`);
|
||||||
|
|
||||||
|
// 3. The block descriptor and the pixels are the same asset.
|
||||||
|
check(pixels.src === projected.descriptor.publicPath, `${where}: descriptor (${projected.descriptor.publicPath}) and pixels (${pixels.src}) disagree`);
|
||||||
|
|
||||||
|
// 4. The descriptor names an asset that really is in the list and really
|
||||||
|
// is renderable (or the legacy static asset, which is in no list).
|
||||||
|
const named = assets.find((candidate) => candidate.id === projected.descriptor.assetId);
|
||||||
|
if (named) {
|
||||||
|
check(named.assetKey === key, `${where}: descriptor names an asset with a different assetKey`);
|
||||||
|
check(named.managementStatus === "READY", `${where}: descriptor names a non-READY asset`);
|
||||||
|
check(Boolean(named.publicPath), `${where}: descriptor names an asset with no publicPath`);
|
||||||
|
} else {
|
||||||
|
check(isSupportedEvidenceKey(key), `${where}: descriptor names an asset that is not in the list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Reversing the array changes nothing anywhere.
|
||||||
|
const reversed = [...assets].reverse();
|
||||||
|
const reProjected = project(key, "a", reversed);
|
||||||
|
if (check(reProjected.accepted, `${where}: order-dependent acceptance`) && reProjected.accepted) {
|
||||||
|
check(
|
||||||
|
JSON.stringify(reProjected.descriptor) === JSON.stringify(projected.descriptor),
|
||||||
|
`${where}: order-dependent descriptor (${projected.descriptor.publicPath} vs ${reProjected.descriptor.publicPath})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check(createAssetCatalogResolver(reversed)(key).src === pixels.src, `${where}: order-dependent pixels`);
|
||||||
|
|
||||||
|
// 6. The alt rule is judged against the asset that actually renders.
|
||||||
|
const emptyAlt = validate(key, "", assets);
|
||||||
|
const altRequired = emptyAlt.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED");
|
||||||
|
check(altRequired === !projected.descriptor.decorative, `${where}: alt rule judged a different asset than the one rendered`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(checked >= 100, `only ${checked} combinations checked`);
|
||||||
|
assert.deepEqual(problems, [], `${problems.length} of ${checked} combinations disagree:\n${problems.join("\n")}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user