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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { FileContentResponse, FileTreeEntry } from "../api";
|
import type { FileContentResponse, FileTreeEntry } from "../api";
|
||||||
import { initialAppState } from "../appState";
|
import { initialAppState } from "../appState";
|
||||||
import type { WorkspacePanelContext } from "../plugins/types";
|
import type { WorkspacePanelContext } from "../plugins/types";
|
||||||
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
|
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(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -18,14 +23,14 @@ describe("workspace-files-panel upload review", () => {
|
|||||||
const panel = new WorkspaceFilesPanel();
|
const panel = new WorkspaceFilesPanel();
|
||||||
panel.context = workspacePanelContext({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload });
|
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);
|
const input = new FakeHTMLInputElement(files);
|
||||||
inputChange(new EventWithCurrentTarget("change", input));
|
inputChange(new EventWithCurrentTarget("change", input));
|
||||||
|
|
||||||
expect(input.value).toBe("");
|
expect(input.value).toBe("");
|
||||||
expect(onStartWorkspaceUpload).not.toHaveBeenCalled();
|
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 });
|
const submitEvent = new FakeSubmitEvent("submit", { cancelable: true });
|
||||||
submit(submitEvent);
|
submit(submitEvent);
|
||||||
|
|
||||||
@@ -36,7 +41,7 @@ describe("workspace-files-panel upload review", () => {
|
|||||||
overwrite: false,
|
overwrite: false,
|
||||||
selectUploadedFile: true,
|
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 rendered = panel.render();
|
||||||
const text = collectTemplateText(rendered);
|
|
||||||
|
|
||||||
expect(text).toContain("▾");
|
// The nested child (main.ts) is only reachable when the expanded directory
|
||||||
expect(text).toContain("src");
|
// actually renders its children, so a working click handler on it proves the
|
||||||
expect(text).toContain("main.ts");
|
// expanded-tree structure without scraping markup for the row text.
|
||||||
expect(text).toContain("README.md");
|
templateClickHandlerForText(rendered, "main.ts")(new Event("click"));
|
||||||
expect(text).toContain("Binary file: README.md · 4.0 KB");
|
templateClickHandlerForText(rendered, "src")(new Event("click"));
|
||||||
expect(text).not.toContain("Select a file.");
|
templateClickHandlerForText(rendered, "README.md")(new Event("click"));
|
||||||
|
|
||||||
findTemplateClickHandlerForText<Event>(rendered, "src")(new Event("click"));
|
|
||||||
findTemplateClickHandlerForText<Event>(rendered, "README.md")(new Event("click"));
|
|
||||||
|
|
||||||
expect(onExpandDir).toHaveBeenCalledWith("src");
|
expect(onExpandDir).toHaveBeenCalledWith("src");
|
||||||
|
expect(onSelectFile).toHaveBeenCalledWith("src/main.ts");
|
||||||
expect(onSelectFile).toHaveBeenCalledWith("README.md");
|
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 {
|
class FakeFileList implements FileList {
|
||||||
readonly length: number;
|
readonly length: number;
|
||||||
[index: number]: File;
|
[index: number]: File;
|
||||||
|
|||||||
@@ -95,11 +95,13 @@ export class WorkspaceFilesPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderFileViewer(context: WorkspacePanelContext): TemplateResult {
|
private renderFileViewer(context: WorkspacePanelContext): TemplateResult {
|
||||||
|
const status = workspaceFileViewerStatusLabel(context);
|
||||||
|
if (status !== undefined) return html`<p class="muted">${status}</p>`;
|
||||||
const file = context.selectedFileContent;
|
const file = context.selectedFileContent;
|
||||||
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`<p class="muted">Select a file.</p>`;
|
// workspaceFileViewerStatusLabel already returned for the undefined/binary
|
||||||
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
|
// 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.mediaType === "image") return this.renderImageViewer(context, file);
|
||||||
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
|
|
||||||
loadCodeViewer();
|
loadCodeViewer();
|
||||||
return html`
|
return html`
|
||||||
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
|
<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 };
|
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(
|
export function startDirectWorkspaceUpload(
|
||||||
context: Pick<WorkspacePanelContext, "workspaceUploadDefaultFolder" | "onStartWorkspaceUpload">,
|
context: Pick<WorkspacePanelContext, "workspaceUploadDefaultFolder" | "onStartWorkspaceUpload">,
|
||||||
files: readonly File[],
|
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
|
* 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).
|
* 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> {
|
export function templateEventHandlerAfterMarker<E extends Event = Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
|
||||||
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
|
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
|
* Anchors wiring to user-facing row/label text (e.g. a file name) rather than
|
||||||
* incidental handler order.
|
* incidental handler order.
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
export function templateClickHandlerForText<E extends Event = Event>(template: TemplateResult, text: string, clickMarker = "@click"): TemplateEventHandler<E> {
|
export function templateClickHandlerForText<E extends Event = Event>(template: TemplateResult, text: string, clickMarker = "@click"): TemplateEventHandler<E> {
|
||||||
const handler = findOptionalTemplateClickHandlerForText<E>(template, text, clickMarker);
|
const handler = findOptionalTemplateClickHandlerForText<E>(template, text, clickMarker);
|
||||||
|
|||||||
Reference in New Issue
Block a user