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
@@ -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 { 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 { 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 { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.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),
|
||||
);
|
||||
});
|
||||
|
||||
// --- 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