feat: search the server from the Asset Picker without shrinking preview's catalog

The Picker asked for `{ managementStatus: "READY", limit: 50 }` and
ignored `nextCursor`, so the 51st-oldest READY asset onward could not be
inserted at all. It sits inside the editing flow, where scrolling a long
list is the wrong interaction, so it gets search rather than a "더 보기"
control -- and it still loads a first page, because an empty panel until
you type is hostile to an author reaching for the asset they uploaded a
minute ago.

The trap this creates is the substance of the change. The editor screen's
Asset array feeds two consumers with opposite needs: the Picker's
*displayed* list, which a search must narrow, and Instant Preview's
*resolution catalog*, which a search must never narrow -- its gate
rejects any key no loaded asset backs. Handing search results straight to
the screen's `setAssets` would blank previously-inserted evidence figures
the moment the author typed a query.

They are kept apart by making the screen's callback additive by
construction rather than by convention: `mergeAssetCatalog` (domain,
beside `findResolvableAsset`) can only grow the set, and both writers --
observed pages and fresh uploads -- go through it. The prop is renamed
`onAssetsObserved` so the contract reads as "what the Picker saw", not
"what to show"; the replacing version was one `setAssets` reference away
and looked correct.

A key backed by neither the first page nor the current results is still
unresolvable. That is the known deferred limitation, not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 13:16:31 +09:00
co-authored by Claude Opus 5
parent a5825c18b5
commit f69edb633d
7 changed files with 428 additions and 33 deletions
+276 -2
View File
@@ -2,7 +2,7 @@
import assert from "node:assert/strict";
import { afterAll, beforeAll, test } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
@@ -10,6 +10,7 @@ import {
AssetPicker,
buildEvidenceDirective,
} from "../../../src/features/tech-log/presentation/studio/components/asset-picker.tsx";
import { mergeAssetCatalog } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts";
import {
AssetUploadDialog,
stateForError,
@@ -151,7 +152,7 @@ test("reports the loaded Asset list once listAssets resolves", async () => {
<AssetPicker
gateway={gatewayOf([READY, QUARANTINED])}
onInsert={() => {}}
onLoaded={(assets) => (loaded as Asset[][]).push([...assets])}
onAssetsObserved={(assets) => (loaded as Asset[][]).push([...assets])}
/>,
);
@@ -1561,3 +1562,276 @@ test("the dialog refuses to upload a non-decorative asset with no alt text", asy
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
assert.equal(calls, 1);
});
// --- Alignment follow-up, item 2: the Picker searches the server ---
//
// The Picker asked for `{ managementStatus: "READY", limit: 50 }` and ignored
// `nextCursor`, so the 51st-oldest READY asset onward could not be inserted at
// all. The Picker sits inside the editing flow, where scrolling a long list is
// the wrong interaction, so it gets search rather than a "더 보기" control --
// but it still loads a first page, because an empty panel until you type is
// hostile to an author who just wants the asset they uploaded a minute ago.
function searchableGateway(items: ReadonlyArray<Record<string, unknown>>) {
const calls: Array<Record<string, unknown>> = [];
const gateway: StudioAssetGateway = {
async listAssets(query) {
calls.push({ ...query });
const q = (query.q ?? "").trim().toLocaleLowerCase("ko-KR");
const matched = items.filter(
(item) =>
(!query.managementStatus ||
item.managementStatus === query.managementStatus) &&
(!q ||
String(item.assetKey).toLocaleLowerCase("ko-KR").includes(q)),
);
return { items: matched, nextCursor: null } as never;
},
async uploadAsset() {
throw new Error("not used");
},
async getAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
throw new Error("not used");
},
async deleteAsset() {},
};
return { gateway, calls };
}
const PICKER_A = {
...READY,
id: "44444444-4444-4444-8444-444444444441",
assetKey: "inserted-diagram",
altText: "삽입한 다이어그램",
publicPath: "/media/inserted-diagram.svg",
};
const PICKER_B = {
...READY,
id: "44444444-4444-4444-8444-444444444442",
assetKey: "other-diagram",
altText: "다른 다이어그램",
publicPath: "/media/other-diagram.svg",
};
test("the Picker shows a first page before anything is typed, then narrows to the submitted query", async () => {
const user = userEvent.setup();
const { gateway, calls } = searchableGateway([PICKER_A, PICKER_B]);
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
// Useful before the author types: both assets are offered.
await screen.findByRole("button", { name: /inserted-diagram/ });
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() =>
assert.equal(screen.queryByRole("button", { name: /inserted-diagram/ }), null),
);
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
assert.equal(calls.at(-1)?.q, "other");
// The server filter must survive search: an asset under review is never
// insertable, whatever the query.
assert.equal(calls.at(-1)?.managementStatus, "READY");
});
test("typing in the Picker's search box never reaches the gateway on its own", async () => {
const user = userEvent.setup();
const { gateway, calls } = searchableGateway([PICKER_A]);
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
await screen.findByRole("button", { name: /inserted-diagram/ });
assert.equal(calls.length, 1);
await user.type(screen.getByLabelText("Asset 검색"), "inserted");
assert.equal(calls.length, 1);
});
test("a QUARANTINED asset the server wrongly returns for a query is still not offered", async () => {
const user = userEvent.setup();
const unsafe = { ...QUARANTINED, assetKey: "unsafe-diagram" };
const gateway: StudioAssetGateway = {
async listAssets() {
// Deliberately ignores `managementStatus`: the screen's own filter is
// the thing under test.
return { items: [PICKER_A, unsafe], nextCursor: null } as never;
},
async uploadAsset() {
throw new Error("not used");
},
async getAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
throw new Error("not used");
},
async deleteAsset() {},
};
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
await screen.findByRole("button", { name: /inserted-diagram/ });
await user.type(screen.getByLabelText("Asset 검색"), "diagram");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() =>
assert.ok(screen.getByRole("button", { name: /inserted-diagram/ })),
);
assert.equal(screen.queryByRole("button", { name: /unsafe-diagram/ }), null);
});
test("the Picker reports search results too, so the catalog grows with every query", async () => {
const user = userEvent.setup();
const observed: Asset[][] = [];
const { gateway } = searchableGateway([PICKER_A, PICKER_B]);
render(
<AssetPicker
gateway={gateway}
onInsert={() => {}}
onAssetsObserved={(assets) => observed.push([...assets])}
/>,
);
await screen.findByRole("button", { name: /inserted-diagram/ });
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(observed.length, 2));
assert.deepEqual(
observed[1]!.map((asset) => asset.assetKey),
["other-diagram"],
);
});
test("a slow earlier Picker search never overwrites the newer query's results", async () => {
const user = userEvent.setup();
const pending: Array<{ settle: (page: unknown) => void }> = [];
const gateway: StudioAssetGateway = {
async listAssets() {
return new Promise((resolve) => {
pending.push({ settle: resolve });
}) as never;
},
async uploadAsset() {
throw new Error("not used");
},
async getAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
throw new Error("not used");
},
async deleteAsset() {},
};
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
await waitFor(() => assert.equal(pending.length, 1));
await act(async () => {
pending[0]!.settle({ items: [PICKER_A], nextCursor: null });
});
await screen.findByRole("button", { name: /inserted-diagram/ });
const input = screen.getByLabelText("Asset 검색");
await user.type(input, "slow");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 2));
await user.clear(input);
await user.type(input, "other");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 3));
await act(async () => {
pending[2]!.settle({ items: [PICKER_B], nextCursor: null });
});
await screen.findByRole("button", { name: /other-diagram/ });
await act(async () => {
pending[1]!.settle({ items: [PICKER_A], nextCursor: null });
});
assert.equal(screen.queryByRole("button", { name: /inserted-diagram/ }), null);
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
});
// --- The trap this item creates ---
//
// The editor screen's Asset array feeds two consumers with opposite needs: the
// Picker's *displayed* list (which a search must narrow) and Instant Preview's
// *resolution catalog* (which a search must never shrink -- its gate rejects a
// key no loaded asset backs). Wiring the Picker's results straight back into
// the array the screen owns makes typing a search term blank out evidence
// figures that rendered a moment earlier: a worse bug than the one being
// fixed. The two are kept apart by making the screen's callback additive --
// the Picker reports what it *observed*, the screen merges it into a catalog
// that only ever grows.
test("a Picker search never drops an already-inserted asset out of Instant Preview's resolution", async () => {
const user = userEvent.setup();
const gateway = createMockStudioGateway();
const { gateway: assetGateway } = searchableGateway([PICKER_A, PICKER_B]);
render(
<MemoryRouter initialEntries={[`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={FIXTURE_IDS.redisAdapterCase} />
</StudioProvider>
</MemoryRouter>,
);
await screen.findByLabelText("본문 Markdown");
await user.click(await screen.findByRole("button", { name: /inserted-diagram/ }));
const body = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value;
assert.ok(body.includes('key="inserted-diagram"'), body);
// Now search for something that excludes the asset just inserted. The Picker
// must narrow; the preview must not.
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() =>
assert.equal(screen.queryByRole("button", { name: /inserted-diagram/ }), null),
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
assert.equal(
within(panel).queryByRole("alert")?.textContent ?? null,
null,
"the preview refused a directive it resolved before the search",
);
const [image] = within(panel).getAllByAltText("삽입한 다이어그램");
assert.equal(image!.getAttribute("src"), "/media/inserted-diagram.svg");
});
test("mergeAssetCatalog keeps every previously known asset and lets a fresher copy win", () => {
const known = assetFixture({ id: "50505050-5050-4050-8050-505050505051", assetKey: "known" });
const staleCopy = assetFixture({
id: "50505050-5050-4050-8050-505050505052",
assetKey: "replaced",
publicPath: "/media/replaced-old.svg",
updatedAt: "2026-08-14T00:00:00.000Z",
});
const freshCopy = { ...staleCopy, publicPath: "/media/replaced-new.svg", version: 2 };
const arrival = assetFixture({ id: "50505050-5050-4050-8050-505050505053", assetKey: "arrival" });
const merged = mergeAssetCatalog([known, staleCopy], [freshCopy, arrival]);
assert.deepEqual(
[...merged].map((asset) => asset.assetKey).sort(),
["arrival", "known", "replaced"],
);
assert.equal(
merged.find((asset) => asset.id === staleCopy.id)?.publicPath,
"/media/replaced-new.svg",
);
});
test("mergeAssetCatalog can only grow: an empty arrival keeps everything", () => {
const known = assetFixture({ id: "60606060-6060-4060-8060-606060606061", assetKey: "kept" });
assert.deepEqual(mergeAssetCatalog([known], []).map((asset) => asset.id), [known.id]);
});