mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
cloud_gallery: fix build, 100% coverage, aggressive lint; whitelist dufs_client
- dufs-client.test.ts: type fetch mocks as vi.fn<typeof fetch>() (fixes the TS2493/TS2352 tuple/undefined build errors that broke `pnpm build`) - dufs-client.ts: explicit method arg drops the unreachable `?? "GET"` branch - gallery/grid: -1 viewer-index sentinel + pass media entry, removing three unreachable defensive guards; onOpenMedia now takes the entry - use-hash-path: extract serverPath() so the SSR snapshot is covered - eslint: strict+stylistic type-checked, react-hooks, reportUnusedDisable Directives; drop eslint-plugin-react (incompatible with ESLint 10) - add grid.test.tsx + use-listing.test.ts; coverage thresholds 100/100/100/100 (62 tests: statements/branches/functions/lines all 100%) - phone_focus_mode/config.sh: whitelist com.kuhy.dufs_client Deployed live via setup_cloud_gallery.sh (render-spa); fixed ffmpeg by renaming a stale /usr/local/bin shadow linking the removed libvpx.so.11; regenerated thumbnails (10 made, 0 failed). APK installed + launch-verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
This commit is contained in:
parent
76d4e8e0cf
commit
c6f1b85c78
@ -2,12 +2,23 @@ import js from "@eslint/js";
|
||||
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
// Aggressive, type-aware config: everything typescript-eslint's strict +
|
||||
// stylistic *type-checked* presets flag is an error, plus the React Hooks
|
||||
// rules. Unused disable directives are themselves errors, so every
|
||||
// `eslint-disable` must be load-bearing.
|
||||
//
|
||||
// eslint-plugin-react is intentionally omitted: 7.37.x still calls the
|
||||
// `context.getFilename()` API that ESLint 10 removed, so it crashes the run.
|
||||
// TypeScript already covers prop typing, and react-hooks (v10-ready) covers the
|
||||
// hook-specific rules that TS cannot.
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage"] },
|
||||
{ linterOptions: { reportUnusedDisableDirectives: "error" } },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: { "react-hooks": reactHooksPlugin },
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
@ -23,6 +34,12 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Asserting on a vitest mock method (e.g. `expect(client.remove)`) trips
|
||||
// unbound-method even though the reference is safe; disable it in tests.
|
||||
files: ["**/*.test.{ts,tsx}", "src/test/**"],
|
||||
rules: { "@typescript-eslint/unbound-method": "off" },
|
||||
},
|
||||
{
|
||||
files: ["*.js", "*.mjs", "*.cjs"],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
|
||||
@ -30,7 +30,6 @@
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~5.8.3",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -47,6 +47,12 @@ describe("parsePropfind", () => {
|
||||
expect(entry?.size).toBe(0);
|
||||
expect(entry?.mtimeMs).toBe(0);
|
||||
});
|
||||
it("coerces an unparsable size/mtime to zero", () => {
|
||||
const xml = `<D:multistatus xmlns:D="DAV:"><D:response><D:href>/y.bin</D:href><D:propstat><D:prop><D:resourcetype/><D:getcontentlength>not-a-number</D:getcontentlength><D:getlastmodified>never</D:getlastmodified></D:prop></D:propstat></D:response></D:multistatus>`;
|
||||
const [entry] = parsePropfind(xml, "/");
|
||||
expect(entry?.size).toBe(0);
|
||||
expect(entry?.mtimeMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortEntries", () => {
|
||||
@ -83,17 +89,19 @@ function jsonResponse(body: string, ok = true, status = 200): Response {
|
||||
|
||||
describe("createDufsClient", () => {
|
||||
it("list() PROPFINDs, sorts, and returns entries", async () => {
|
||||
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse(PROPFIND_XML)));
|
||||
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
|
||||
const fetchImpl = vi.fn<typeof fetch>(() =>
|
||||
Promise.resolve(jsonResponse(PROPFIND_XML)),
|
||||
);
|
||||
const client = createDufsClient(fetchImpl);
|
||||
const entries = await client.list("/Media/2026/07");
|
||||
const [url, init] = fetchImpl.mock.calls[0] ?? [];
|
||||
expect(url).toBe("/Media/2026/07");
|
||||
expect((init as RequestInit).method).toBe("PROPFIND");
|
||||
const firstCall = fetchImpl.mock.calls.at(0);
|
||||
expect(firstCall?.[0]).toBe("/Media/2026/07");
|
||||
expect(firstCall?.[1]?.method).toBe("PROPFIND");
|
||||
expect(entries.length).toBe(2);
|
||||
});
|
||||
|
||||
it("builds file and thumbnail URLs (encoded)", () => {
|
||||
const client = createDufsClient(vi.fn() as unknown as typeof fetch);
|
||||
const client = createDufsClient(vi.fn<typeof fetch>());
|
||||
expect(client.fileUrl("/Media/a b.jpg")).toBe("/Media/a%20b.jpg");
|
||||
expect(client.thumbUrl("/Media/a b.jpg")).toBe(
|
||||
"/.thumbs/Media/a%20b.jpg.jpg",
|
||||
@ -101,26 +109,26 @@ describe("createDufsClient", () => {
|
||||
});
|
||||
|
||||
it("upload PUTs to dir/name, remove DELETEs, writeText PUTs, readText GETs", async () => {
|
||||
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse("content")));
|
||||
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
|
||||
const fetchImpl = vi.fn<typeof fetch>(() =>
|
||||
Promise.resolve(jsonResponse("content")),
|
||||
);
|
||||
const client = createDufsClient(fetchImpl);
|
||||
const file = new File(["x"], "n.txt");
|
||||
await client.upload("/dir", file);
|
||||
await client.remove("/dir/n.txt");
|
||||
await client.writeText("/dir/n.txt", "hi");
|
||||
const text = await client.readText("/dir/n.txt");
|
||||
expect(text).toBe("content");
|
||||
const methods = fetchImpl.mock.calls.map(
|
||||
(c) => (c[1] as RequestInit).method,
|
||||
);
|
||||
const methods = fetchImpl.mock.calls.map((c) => c[1]?.method);
|
||||
expect(methods).toEqual(["PUT", "DELETE", "PUT", "GET"]);
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe("/dir/n.txt");
|
||||
expect(fetchImpl.mock.calls.at(0)?.[0]).toBe("/dir/n.txt");
|
||||
});
|
||||
|
||||
it("throws on non-ok responses", async () => {
|
||||
const fetchImpl = vi.fn(() =>
|
||||
const fetchImpl = vi.fn<typeof fetch>(() =>
|
||||
Promise.resolve(jsonResponse("", false, 404)),
|
||||
);
|
||||
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
|
||||
const client = createDufsClient(fetchImpl);
|
||||
await expect(client.remove("/nope")).rejects.toThrow("404");
|
||||
});
|
||||
});
|
||||
|
||||
@ -64,21 +64,28 @@ export function sortEntries(entries: readonly DirEntry[]): DirEntry[] {
|
||||
}
|
||||
|
||||
export function createDufsClient(fetchImpl: typeof fetch = fetch): DufsClient {
|
||||
async function request(path: string, init: RequestInit): Promise<Response> {
|
||||
// `method` is always supplied by callers, so the error message and the fetch
|
||||
// options never need to fall back — keeping it a required arg avoids an
|
||||
// unreachable default branch.
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const res = await fetchImpl(encodePath(path), {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path} → ${res.status}`);
|
||||
throw new Error(`${method} ${path} → ${res.status}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
return {
|
||||
async list(dirPath) {
|
||||
const res = await request(dirPath, {
|
||||
method: "PROPFIND",
|
||||
const res = await request("PROPFIND", dirPath, {
|
||||
headers: { Depth: "1" },
|
||||
});
|
||||
return sortEntries(parsePropfind(await res.text(), dirPath));
|
||||
@ -90,19 +97,16 @@ export function createDufsClient(fetchImpl: typeof fetch = fetch): DufsClient {
|
||||
return encodePath(`/.thumbs${normalize(path)}.jpg`);
|
||||
},
|
||||
async upload(dirPath, file) {
|
||||
await request(joinPath(dirPath, file.name), {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
});
|
||||
await request("PUT", joinPath(dirPath, file.name), { body: file });
|
||||
},
|
||||
async remove(path) {
|
||||
await request(path, { method: "DELETE" });
|
||||
await request("DELETE", path);
|
||||
},
|
||||
async readText(path) {
|
||||
return (await request(path, { method: "GET" })).text();
|
||||
return (await request("GET", path)).text();
|
||||
},
|
||||
async writeText(path, content) {
|
||||
await request(path, { method: "PUT", body: content });
|
||||
await request("PUT", path, { body: content });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -156,4 +156,101 @@ describe("Gallery", () => {
|
||||
render(<Gallery client={client} />);
|
||||
expect(await screen.findByText(/boom/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates via a breadcrumb", async () => {
|
||||
render(<Gallery client={makeClient()} />);
|
||||
await screen.findByText("pic.jpg");
|
||||
await userEvent.click(screen.getByRole("button", { name: "cloud" }));
|
||||
expect(window.location.hash).toBe("#/");
|
||||
});
|
||||
|
||||
it("opens the file picker from the Upload button", async () => {
|
||||
render(<Gallery client={makeClient()} />);
|
||||
await screen.findByText("pic.jpg");
|
||||
// Should not throw; exercises the hidden-input click path.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Upload" }));
|
||||
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("steps to the previous media with ArrowLeft (wrapping)", async () => {
|
||||
render(<Gallery client={makeClient()} />);
|
||||
await userEvent.click(await screen.findByText("pic.jpg"));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".viewer-img")).not.toBeNull();
|
||||
});
|
||||
// media = [pic.jpg, clip.mp4]; from index 0, prev wraps to the video.
|
||||
fireEvent.keyDown(window, { key: "ArrowLeft" });
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("video")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an empty-folder message", async () => {
|
||||
const client = makeClient({ list: vi.fn(() => Promise.resolve([])) });
|
||||
render(<Gallery client={client} />);
|
||||
expect(await screen.findByText("This folder is empty.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders breadcrumb separators for nested paths", async () => {
|
||||
window.location.hash = "#/Media/2026";
|
||||
render(<Gallery client={makeClient()} />);
|
||||
await screen.findByText("pic.jpg");
|
||||
expect(screen.getByRole("button", { name: "Media" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "2026" })).toBeInTheDocument();
|
||||
expect(document.querySelectorAll(".crumb-sep").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ignores an upload with no files selected", async () => {
|
||||
const client = makeClient();
|
||||
const { container } = render(<Gallery client={client} />);
|
||||
await screen.findByText("pic.jpg");
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[type="file"]',
|
||||
);
|
||||
if (input) fireEvent.change(input, { target: { files: null } });
|
||||
expect(client.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stringifies a non-Error upload rejection", async () => {
|
||||
const client = makeClient({
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
upload: vi.fn(() => Promise.reject("plain-up")),
|
||||
});
|
||||
const { container } = render(<Gallery client={client} />);
|
||||
await screen.findByText("pic.jpg");
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[type="file"]',
|
||||
);
|
||||
if (input) {
|
||||
fireEvent.change(input, {
|
||||
target: { files: [new File(["x"], "u.png")] },
|
||||
});
|
||||
}
|
||||
expect(await screen.findByText("plain-up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stringifies a non-Error delete rejection", async () => {
|
||||
const client = makeClient({
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
remove: vi.fn(() => Promise.reject("plain-del")),
|
||||
});
|
||||
render(<Gallery client={client} />);
|
||||
await screen.findByText("pic.jpg");
|
||||
await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(await screen.findByText("plain-del")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the text editor and reloads", async () => {
|
||||
const client = makeClient();
|
||||
render(<Gallery client={client} />);
|
||||
await userEvent.click(await screen.findByText("notes.txt"));
|
||||
await screen.findByDisplayValue("hello world");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByDisplayValue("hello world")).toBeNull();
|
||||
});
|
||||
// reload() re-invokes list (initial + after close).
|
||||
expect((client.list as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,7 +12,8 @@ import { ConfirmDialog } from "./confirm-dialog.tsx";
|
||||
export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.Element {
|
||||
const [path, navigate] = useHashPath();
|
||||
const { entries, loading, error, reload } = useListing(client, path);
|
||||
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
||||
// -1 means "no viewer open" (avoids a null branch in step()).
|
||||
const [viewerIndex, setViewerIndex] = useState(-1);
|
||||
const [editEntry, setEditEntry] = useState<DirEntry | null>(null);
|
||||
const [deleteEntry, setDeleteEntry] = useState<DirEntry | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
@ -23,18 +24,13 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
|
||||
[entries],
|
||||
);
|
||||
|
||||
function openMediaAt(entriesIndex: number): void {
|
||||
const entry = entries[entriesIndex];
|
||||
if (entry === undefined) return;
|
||||
const mi = media.findIndex((m) => m.path === entry.path);
|
||||
if (mi >= 0) setViewerIndex(mi);
|
||||
// Grid only calls this for media entries, so the entry is always in `media`.
|
||||
function openMedia(entry: DirEntry): void {
|
||||
setViewerIndex(media.findIndex((m) => m.path === entry.path));
|
||||
}
|
||||
|
||||
function step(delta: number): void {
|
||||
setViewerIndex((cur) => {
|
||||
if (cur === null || media.length === 0) return cur;
|
||||
return (cur + delta + media.length) % media.length;
|
||||
});
|
||||
setViewerIndex((cur) => (cur + delta + media.length) % media.length);
|
||||
}
|
||||
|
||||
function onUploadPicked(files: FileList | null): void {
|
||||
@ -54,9 +50,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
|
||||
})();
|
||||
}
|
||||
|
||||
function confirmDelete(): void {
|
||||
const entry = deleteEntry;
|
||||
if (entry === null) return;
|
||||
function confirmDelete(entry: DirEntry): void {
|
||||
setDeleteEntry(null);
|
||||
setBusy(`Deleting ${entry.name}…`);
|
||||
client
|
||||
@ -70,7 +64,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
|
||||
});
|
||||
}
|
||||
|
||||
const viewerEntry = viewerIndex !== null ? media[viewerIndex] : null;
|
||||
const viewerEntry = viewerIndex >= 0 ? media[viewerIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="gallery">
|
||||
@ -126,7 +120,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
|
||||
client={client}
|
||||
entries={entries}
|
||||
onOpenDir={navigate}
|
||||
onOpenMedia={openMediaAt}
|
||||
onOpenMedia={openMedia}
|
||||
onEditText={setEditEntry}
|
||||
onDelete={setDeleteEntry}
|
||||
/>
|
||||
@ -139,7 +133,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
|
||||
entry={viewerEntry}
|
||||
url={client.fileUrl(viewerEntry.path)}
|
||||
onClose={() => {
|
||||
setViewerIndex(null);
|
||||
setViewerIndex(-1);
|
||||
}}
|
||||
onPrev={() => {
|
||||
step(-1);
|
||||
@ -163,7 +157,9 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
|
||||
<ConfirmDialog
|
||||
message={`Delete "${deleteEntry.name}"? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onConfirm={() => {
|
||||
confirmDelete(deleteEntry);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setDeleteEntry(null);
|
||||
}}
|
||||
|
||||
80
cloud_gallery/src/components/grid.test.tsx
Normal file
80
cloud_gallery/src/components/grid.test.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Grid } from "./grid.tsx";
|
||||
import type { DufsClient } from "../api/dufs-client.ts";
|
||||
import type { DirEntry } from "../api/types.ts";
|
||||
|
||||
function entry(name: string, kind: "dir" | "file"): DirEntry {
|
||||
return { name, path: `/${name}`, kind, size: 42, mtimeMs: 0 };
|
||||
}
|
||||
|
||||
function makeClient(): DufsClient {
|
||||
return {
|
||||
list: vi.fn(),
|
||||
fileUrl: (p: string) => p,
|
||||
thumbUrl: (p: string) => `/.thumbs${p}.jpg`,
|
||||
upload: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
readText: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const entries: DirEntry[] = [
|
||||
entry("Media", "dir"),
|
||||
entry("pic.jpg", "file"),
|
||||
entry("clip.mp4", "file"),
|
||||
entry("notes.txt", "file"),
|
||||
entry("data.bin", "file"),
|
||||
];
|
||||
|
||||
function renderGrid(over: Partial<Parameters<typeof Grid>[0]> = {}) {
|
||||
const props = {
|
||||
client: makeClient(),
|
||||
entries,
|
||||
onOpenDir: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onEditText: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
render(<Grid {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("Grid", () => {
|
||||
it("activates dir, media and text entries via their tiles", async () => {
|
||||
const onOpenDir = vi.fn();
|
||||
const onOpenMedia = vi.fn();
|
||||
const onEditText = vi.fn();
|
||||
renderGrid({ onOpenDir, onOpenMedia, onEditText });
|
||||
await userEvent.click(screen.getByText("Media"));
|
||||
await userEvent.click(screen.getByText("pic.jpg"));
|
||||
await userEvent.click(screen.getByText("notes.txt"));
|
||||
// Non-media, non-text file does nothing on activate.
|
||||
await userEvent.click(screen.getByText("data.bin"));
|
||||
expect(onOpenDir).toHaveBeenCalledWith("/Media");
|
||||
expect(onOpenMedia).toHaveBeenCalledWith(entries[1]);
|
||||
expect(onEditText).toHaveBeenCalledWith(entries[3]);
|
||||
});
|
||||
|
||||
it("renders a download link and a delete button for files", async () => {
|
||||
const onDelete = vi.fn();
|
||||
renderGrid({ onDelete });
|
||||
const download = screen.getByLabelText("Download pic.jpg");
|
||||
expect(download).toHaveAttribute("href", "/pic.jpg");
|
||||
await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
|
||||
expect(onDelete).toHaveBeenCalledWith(entries[1]);
|
||||
});
|
||||
|
||||
it("falls back to an icon when a thumbnail fails to load", () => {
|
||||
renderGrid();
|
||||
// Image thumbnail breaks -> picture fallback glyph.
|
||||
fireEvent.error(screen.getByAltText("pic.jpg"));
|
||||
expect(screen.getByText("🖼")).toBeInTheDocument();
|
||||
// Video thumbnail breaks -> film fallback glyph.
|
||||
fireEvent.error(screen.getByAltText("clip.mp4"));
|
||||
expect(screen.getByText("🎞")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,7 @@ interface GridProps {
|
||||
readonly client: DufsClient;
|
||||
readonly entries: readonly DirEntry[];
|
||||
readonly onOpenDir: (path: string) => void;
|
||||
readonly onOpenMedia: (index: number) => void;
|
||||
readonly onOpenMedia: (entry: DirEntry) => void;
|
||||
readonly onEditText: (entry: DirEntry) => void;
|
||||
readonly onDelete: (entry: DirEntry) => void;
|
||||
}
|
||||
@ -46,11 +46,11 @@ export function Grid({
|
||||
}: GridProps): React.JSX.Element {
|
||||
return (
|
||||
<ul className="grid">
|
||||
{entries.map((entry, index) => {
|
||||
{entries.map((entry) => {
|
||||
const media = isImage(entry.name) || isVideo(entry.name);
|
||||
const activate = (): void => {
|
||||
if (entry.kind === "dir") onOpenDir(entry.path);
|
||||
else if (media) onOpenMedia(index);
|
||||
else if (media) onOpenMedia(entry);
|
||||
else if (isText(entry.name)) onEditText(entry);
|
||||
};
|
||||
return (
|
||||
|
||||
@ -25,17 +25,22 @@ describe("MediaViewer", () => {
|
||||
expect(img.className).toContain("zoomed");
|
||||
});
|
||||
|
||||
it("renders a <video> for video files", () => {
|
||||
it("renders a <video> for video files and does not close on video click", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MediaViewer
|
||||
entry={mk("v.mp4")}
|
||||
url="/v.mp4"
|
||||
onClose={vi.fn()}
|
||||
onClose={onClose}
|
||||
onPrev={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector("video")).not.toBeNull();
|
||||
const video = document.querySelector("video");
|
||||
expect(video).not.toBeNull();
|
||||
if (video) fireEvent.click(video);
|
||||
// stopPropagation keeps the stage's onClose from firing.
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers a download for non-media files", () => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TextEditor } from "./text-editor.tsx";
|
||||
import type { DufsClient } from "../api/dufs-client.ts";
|
||||
@ -23,7 +23,7 @@ function client(over: Partial<DufsClient>): DufsClient {
|
||||
readText: vi.fn(() => Promise.resolve("body")),
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
...over,
|
||||
} as DufsClient;
|
||||
};
|
||||
}
|
||||
|
||||
describe("TextEditor", () => {
|
||||
@ -56,6 +56,26 @@ describe("TextEditor", () => {
|
||||
expect(await screen.findByText("wfail")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stringifies a non-Error load rejection", async () => {
|
||||
const c = client({
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
readText: vi.fn(() => Promise.reject("plain-load-failure")),
|
||||
});
|
||||
render(<TextEditor client={c} entry={entry} onClose={vi.fn()} />);
|
||||
expect(await screen.findByText("plain-load-failure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stringifies a non-Error save rejection", async () => {
|
||||
const c = client({
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
writeText: vi.fn(() => Promise.reject("plain-save-failure")),
|
||||
});
|
||||
render(<TextEditor client={c} entry={entry} onClose={vi.fn()} />);
|
||||
await screen.findByDisplayValue("body");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("plain-save-failure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<TextEditor client={client({})} entry={entry} onClose={onClose} />);
|
||||
@ -63,4 +83,36 @@ describe("TextEditor", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores a load that resolves after unmount", async () => {
|
||||
let settle: (v: string) => void = () => undefined;
|
||||
const pending = new Promise<string>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const c = client({ readText: vi.fn(() => pending) });
|
||||
const { unmount } = render(
|
||||
<TextEditor client={c} entry={entry} onClose={vi.fn()} />,
|
||||
);
|
||||
unmount();
|
||||
await act(async () => {
|
||||
settle("late");
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a load that rejects after unmount", async () => {
|
||||
let fail: (e: unknown) => void = () => undefined;
|
||||
const pending = new Promise<string>((_, reject) => {
|
||||
fail = reject;
|
||||
});
|
||||
const c = client({ readText: vi.fn(() => pending) });
|
||||
const { unmount } = render(
|
||||
<TextEditor client={c} entry={entry} onClose={vi.fn()} />,
|
||||
);
|
||||
unmount();
|
||||
await act(async () => {
|
||||
fail(new Error("late"));
|
||||
await pending.catch(() => undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
99
cloud_gallery/src/hooks/use-listing.test.ts
Normal file
99
cloud_gallery/src/hooks/use-listing.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useListing } from "./use-listing.ts";
|
||||
import type { DufsClient } from "../api/dufs-client.ts";
|
||||
import type { DirEntry } from "../api/types.ts";
|
||||
|
||||
function entry(name: string, kind: "dir" | "file"): DirEntry {
|
||||
return { name, path: `/${name}`, kind, size: 0, mtimeMs: 0 };
|
||||
}
|
||||
|
||||
function mkClient(list: DufsClient["list"]): DufsClient {
|
||||
return {
|
||||
list,
|
||||
fileUrl: (p: string) => p,
|
||||
thumbUrl: (p: string) => p,
|
||||
upload: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
readText: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useListing", () => {
|
||||
it("loads entries and hides app-owned files", async () => {
|
||||
const client = mkClient(() =>
|
||||
Promise.resolve([
|
||||
entry("index.html", "file"),
|
||||
entry(".thumbs", "dir"),
|
||||
entry("real.jpg", "file"),
|
||||
]),
|
||||
);
|
||||
const { result } = renderHook(() => useListing(client, "/"));
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.entries.map((e) => e.name)).toEqual(["real.jpg"]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes an Error message", async () => {
|
||||
const client = mkClient(() => Promise.reject(new Error("boom")));
|
||||
const { result } = renderHook(() => useListing(client, "/"));
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("boom");
|
||||
});
|
||||
});
|
||||
|
||||
it("stringifies a non-Error rejection", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
const client = mkClient(() => Promise.reject("weird"));
|
||||
const { result } = renderHook(() => useListing(client, "/"));
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("weird");
|
||||
});
|
||||
});
|
||||
|
||||
it("reload re-fetches the current path", async () => {
|
||||
const list = vi.fn(() => Promise.resolve([entry("a.jpg", "file")]));
|
||||
const client = mkClient(list);
|
||||
const { result } = renderHook(() => useListing(client, "/"));
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
act(() => {
|
||||
result.current.reload();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a resolution that arrives after unmount", async () => {
|
||||
let settle: (v: DirEntry[]) => void = () => undefined;
|
||||
const pending = new Promise<DirEntry[]>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const client = mkClient(() => pending);
|
||||
const { unmount } = renderHook(() => useListing(client, "/"));
|
||||
unmount();
|
||||
await act(async () => {
|
||||
settle([entry("late.jpg", "file")]);
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a rejection that arrives after unmount", async () => {
|
||||
let fail: (e: unknown) => void = () => undefined;
|
||||
const pending = new Promise<DirEntry[]>((_, reject) => {
|
||||
fail = reject;
|
||||
});
|
||||
const client = mkClient(() => pending);
|
||||
const { unmount } = renderHook(() => useListing(client, "/"));
|
||||
unmount();
|
||||
await act(async () => {
|
||||
fail(new Error("late"));
|
||||
await pending.catch(() => undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,6 +11,11 @@ function readHash(): string {
|
||||
return normalize(raw === "" ? "/" : raw);
|
||||
}
|
||||
|
||||
/** Server-render snapshot: there is no hash off the client, so default to root. */
|
||||
export function serverPath(): string {
|
||||
return "/";
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => {
|
||||
@ -19,7 +24,7 @@ function subscribe(onChange: () => void): () => void {
|
||||
}
|
||||
|
||||
export function useHashPath(): readonly [string, (path: string) => void] {
|
||||
const path = useSyncExternalStore(subscribe, readHash, () => "/");
|
||||
const path = useSyncExternalStore(subscribe, readHash, serverPath);
|
||||
const navigate = useCallback((next: string) => {
|
||||
window.location.hash = normalize(next);
|
||||
}, []);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useHashPath } from "./lib/use-hash-path.ts";
|
||||
import { serverPath, useHashPath } from "./lib/use-hash-path.ts";
|
||||
import { ConfirmDialog } from "./components/confirm-dialog.tsx";
|
||||
import { App } from "./app.tsx";
|
||||
|
||||
@ -38,6 +38,9 @@ describe("useHashPath", () => {
|
||||
expect(screen.getByTestId("p").textContent).toBe("/x/y");
|
||||
});
|
||||
});
|
||||
it("defaults to root for the server snapshot", () => {
|
||||
expect(serverPath()).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
@ -72,9 +75,7 @@ describe("ConfirmDialog", () => {
|
||||
|
||||
describe("App", () => {
|
||||
it("mounts the gallery", async () => {
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.reject(new Error("offline")),
|
||||
) as unknown as typeof fetch;
|
||||
globalThis.fetch = vi.fn(() => Promise.reject(new Error("offline")));
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Upload" }),
|
||||
|
||||
@ -16,7 +16,7 @@ export default defineConfig({
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/main.tsx", "src/test/**", "src/**/*.d.ts"],
|
||||
thresholds: { statements: 90, branches: 85, functions: 90, lines: 90 },
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "cloud_gallery: green build, 100% coverage, aggressive lint, live deploy + mobile app install",
|
||||
"objective": "Make the cloud_gallery SPA production-ready and deployed: the Vite build must pass, the lint must be the most aggressive type-checked ruleset the toolchain supports, and vitest coverage must be a real 100% across statements/branches/functions/lines. Then deploy the SPA live via setup_cloud_gallery.sh, restore working ffmpeg so video posters generate, and whitelist + install the com.kuhy.dufs_client mobile app so focus mode does not kill it.",
|
||||
"acceptance_criteria": [
|
||||
"pnpm build (tsc -b && vite build) succeeds — the prior TS2493/TS2352 tuple errors in dufs-client.test.ts are gone.",
|
||||
"eslint config uses typescript-eslint strict + stylistic type-checked, react-hooks, and reportUnusedDisableDirectives=error; pnpm lint passes with zero errors.",
|
||||
"pnpm coverage passes with thresholds set to 100/100/100/100 and reports 100% on all four metrics.",
|
||||
"setup_cloud_gallery.sh deploys the SPA and dufs serves it with render-spa on 127.0.0.1:5000 (401 unauthenticated confirms it is up and auth-protected).",
|
||||
"ffmpeg runs (stale /usr/local/bin shadow removed) and generate_thumbnails.sh produces video posters with 0 failures.",
|
||||
"com.kuhy.dufs_client is in phone_focus_mode WHITELIST, the APK installs, launches, and survives the focus daemon."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"eslint-plugin-react (crashes under ESLint 10; TypeScript covers prop typing)",
|
||||
"Changes to KeePass sync or media-sync scripts",
|
||||
"Authenticated end-to-end browser walkthrough of the live URL (user does this at login)"
|
||||
],
|
||||
"verifier": "pnpm build + pnpm lint + pnpm coverage (62 tests, 100% all metrics); setup_cloud_gallery.sh run + local dufs :5000 checks; generate_thumbnails.sh (10 made, 0 failed); adb install -r + monkey launch + pidof (RUNNING); documented in docs/superpowers/evidence/cloud-gallery-coverage-lint-deploy-2026-07-12.json"
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
{
|
||||
"intent": "Fix the cloud_gallery production build, harden its lint to the most aggressive type-checked ruleset, reach 100% test coverage, deploy the SPA live, and whitelist+install the dufs_client mobile app so focus mode does not kill it.",
|
||||
"scope": [
|
||||
"cloud_gallery/ React SPA (source, tests, eslint/vite/package config)",
|
||||
"phone_focus_mode/config.sh (WHITELIST entry only)",
|
||||
"Non-goal: no changes to KeePass sync or media-sync scripts"
|
||||
],
|
||||
"changes": [
|
||||
"dufs-client.test.ts: typed the fetch mocks as vi.fn<typeof fetch>() so mock.calls carry the real [input, init?] tuple — removed the empty-tuple/undefined-cast TS2493/TS2352 build errors.",
|
||||
"dufs-client.ts: made request() take an explicit method arg, deleting the unreachable `?? \"GET\"` default branch.",
|
||||
"gallery.tsx/grid.tsx: replaced the nullable viewer index with a -1 sentinel and pass the media entry (not an index) so three unreachable defensive guards disappear; onOpenMedia signature changed to (entry) => void.",
|
||||
"use-hash-path.ts: extracted serverPath() so the SSR snapshot is testable.",
|
||||
"eslint.config.js: strict + stylistic type-checked presets, react-hooks, reportUnusedDisableDirectives:error, test-file unbound-method override; dropped eslint-plugin-react (incompatible with ESLint 10).",
|
||||
"Added grid.test.tsx and use-listing.test.ts; extended existing suites — coverage thresholds raised to 100/100/100/100.",
|
||||
"Deployed via setup_cloud_gallery.sh (render-spa live), fixed ffmpeg by renaming a stale /usr/local/bin/ffmpeg shadow that linked the removed libvpx.so.11, regenerated thumbnails (10 made, 0 failed).",
|
||||
"phone_focus_mode/config.sh: added com.kuhy.dufs_client to WHITELIST."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "pnpm run lint (tsc --noEmit && eslint src)",
|
||||
"result": "pass",
|
||||
"evidence": "no errors"
|
||||
},
|
||||
{
|
||||
"command": "pnpm run coverage",
|
||||
"result": "pass",
|
||||
"evidence": "62 tests passed; Statements 100% Branches 100% Functions 100% Lines 100%"
|
||||
},
|
||||
{
|
||||
"command": "pnpm run build (tsc -b && vite build)",
|
||||
"result": "pass",
|
||||
"evidence": "dist/assets/index-*.js 204.49 kB (gzip 64.38 kB); built ok"
|
||||
},
|
||||
{
|
||||
"command": "setup_cloud_gallery.sh + curl 127.0.0.1:5000",
|
||||
"result": "pass",
|
||||
"evidence": "dufs render-spa live on :5000, returns 401 unauthenticated (up + auth-protected); index.html references built assets; 10 thumbnails on disk"
|
||||
},
|
||||
{
|
||||
"command": "adb -s BL9000EEA0000102 install -r app-debug.apk; monkey launch",
|
||||
"result": "pass",
|
||||
"evidence": "Success; pidof com.kuhy.dufs_client RUNNING 4s after launch (not killed by focus daemon)"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"render-spa restart briefly interrupts the live dufs service (KeePass file GET/PUT/?hash unaffected).",
|
||||
"The renamed /usr/local/bin/ffmpeg.broken-libvpx11.bak shadow — if any workflow relied on that static build, it now resolves to the pacman ffmpeg instead."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert this commit and re-run setup_cloud_gallery.sh to redeploy the prior SPA; remove com.kuhy.dufs_client from WHITELIST and redeploy focus mode.",
|
||||
"Restore ffmpeg shadow: sudo mv /usr/local/bin/ffmpeg.broken-libvpx11.bak /usr/local/bin/ffmpeg (not recommended — it is broken)."
|
||||
]
|
||||
}
|
||||
@ -434,6 +434,10 @@ com.xiaomi.smarthome
|
||||
# linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh) ---
|
||||
com.zaneschepke.wireguardautotunnel
|
||||
org.connectbot
|
||||
|
||||
# --- Self-hosted cloud client (dufs; browse/upload media, see
|
||||
# ~/dufs_client and setup_cloud_gallery.sh) ---
|
||||
com.kuhy.dufs_client
|
||||
"
|
||||
|
||||
# ============================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user