Archived
refactor(test): migrate WorkspaceFilesPanel test to shared template-inspection seam
Route genuine Lit event-wiring (upload input change, form submit, file-tree row clicks) through the shared templateInspection.testSupport escape hatch and add the required proportionality comment. Move viewer content messaging (empty/loading/binary states) to a new public workspaceFileViewerStatusLabel seam on the component instead of scraping Lit markup for text. Delete the per-file inspection helper cluster; drop @public from the two shared helpers that now have a real importer.
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileContentResponse, FileTreeEntry } from "../api";
|
||||
import { initialAppState } from "../appState";
|
||||
import type { WorkspacePanelContext } from "../plugins/types";
|
||||
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
|
||||
import { WorkspaceFilesPanel, startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel";
|
||||
// Genuine Lit event-wiring extraction (upload input/form submit and file-tree
|
||||
// row clicks) routes through the shared, type-guarded template-inspection escape
|
||||
// hatch; see ../templateInspection.testSupport for the proportionality
|
||||
// rationale. Viewer content messaging is asserted via the public
|
||||
// workspaceFileViewerStatusLabel seam instead of scraping Lit markup.
|
||||
import { findOptionalTemplateEventHandlerAfterMarker, templateClickHandlerForText, templateEventHandlerAfterMarker } from "../templateInspection.testSupport";
|
||||
import { WorkspaceFilesPanel, startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceFileViewerStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -18,14 +23,14 @@ describe("workspace-files-panel upload review", () => {
|
||||
const panel = new WorkspaceFilesPanel();
|
||||
panel.context = workspacePanelContext({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload });
|
||||
|
||||
const inputChange = findTemplateEventHandler<Event>(panel.render(), `id="workspace-upload-input"`);
|
||||
const inputChange = templateEventHandlerAfterMarker(panel.render(), `id="workspace-upload-input"`);
|
||||
const input = new FakeHTMLInputElement(files);
|
||||
inputChange(new EventWithCurrentTarget("change", input));
|
||||
|
||||
expect(input.value).toBe("");
|
||||
expect(onStartWorkspaceUpload).not.toHaveBeenCalled();
|
||||
|
||||
const submit = findTemplateEventHandler<SubmitEvent>(panel.render(), "<form @submit=");
|
||||
const submit = templateEventHandlerAfterMarker<SubmitEvent>(panel.render(), "<form @submit=");
|
||||
const submitEvent = new FakeSubmitEvent("submit", { cancelable: true });
|
||||
submit(submitEvent);
|
||||
|
||||
@@ -36,7 +41,7 @@ describe("workspace-files-panel upload review", () => {
|
||||
overwrite: false,
|
||||
selectUploadedFile: true,
|
||||
});
|
||||
expect(findOptionalTemplateEventHandler<SubmitEvent>(panel.render(), "<form @submit=")).toBeUndefined();
|
||||
expect(findOptionalTemplateEventHandlerAfterMarker<SubmitEvent>(panel.render(), "<form @submit=")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,20 +60,36 @@ describe("workspace-files-panel file tree boundary", () => {
|
||||
});
|
||||
|
||||
const rendered = panel.render();
|
||||
const text = collectTemplateText(rendered);
|
||||
|
||||
expect(text).toContain("▾");
|
||||
expect(text).toContain("src");
|
||||
expect(text).toContain("main.ts");
|
||||
expect(text).toContain("README.md");
|
||||
expect(text).toContain("Binary file: README.md · 4.0 KB");
|
||||
expect(text).not.toContain("Select a file.");
|
||||
|
||||
findTemplateClickHandlerForText<Event>(rendered, "src")(new Event("click"));
|
||||
findTemplateClickHandlerForText<Event>(rendered, "README.md")(new Event("click"));
|
||||
// The nested child (main.ts) is only reachable when the expanded directory
|
||||
// actually renders its children, so a working click handler on it proves the
|
||||
// expanded-tree structure without scraping markup for the row text.
|
||||
templateClickHandlerForText(rendered, "main.ts")(new Event("click"));
|
||||
templateClickHandlerForText(rendered, "src")(new Event("click"));
|
||||
templateClickHandlerForText(rendered, "README.md")(new Event("click"));
|
||||
|
||||
expect(onExpandDir).toHaveBeenCalledWith("src");
|
||||
expect(onSelectFile).toHaveBeenCalledWith("src/main.ts");
|
||||
expect(onSelectFile).toHaveBeenCalledWith("README.md");
|
||||
|
||||
// Viewer messaging (selected binary file) is a content concern; assert it
|
||||
// through the public seam rather than the rendered template.
|
||||
expect(workspaceFileViewerStatusLabel(workspacePanelContext({
|
||||
selectedFilePath: "README.md",
|
||||
selectedFileContent: binaryFileContent("README.md", 4096),
|
||||
}))).toBe("Binary file: README.md · 4.0 KB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceFileViewerStatusLabel", () => {
|
||||
it("messages empty, loading, and binary viewer states while deferring to real viewers", () => {
|
||||
expect(workspaceFileViewerStatusLabel(workspacePanelContext({ selectedFilePath: undefined }))).toBe("Select a file.");
|
||||
expect(workspaceFileViewerStatusLabel(workspacePanelContext({ selectedFilePath: "" }))).toBe("Select a file.");
|
||||
expect(workspaceFileViewerStatusLabel(workspacePanelContext({ selectedFilePath: "notes.md", selectedFileContent: undefined }))).toBe("Loading notes.md…");
|
||||
expect(workspaceFileViewerStatusLabel(workspacePanelContext({
|
||||
selectedFilePath: "logo.png",
|
||||
selectedFileContent: { ...binaryFileContent("logo.png", 10), mediaType: "image" },
|
||||
}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,111 +171,6 @@ describe("workspaceUploadReviewError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
type TemplateEventHandler<E extends Event> = (event: E) => void;
|
||||
|
||||
function findTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateEventHandler<E>(template, marker);
|
||||
if (handler === undefined) throw new Error(`Expected template event handler after ${marker}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function findOptionalTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> | undefined {
|
||||
return findInTemplate(template);
|
||||
|
||||
function findInTemplate(current: TemplateResult): TemplateEventHandler<E> | undefined {
|
||||
const strings = templateStrings(current);
|
||||
const values = templateValues(current);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const staticChunk = strings[index];
|
||||
const value = values[index];
|
||||
if (staticChunk !== undefined && staticChunk.includes(marker) && isTemplateEventHandler<E>(value)) return value;
|
||||
const nestedHandler = findInValue(value);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findInValue(value: unknown): TemplateEventHandler<E> | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nestedHandler = findInValue(item);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (isTemplateResult(value)) return findInTemplate(value);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Node-based Lit tests cannot click shadow DOM here; keep direct handler extraction
|
||||
// anchored to rendered file labels and assert the observable context callbacks.
|
||||
function findTemplateClickHandlerForText<E extends Event>(template: TemplateResult, text: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateClickHandlerForText<E>(template, text);
|
||||
if (handler === undefined) throw new Error(`Expected click handler near ${text}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function findOptionalTemplateClickHandlerForText<E extends Event>(value: unknown, text: string): TemplateEventHandler<E> | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isTemplateResult(value)) return undefined;
|
||||
|
||||
for (const item of templateValues(value)) {
|
||||
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
if (!collectTemplateText(value).includes(text)) return undefined;
|
||||
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const staticChunk = strings[index];
|
||||
const candidate = values[index];
|
||||
if (staticChunk !== undefined && staticChunk.includes("@click") && isTemplateEventHandler<E>(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectTemplateText(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.map((item) => collectTemplateText(item)).join("");
|
||||
if (isTemplateResult(value)) {
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
return strings.map((part, index) => `${part}${index < values.length ? collectTemplateText(values[index]) : ""}`).join("");
|
||||
}
|
||||
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isTemplateEventHandler<E extends Event>(value: unknown): value is TemplateEventHandler<E> {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
class FakeFileList implements FileList {
|
||||
readonly length: number;
|
||||
[index: number]: File;
|
||||
|
||||
@@ -95,11 +95,13 @@ export class WorkspaceFilesPanel extends LitElement {
|
||||
}
|
||||
|
||||
private renderFileViewer(context: WorkspacePanelContext): TemplateResult {
|
||||
const status = workspaceFileViewerStatusLabel(context);
|
||||
if (status !== undefined) return html`<p class="muted">${status}</p>`;
|
||||
const file = context.selectedFileContent;
|
||||
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`<p class="muted">Select a file.</p>`;
|
||||
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
|
||||
// workspaceFileViewerStatusLabel already returned for the undefined/binary
|
||||
// cases above; this guard only narrows the type for the code viewer path.
|
||||
if (file === undefined) return html`<p class="muted">Select a file.</p>`;
|
||||
if (file.mediaType === "image") return this.renderImageViewer(context, file);
|
||||
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
|
||||
loadCodeViewer();
|
||||
return html`
|
||||
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
|
||||
@@ -399,6 +401,22 @@ export function workspaceUploadReviewDefaults(destinationFolder: string): { dest
|
||||
return { destinationFolder, createDirs: true, overwrite: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The muted status message the file viewer shows instead of file content, or
|
||||
* `undefined` when a real image/code viewer renders. Pure seam so tests can
|
||||
* assert viewer messaging (empty/loading/binary) without scraping Lit markup.
|
||||
*/
|
||||
export function workspaceFileViewerStatusLabel(
|
||||
context: Pick<WorkspacePanelContext, "selectedFilePath" | "selectedFileContent">,
|
||||
): string | undefined {
|
||||
const file = context.selectedFileContent;
|
||||
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return "Select a file.";
|
||||
if (file === undefined) return `Loading ${context.selectedFilePath}…`;
|
||||
if (file.mediaType === "image") return undefined;
|
||||
if (file.binary) return `Binary file: ${file.path} · ${formatFileSize(file.size)}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function startDirectWorkspaceUpload(
|
||||
context: Pick<WorkspacePanelContext, "workspaceUploadDefaultFolder" | "onStartWorkspaceUpload">,
|
||||
files: readonly File[],
|
||||
|
||||
@@ -242,8 +242,6 @@ export function findOptionalTemplateEventHandlerNearMarker<E extends Event = Eve
|
||||
*
|
||||
* Use when the marker is not the handler's own attribute but a stable anchor
|
||||
* that precedes it (e.g. a `send-button` id whose `@click=` handler follows).
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function templateEventHandlerAfterMarker<E extends Event = Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
|
||||
@@ -331,8 +329,6 @@ export function findOptionalTemplateEventHandlerAfterValue<E extends Event = Eve
|
||||
*
|
||||
* Anchors wiring to user-facing row/label text (e.g. a file name) rather than
|
||||
* incidental handler order.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function templateClickHandlerForText<E extends Event = Event>(template: TemplateResult, text: string, clickMarker = "@click"): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateClickHandlerForText<E>(template, text, clickMarker);
|
||||
|
||||
Reference in New Issue
Block a user