fix: rebuild gate 1 from the resolver's own predicate, not a catalog lookup
Fix round 3 (review of 7ff9728):
Round 2's gate 1 (supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets)))
still routed through evidenceCatalogEntryFor, matching on id || label ||
publicPath -- gate 2's question, asked over fewer rows, not the resolver's
actual success condition (assetKey === key && managementStatus === "READY"
&& Boolean(publicPath)). Two Asset shapes made the two predicates disagree:
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 isn't its own assetKey. Both passed gate 1 while the resolver
could not produce real pixels, reproducing the validateDocument-VALID /
createPreview-throws-raw-Error disagreement a second time.
Rebuilt gate 1 in validate-working-copy.ts and
adapters/mock/project-public-render-model.ts directly from a new domain
function, supportsEvidenceKeyFromReadyAssets, that mirrors the resolver's
exact condition -- never through evidenceCatalogEntryFor again. Gate 2
keeps reading the merged catalog. Made the mock resolver total (returns a
placeholder instead of throwing, matching instant-preview.tsx's resolver),
removing a comment that asserted an invariant the code did not hold.
Wrapped mock-studio-gateway.ts's idempotent() so any non-StudioGatewayError
that reaches its catch is normalized before crossing the port -- closing
the class generally, not just this instance.
Reverted instant-preview.tsx's own gate 1 to the merged catalog (unlike
the mock adapters, its resolver is provably total, so a loose gate there
only ever degrades to a placeholder) -- round 2's narrowing there was a
separate regression: a CASE referencing a document-catalog-backed key
outside the editor's currently-loaded Asset list blanked the entire
preview instead of degrading one figure.
Pinned both slip-through shapes failing on both mock paths with the
thrown/rejected error's type asserted (StudioGatewayError, never a raw
Error), pinned idempotent()'s new wrapping via a validate/preview race,
pinned EVIDENCE_NOT_FOUND as reachable through validateWorkingCopy
directly, and pinned Instant Preview's graceful degradation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7ff9728a5c
commit
783e9b2cf1
@@ -86,7 +86,27 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
|||||||
return clone(prior.outcome.value as T);
|
return clone(prior.outcome.value as T);
|
||||||
}
|
}
|
||||||
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) { if (error instanceof StudioGatewayError) { state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(error.problem) } }); throw new StudioGatewayError(clone(error.problem)); } throw error; }
|
catch (error) {
|
||||||
|
// Fix round 3. This port's contract is `StudioGatewayError` only --
|
||||||
|
// nothing else may cross it. Round 2 fixed one way a raw `Error`
|
||||||
|
// reached here (`createPreview`'s resolver); the class recurred a
|
||||||
|
// third time via a different Asset shape, and each time the actual
|
||||||
|
// symptom at the port boundary was the same: an unwrapped exception
|
||||||
|
// escaping `work()`. Rather than keep chasing individual internal
|
||||||
|
// causes, anything that isn't already a `StudioGatewayError` is
|
||||||
|
// normalized into one here, so a future internal defect degrades to a
|
||||||
|
// proper problem response instead of a bare `Error`.
|
||||||
|
const studioError = error instanceof StudioGatewayError
|
||||||
|
? error
|
||||||
|
: gatewayProblem(
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = (id: string) => { const value = state.documents.get(id); if (!value) throw gatewayProblem(404, "DOCUMENT_NOT_FOUND", `Document ${id} was not found.`); return value; };
|
const document = (id: string) => { const value = state.documents.get(id); if (!value) throw gatewayProblem(404, "DOCUMENT_NOT_FOUND", `Document ${id} was not found.`); return value; };
|
||||||
|
|||||||
@@ -1,7 +1,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,
|
||||||
supportsEvidenceKeyIn,
|
supportsEvidenceKeyFromReadyAssets,
|
||||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||||
import {
|
import {
|
||||||
projectWorkingCopy as projectWorkingCopyWithEvidence,
|
projectWorkingCopy as projectWorkingCopyWithEvidence,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
resolveEvidenceAssetDescriptor,
|
resolveEvidenceAssetDescriptor,
|
||||||
} from "../static/evidence-assets.ts";
|
} from "../static/evidence-assets.ts";
|
||||||
|
|
||||||
|
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
|
||||||
@@ -23,30 +25,30 @@ type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
|
|||||||
// `validateDocument` about which evidence keys exist), so it resolves the
|
// `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 2 (I2 regression). Gate 1 (`supportsEvidenceKey` below) must ask
|
// Fix round 3 (I2 regression, third instance). Gate 1 (`supportsEvidenceKey`
|
||||||
// "does a real Asset (or the legacy key) back this", not "is there any
|
// below) is built from `supportsEvidenceKeyFromReadyAssets` -- the exact
|
||||||
// EVIDENCE catalog row for it" -- `catalog` carries EVIDENCE rows unrelated
|
// predicate the resolver below uses (`assetKey === key && READY &&
|
||||||
// to media (e.g. a QUESTION resolution-target row). Handing gate 1 the
|
// publicPath truthy`) -- not a catalog lookup. Round 1 gated on "is there
|
||||||
// merged catalog let a key backed by nothing pass the projection and reach
|
// any EVIDENCE catalog row for it" (too loose: rows that exist for
|
||||||
// the resolver below, whose own `isSupportedEvidenceKey`-only fallback then
|
// something other than media, e.g. a QUESTION resolution target, passed).
|
||||||
// threw a raw, unwrapped `Error` -- the disagreement in the opposite
|
// Round 2 narrowed to a catalog synthesized *from* Assets, but that catalog
|
||||||
// direction from I2 (preview crashing instead of `validateDocument`
|
// lookup (`evidenceCatalogEntryFor`, matching `id || label || publicPath`)
|
||||||
// rejecting), and a contract violation on top (`StudioGatewayError` is what
|
// is still a different expression than the resolver's own condition: a
|
||||||
// may leave this port, never a bare `Error`). With gate 1 narrowed to
|
// `READY` Asset with `publicPath: null`/`""`, or one whose `publicPath`
|
||||||
// asset-backing/legacy only, any key the resolver would fail to resolve was
|
// happens to satisfy the legacy `/media/${someOtherKey}.svg` convention for
|
||||||
// already rejected before `projectWorkingCopyWithEvidence` returns, so the
|
// a key that is not its own `assetKey`, passed that gate while the resolver
|
||||||
// resolver's fallback throw below is unreachable for every key that made it
|
// below could not produce real pixels for it -- the same
|
||||||
// this far.
|
// `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 assetEntries = evidenceCatalogEntriesFromAssets(assets);
|
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
|
||||||
const evidenceCatalog = [...catalog, ...assetEntries];
|
|
||||||
const supportsEvidenceKey = (key: string) =>
|
const supportsEvidenceKey = (key: string) =>
|
||||||
isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key);
|
isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(assets)(key);
|
||||||
const model = projectWorkingCopyWithEvidence(
|
const model = projectWorkingCopyWithEvidence(
|
||||||
input,
|
input,
|
||||||
evidenceCatalog,
|
evidenceCatalog,
|
||||||
@@ -54,7 +56,24 @@ export function projectWorkingCopy(
|
|||||||
supportsEvidenceKey,
|
supportsEvidenceKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return resolveCaseEvidenceAssets(model, (key) => {
|
return resolveCaseEvidenceAssets(model, resolveAssetDescriptor(assets));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix round 3. Total -- never throws -- the same discipline
|
||||||
|
* `instant-preview.tsx`'s resolver already followed. This class of bug has
|
||||||
|
* now recurred twice as "gate 1 passes a key the resolver then cannot
|
||||||
|
* resolve, and the resolver throws a raw, unwrapped `Error`". With gate 1
|
||||||
|
* 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[]) {
|
||||||
|
return (key: string): ResolvedAsset => {
|
||||||
const asset = assets.find(
|
const asset = assets.find(
|
||||||
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
|
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
|
||||||
);
|
);
|
||||||
@@ -69,13 +88,17 @@ export function projectWorkingCopy(
|
|||||||
decorative: asset.decorative,
|
decorative: asset.decorative,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!isSupportedEvidenceKey(key)) {
|
if (isSupportedEvidenceKey(key)) {
|
||||||
// Defensive only -- gate 1 above already rejects any key that would
|
return resolveEvidenceAssetDescriptor(key);
|
||||||
// reach here without a resolvable Asset or the legacy key. Kept as a
|
|
||||||
// guard rather than removed so a future gate regression fails loudly
|
|
||||||
// here too, not silently.
|
|
||||||
throw new Error(`Unknown local evidence asset: ${key}`);
|
|
||||||
}
|
}
|
||||||
return resolveEvidenceAssetDescriptor(key);
|
return {
|
||||||
});
|
assetId: "00000000-0000-4000-8000-000000000000",
|
||||||
|
assetKey: key,
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
publicPath: "",
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
decorative: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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,
|
||||||
supportsEvidenceKeyIn,
|
supportsEvidenceKeyFromReadyAssets,
|
||||||
} 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";
|
||||||
@@ -29,6 +29,11 @@ export type ValidationDependencies = {
|
|||||||
* `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey`
|
* `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey`
|
||||||
* below) intentionally does NOT read the merged catalog -- see that
|
* below) intentionally does NOT read the merged catalog -- see that
|
||||||
* variable's comment.
|
* variable's comment.
|
||||||
|
*
|
||||||
|
* 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>;
|
||||||
};
|
};
|
||||||
@@ -182,19 +187,23 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
|||||||
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||||
else try {
|
else try {
|
||||||
const assetEntries = evidenceCatalogEntriesFromAssets(dependencies.assets);
|
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
||||||
const evidenceCatalog = [...dependencies.catalog, ...assetEntries];
|
// Fix round 3 (I2 regression, third instance). Gate 1 must ask "does a
|
||||||
// Fix round 2 (I2 regression). Gate 1 must ask "does a real Asset (or
|
// real, resolvable Asset (or the legacy key) back this" -- not "is
|
||||||
// the legacy key) back this" -- NOT "is there any EVIDENCE catalog row
|
// there a catalog row for it" (round 1's bug) and not "is there a
|
||||||
// for it". `dependencies.catalog` carries EVIDENCE rows unrelated to
|
// catalog row synthesized *from* an Asset for it" either (round 2's
|
||||||
// media (e.g. a QUESTION resolution-target row), so handing gate 1 the
|
// bug: `evidenceCatalogEntryFor` matches on `id || label ||
|
||||||
// merged catalog (as fix round 1 did) let a key backed by nothing
|
// publicPath`, which is still gate 2's question over fewer rows, and a
|
||||||
// resolve here while `createPreview`'s resolver -- which only ever
|
// `READY` Asset with a null/empty `publicPath`, or one whose
|
||||||
// knew about the legacy key and real Assets -- threw a raw, unwrapped
|
// `publicPath` happens to satisfy the legacy convention for a
|
||||||
// Error. Gate 2 (`evidenceCatalogEntryFor` below) still reads the
|
// *different* key, passed it while the resolver could not produce
|
||||||
// merged catalog; only gate 1 narrows.
|
// 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) =>
|
const supportsEvidenceKey = (key: string) =>
|
||||||
isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key);
|
isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(dependencies.assets)(key);
|
||||||
const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset]));
|
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}`);
|
||||||
|
|||||||
@@ -17,22 +17,34 @@ type CatalogEntry = components["schemas"]["CatalogEntry"];
|
|||||||
* preview perfectly and then fail validation. Both paths now call this one
|
* preview perfectly and then fail validation. Both paths now call this one
|
||||||
* function instead.
|
* function instead.
|
||||||
*
|
*
|
||||||
* Fix round 2 (I2 regression): this module supplies the *building blocks* for
|
* Fix round 2 (I2 regression): gate 1 ("may this document reference this key
|
||||||
* gate 1 (`supportsEvidenceKey` — "may this document reference this key at
|
* at all") and gate 2 ("does some catalog actually carry it") must ask
|
||||||
* all") and gate 2 (`evidenceCatalogEntryFor` — "does some catalog actually
|
* different questions. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)`
|
||||||
* carry it"); it deliberately does not hand callers a single combined
|
* for BOTH gates, so any `EVIDENCE` catalog row backing something other than
|
||||||
* predicate. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)` for BOTH
|
* an Asset (e.g. the pre-existing "Fetch Join Case" row that exists for
|
||||||
* gates, which made gate 1 ask the identical question gate 2 already asks —
|
* QUESTION resolution targets, not media) passed gate 1 even though no Asset
|
||||||
* so any `EVIDENCE` catalog row backing something other than an Asset (e.g.
|
* and no legacy key back it. Round 2 narrowed gate 1 to
|
||||||
* the pre-existing "Fetch Join Case" row that exists for QUESTION resolution
|
* `supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))` --
|
||||||
* targets, not media) passed gate 1 even though no Asset and no legacy key
|
* still `evidenceCatalogEntryFor` under the hood, matching on
|
||||||
* back it. Callers must instead compose gate 1 from *asset-backing only*:
|
* `id || label || publicPath`.
|
||||||
* `(key) => isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))(key)`,
|
*
|
||||||
* then hand gate 2 the merged catalog (`[...documentCatalog,
|
* Fix round 3 (same regression, third instance): `evidenceCatalogEntryFor`
|
||||||
* ...evidenceCatalogEntriesFromAssets(assets)]`) separately. See
|
* is gate 2's question, asked over fewer rows -- it is still not the same
|
||||||
* `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
|
* predicate as the resolver's actual success condition (`assetKey === key &&
|
||||||
* and `instant-preview.tsx` for the three compositions (each has its own
|
* managementStatus === "READY" && Boolean(publicPath)`), so a `READY` Asset
|
||||||
* notion of "legacy key" per its layer).
|
* with `publicPath: null`/`""`, or one whose `publicPath` happens to match
|
||||||
|
* `/media/${someOtherKey}.svg` for a key that isn't its own `assetKey`,
|
||||||
|
* still passed gate 1 while the resolver could not produce real pixels for
|
||||||
|
* it. Gate 1 must therefore be built directly from
|
||||||
|
* `supportsEvidenceKeyFromReadyAssets` below -- the resolver's own
|
||||||
|
* condition, not a catalog lookup -- never through
|
||||||
|
* `evidenceCatalogEntryFor`/`supportsEvidenceKeyIn`. Gate 2 keeps using the
|
||||||
|
* merged catalog via `evidenceCatalogEntryFor` as before. See
|
||||||
|
* `validate-working-copy.ts` and `adapters/mock/project-public-render-model.ts`
|
||||||
|
* for the compositions; `instant-preview.tsx` deliberately keeps gate 1 on
|
||||||
|
* 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
|
||||||
|
* itself a regression -- see that file's comment).
|
||||||
*/
|
*/
|
||||||
export function evidenceCatalogEntriesFromAssets(
|
export function evidenceCatalogEntriesFromAssets(
|
||||||
assets: readonly Asset[],
|
assets: readonly Asset[],
|
||||||
@@ -55,9 +67,43 @@ export function evidenceCatalogEntriesFromAssets(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `SupportsEvidenceKey` built from the same rule the catalog-entry gate uses. */
|
/**
|
||||||
|
* `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(
|
export function supportsEvidenceKeyIn(
|
||||||
catalog: ReadonlyArray<CatalogEntry>,
|
catalog: ReadonlyArray<CatalogEntry>,
|
||||||
): SupportsEvidenceKey {
|
): SupportsEvidenceKey {
|
||||||
return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key));
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
projectWorkingCopy,
|
projectWorkingCopy,
|
||||||
resolveCaseEvidenceAssets,
|
resolveCaseEvidenceAssets,
|
||||||
} from "../../../domain/content-format/project-public-render-model.ts";
|
} from "../../../domain/content-format/project-public-render-model.ts";
|
||||||
import type { SupportsEvidenceKey } from "../../../domain/public-render-content.ts";
|
|
||||||
import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts";
|
import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts";
|
||||||
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
@@ -21,28 +20,12 @@ type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
|||||||
/**
|
/**
|
||||||
* The one legacy key predates the Asset gateway entirely: the document
|
* The one legacy key predates the Asset gateway entirely: the document
|
||||||
* catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record
|
* catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record
|
||||||
* backs it, so `assets` never resolves it. This is also gate 1's "legacy"
|
* backs it, so `assets` never resolves it.
|
||||||
* half now (fix round 2) -- see `supportsEvidenceKeyForAssets` below.
|
|
||||||
*/
|
*/
|
||||||
function isLegacyStaticEvidenceKey(key: string): boolean {
|
function isLegacyStaticEvidenceKey(key: string): boolean {
|
||||||
return key === "fetch-strategy-boundary";
|
return key === "fetch-strategy-boundary";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gate 1: "may this document reference this key at all" -- the legacy key,
|
|
||||||
* or a `READY` Asset actually backs it. Deliberately narrower than gate 2
|
|
||||||
* (the merged-catalog lookup `projectWorkingCopy` runs internally): fix
|
|
||||||
* round 1 collapsed this into `supportsEvidenceKeyIn(effectiveCatalog)`,
|
|
||||||
* which made gate 1 ask the same question as gate 2 and let any `EVIDENCE`
|
|
||||||
* catalog row backing something other than an Asset (e.g. a QUESTION
|
|
||||||
* resolution-target row) pass gate 1 with no Asset and no legacy key behind
|
|
||||||
* it. See `asset-evidence-catalog.ts`'s module doc for the shared shape.
|
|
||||||
*/
|
|
||||||
function supportsEvidenceKeyForAssets(assets: readonly Asset[]): SupportsEvidenceKey {
|
|
||||||
const assetOnly = supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets));
|
|
||||||
return (key: string) => isLegacyStaticEvidenceKey(key) || assetOnly(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
|
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
|
||||||
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
|
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
|
||||||
@@ -111,7 +94,27 @@ export function InstantPreview({
|
|||||||
draft,
|
draft,
|
||||||
effectiveCatalog,
|
effectiveCatalog,
|
||||||
{ mode: "PREVIEW", publishedAt: null },
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
supportsEvidenceKeyForAssets(assets),
|
// Gate 1 deliberately asks the SAME question gate 2 asks here
|
||||||
|
// (`supportsEvidenceKeyIn` over the merged catalog) -- unlike
|
||||||
|
// `validate-working-copy.ts`/`adapters/mock/project-public-render-model.ts`,
|
||||||
|
// where reusing gate 2's question for gate 1 was the bug across fix
|
||||||
|
// rounds 1-3 (see `asset-evidence-catalog.ts`'s module doc). The
|
||||||
|
// difference: `resolveAssetDescriptor` below is total and cannot
|
||||||
|
// 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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { StudioProvider } from "../../../src/features/tech-log/presentation/stud
|
|||||||
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";
|
||||||
import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts";
|
import { projectWorkingCopy as mockProjectWorkingCopy } 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 { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
|
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
|
||||||
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
|
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
|
||||||
@@ -788,3 +789,273 @@ test("EVIDENCE_ALT_REQUIRED respects Asset.decorative -- empty alt passes for a
|
|||||||
JSON.stringify(informativeReport.issues),
|
JSON.stringify(informativeReport.issues),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Fix round 3 ---
|
||||||
|
|
||||||
|
// Round 2's gate 1 (`supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))`)
|
||||||
|
// still routed through `evidenceCatalogEntryFor` (id || label || publicPath),
|
||||||
|
// which is gate 2's question over fewer rows -- not the resolver's actual
|
||||||
|
// success condition (`assetKey === key && READY && publicPath truthy`). Two
|
||||||
|
// Asset shapes make the two predicates disagree; both are exercised through
|
||||||
|
// `MockStudioDependencies.assets` injection directly, the same way a test or
|
||||||
|
// harness (or a future adapter) could construct one, bypassing the mock
|
||||||
|
// uploader (which always sets a real, matching `publicPath` and so cannot
|
||||||
|
// produce either shape itself).
|
||||||
|
function assetFixture(overrides: Partial<Asset>): Asset {
|
||||||
|
return {
|
||||||
|
id: "77777777-7777-4777-8777-777777777771",
|
||||||
|
assetKey: "shape-check",
|
||||||
|
kind: "IMAGE",
|
||||||
|
mediaType: "image/svg+xml",
|
||||||
|
originalFilename: "shape.svg",
|
||||||
|
byteSize: 10,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
altText: null,
|
||||||
|
decorative: false,
|
||||||
|
managementStatus: "READY",
|
||||||
|
publicPath: "/media/shape-check.svg",
|
||||||
|
usageCount: 0,
|
||||||
|
version: 1,
|
||||||
|
createdAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectBothPathsRejectSlipThrough(
|
||||||
|
assets: Map<string, Asset>,
|
||||||
|
directiveKey: string,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
const gateway = createMockStudioGateway({ assets });
|
||||||
|
|
||||||
|
const created = await gateway.createDocument(
|
||||||
|
{
|
||||||
|
kind: "CASE",
|
||||||
|
title: `slip-through 확인 (${label})`,
|
||||||
|
slug: `slip-through-${label}`,
|
||||||
|
summary: "gate 1과 resolver가 다른 조건을 쓰면 일어나는 문제를 확인합니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: `:::evidence key="${directiveKey}" alt="근거" caption="근거" zoom="false"\n:::`,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: `slip-through-create-${label}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = await gateway.validateDocument(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version },
|
||||||
|
{ idempotencyKey: `slip-through-validate-${label}` },
|
||||||
|
);
|
||||||
|
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
|
||||||
|
assert.ok(
|
||||||
|
report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"),
|
||||||
|
JSON.stringify(report.issues),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
gateway.createPreview(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version, validationId: report.validationId },
|
||||||
|
{ idempotencyKey: `slip-through-preview-${label}` },
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(
|
||||||
|
error instanceof StudioGatewayError,
|
||||||
|
`expected a StudioGatewayError, got ${String(error)}`,
|
||||||
|
);
|
||||||
|
assert.equal(error.code, "VALIDATION_STALE");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [publicPath, label] of [
|
||||||
|
[null, "null-path"],
|
||||||
|
["", "empty-path"],
|
||||||
|
] as const) {
|
||||||
|
test(`a READY asset with a ${label === "null-path" ? "null" : "empty-string"} publicPath fails both mock paths, never as a raw Error`, async () => {
|
||||||
|
const key = `${label}-check`;
|
||||||
|
const asset = assetFixture({ assetKey: key, publicPath });
|
||||||
|
await expectBothPathsRejectSlipThrough(new Map([[asset.assetKey, asset]]), key, label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a READY asset whose publicPath satisfies a different key's legacy convention fails both mock paths, never as a raw Error", async () => {
|
||||||
|
// assetKey "real-owner" legitimately owns "/media/real-owner.svg", but its
|
||||||
|
// publicPath is set to "/media/decoy-key.svg" instead -- which happens to
|
||||||
|
// satisfy `evidenceCatalogEntryFor`'s legacy `/media/${key}.svg` match for
|
||||||
|
// the UNRELATED key "decoy-key", a key this asset does not own
|
||||||
|
// (`assetKey !== "decoy-key"`). The resolver requires `assetKey === key`,
|
||||||
|
// so it can never resolve "decoy-key" from this asset.
|
||||||
|
const asset = assetFixture({ assetKey: "real-owner", publicPath: "/media/decoy-key.svg" });
|
||||||
|
await expectBothPathsRejectSlipThrough(
|
||||||
|
new Map([[asset.assetKey, asset]]),
|
||||||
|
"decoy-key",
|
||||||
|
"mismatched-path",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// idempotent()'s new wrapping (part 3 of the ruling) is not exercised by the
|
||||||
|
// shapes above -- those are caught by validateDocument's now-correct gate,
|
||||||
|
// so createPreview only ever reaches its pre-existing VALIDATION_STALE
|
||||||
|
// guard, itself already a StudioGatewayError. To exercise the *new*
|
||||||
|
// wrapping, force a genuine internal disagreement: validate while the Asset
|
||||||
|
// still backs the key (VALID), then remove it before createPreview, without
|
||||||
|
// bumping dependencyRevision -- a narrow but real staleness gap the mock's
|
||||||
|
// existing guard does not close on its own. projectWorkingCopy's gate,
|
||||||
|
// re-evaluated fresh inside createPreview against the now-mutated assets,
|
||||||
|
// correctly rejects -- and that internal ContentFormatError must not leave
|
||||||
|
// the port unwrapped.
|
||||||
|
test("idempotent() wraps a raw domain error into a StudioGatewayError, never lets it escape the port", async () => {
|
||||||
|
const asset = assetFixture({ assetKey: "race-check" });
|
||||||
|
const assets = new Map<string, Asset>([[asset.assetKey, asset]]);
|
||||||
|
const gateway = createMockStudioGateway({ assets });
|
||||||
|
|
||||||
|
const created = await gateway.createDocument(
|
||||||
|
{
|
||||||
|
kind: "CASE",
|
||||||
|
title: "검증과 미리보기 사이의 경쟁 상태",
|
||||||
|
slug: "validate-preview-race-check",
|
||||||
|
summary: "검증 이후 Asset이 사라지면 gate가 새로 평가되어 거부해야 합니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: `:::evidence key="race-check" alt="근거" caption="근거" zoom="false"\n:::`,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: "race-create" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = await gateway.validateDocument(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version },
|
||||||
|
{ idempotencyKey: "race-validate" },
|
||||||
|
);
|
||||||
|
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
|
||||||
|
|
||||||
|
assets.delete("race-check");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
gateway.createPreview(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version, validationId: report.validationId },
|
||||||
|
{ idempotencyKey: "race-preview" },
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(
|
||||||
|
error instanceof StudioGatewayError,
|
||||||
|
`expected a StudioGatewayError, got ${String(error)}`,
|
||||||
|
);
|
||||||
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// EVIDENCE_NOT_FOUND has been unreachable through `createMockStudioGateway()`
|
||||||
|
// on its own: the fixture catalog it always loads already carries a row for
|
||||||
|
// the one legacy key, so gate 1 passing (via the legacy key) always implied
|
||||||
|
// gate 2 passing too. Pinned directly against `validateWorkingCopy` instead,
|
||||||
|
// with a hand-built catalog that omits the row on purpose.
|
||||||
|
test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the legacy key with no catalog EVIDENCE row", () => {
|
||||||
|
const document = {
|
||||||
|
id: "88888888-8888-4888-8888-888888888881",
|
||||||
|
version: 1,
|
||||||
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
kind: "CASE",
|
||||||
|
title: "레거시 키인데 카탈로그에 없음",
|
||||||
|
slug: "legacy-key-no-catalog-row",
|
||||||
|
summary: "레거시 키는 gate 1을 통과하지만 카탈로그에 행이 없으면 gate 2가 거부해야 합니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: ':::evidence key="fetch-strategy-boundary" alt="근거" caption="근거" zoom="false"\n:::',
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = validateWorkingCopy(document as never, {
|
||||||
|
now: new Date("2026-08-14T01:00:00.000Z"),
|
||||||
|
validationId: "evidence-not-found-check",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
catalog: [
|
||||||
|
{ id: FIXTURE_IDS.topicJpa, type: "TOPIC", label: "JPA", dependencyRevision: "r1" },
|
||||||
|
{ id: FIXTURE_IDS.projectBackend, type: "PROJECT", label: "Backend", dependencyRevision: "r1" },
|
||||||
|
// Deliberately no EVIDENCE row for "fetch-strategy-boundary" -- gate 1
|
||||||
|
// (the legacy key) passes, gate 2 (this catalog) must not.
|
||||||
|
] as never,
|
||||||
|
documents: [document as never],
|
||||||
|
assets: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
|
||||||
|
assert.ok(
|
||||||
|
report.issues.some((issue) => issue.code === "EVIDENCE_NOT_FOUND"),
|
||||||
|
JSON.stringify(report.issues),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Low note (reviewer): round 2's assets-only gate 1 in InstantPreview made a
|
||||||
|
// 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
|
||||||
|
// loaded `assets` array -- outside the Picker's 50-item page, or simply
|
||||||
|
// because `assets` is still `[]` while `listAssets` is in flight. Fix round
|
||||||
|
// 3 reverted InstantPreview's gate 1 to the merged catalog; this pins that
|
||||||
|
// such a document now degrades the one unresolved figure to a placeholder
|
||||||
|
// instead of failing the whole preview.
|
||||||
|
test("Instant Preview degrades to a placeholder for a document-catalog-backed key with no loaded Asset, instead of blanking the whole preview", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const gateway = createMockStudioGateway();
|
||||||
|
const assetGateway = gatewayOf([]);
|
||||||
|
|
||||||
|
const created = await gateway.createDocument(
|
||||||
|
{
|
||||||
|
kind: "CASE",
|
||||||
|
title: "카탈로그로만 뒷받침되는 근거 미리보기",
|
||||||
|
slug: "catalog-only-evidence-preview-check",
|
||||||
|
summary: "로드된 Asset 없이 카탈로그만으로 뒷받침되는 키의 미리보기 동작을 확인합니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: `## 제목\n\n:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: "catalog-only-evidence-preview-create" },
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
|
||||||
|
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
|
||||||
|
<DocumentEditorScreen documentId={created.id} />
|
||||||
|
</StudioProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByLabelText("본문 Markdown");
|
||||||
|
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||||
|
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
||||||
|
|
||||||
|
assert.equal(within(panel).queryByRole("alert"), null);
|
||||||
|
assert.ok(
|
||||||
|
within(panel).getByRole("heading", { level: 1, name: "카탈로그로만 뒷받침되는 근거 미리보기" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user