Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("Switchify PC shell", () => {

function localSwitches(): switchHooks.SwitchController {
const settings: switchHooks.SwitchSettings = { schemaVersion: 1, holdIntervalMs: 1000, bindings: [{ id: "space", name: "Space switch", key: "Space", pressAction: "select", holdActions: [] }] };
return { settings, state: { settings, capture: { active: false, key: null, error: null }, supported: true, error: null, escapeHoldMs: 4000, unavailableKeys: [] }, pending: 0, error: null, capturing: false, unsaved: false, update: vi.fn(), capture: vi.fn(), cancelCapture: vi.fn().mockResolvedValue(undefined), setKeyboardEntry: vi.fn().mockResolvedValue(true), retry: vi.fn() };
return { settings, state: { settings, capture: { active: false, key: null, error: null }, supported: true, error: null, escapeHoldMs: 4000, unavailableKeys: [] }, pending: 0, error: null, capturing: false, unsaved: false, update: vi.fn(), remove: vi.fn(), capture: vi.fn(), cancelCapture: vi.fn().mockResolvedValue(undefined), setKeyboardEntry: vi.fn().mockResolvedValue(true), retry: vi.fn() };
}

it("shows local readiness without Bluetooth and explains Android suspension", async () => {
Expand Down
131 changes: 124 additions & 7 deletions src/Switches.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useLayoutEffect, type ReactNode } from "react";
import {
act,
fireEvent,
Expand Down Expand Up @@ -159,6 +160,7 @@ it("cancelling a new switch also cancels its capture", async () => {
event({ ...current, capture: { active: false, key: "Enter", error: null } });
await screen.findByText("Enter");
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
fireEvent.click(screen.getByRole("button", { name: "Discard switch" }));
expect(screen.queryByLabelText("New switch name")).toBeNull();
expect(current.settings.bindings).toHaveLength(1);
});
Expand Down Expand Up @@ -345,8 +347,9 @@ it("keeps focus in the draft when another switch is removed while adding", async
event(current);
await screen.findByText("Enter");
fireEvent.click(screen.getByRole("button", { name: "Remove Head switch" }));
fireEvent.click(screen.getByRole("button", { name: "Remove switch" }));
await waitFor(() => expect(current.settings.bindings).toHaveLength(0));
expect(document.activeElement).toBe(screen.getByLabelText("New switch name"));
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("New switch name")));
});
it("lists remote switches with local ones and edits them in place", async () => {
render(<Shell />);
Expand Down Expand Up @@ -397,9 +400,11 @@ it("removes a remote switch and surfaces a failed remote save with retry", async
throw "Remote save failed";
});
fireEvent.click(screen.getByRole("button", { name: "Remove Remote switch 1" }));
await screen.findByText("Remote save failed");
fireEvent.click(screen.getByRole("button", { name: "Remove switch" }));
await screen.findByText(/Could not remove the switch/);
expect(remote.slots[0].pressAction).toBe("select");
fireEvent.click(screen.getByRole("button", { name: "Retry save" }));
expect(screen.getByRole("heading", { name: "Remote switch 1" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Remove switch" }));
await waitFor(() => expect(remote.slots[0].pressAction).toBeNull());
expect(screen.queryByRole("heading", { name: "Remote switch 1" })).toBeNull();
});
Expand Down Expand Up @@ -460,6 +465,8 @@ it("preserves a newly learned key while typing and releases capture ownership on
fireEvent.change(screen.getByRole("textbox", { name: "New switch name" }), { target: { value: "Second switch" } });
expect(screen.getByText("Enter", { selector: "kbd" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
expect(current.keyboardEntry).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Discard switch" }));
await waitFor(() => expect(current.keyboardEntry).toBe(false));
expect(current.settings.bindings).toHaveLength(1);
});
Expand All @@ -472,15 +479,125 @@ it("offers recovery when returning to Switches with keyboard typing still active
});


it.each([0, 1])("releases keyboard entry when the edited remote switch is removed using control %s", async (index) => {
it.each([["Remote switch 1", 0], ["Remote switch 1", 1], ["Head switch", 0], ["Head switch", 1]] as const)("releases keyboard entry when %s is removed using control %s", async (name, index) => {
render(<Shell />);
fireEvent.click(await screen.findByRole("button", { name: "Edit Remote switch 1" }));
fireEvent.click(await screen.findByRole("button", { name: `Edit ${name}` }));
fireEvent.click(screen.getByRole("button", { name: "Type with keyboard" }));
await screen.findByRole("button", { name: "Resume switch control" });
fireEvent.click(screen.getAllByRole("button", { name: "Remove Remote switch 1" })[index]);
fireEvent.click(screen.getAllByRole("button", { name: `Remove ${name}` })[index]);
expect(current.keyboardEntry).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Remove switch" }));
await waitFor(() => expect(current.keyboardEntry).toBe(false));
expect(mocks.invoke).toHaveBeenCalledWith("set_switch_keyboard_entry", { active: false });
expect(screen.queryByRole("heading", { name: "Remote switch 1" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name })).not.toBeInTheDocument();
});
it("keeps an edited local draft and restores focus after cancelling discard", async () => {
render(<Shell />);
await screen.findByRole("heading", { name: "Head switch" });
fireEvent.click(screen.getByRole("button", { name: "Add switch" }));
await screen.findByRole("button", { name: "Cancel capture" });
event({ ...current, capture: { active: false, key: "Enter", error: null } });
fireEvent.change(await screen.findByLabelText("New switch name"), { target: { value: "Foot" } });
const cancel = screen.getByRole("button", { name: "Cancel new switch" });
cancel.focus();
fireEvent.click(cancel);
const dialog = screen.getByRole("alertdialog", { name: "Discard this new switch?" });
const keep = within(dialog).getByRole("button", { name: "Keep switch" });
expect(keep).toHaveFocus();
fireEvent.keyDown(keep, { key: "Tab", shiftKey: true });
expect(within(dialog).getByRole("button", { name: "Discard switch" })).toHaveFocus();
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() => expect(cancel).toHaveFocus());
expect(screen.getByLabelText("New switch name")).toHaveValue("Foot");
});
it("requires discard after changing a remote draft action, while untouched cancellation is immediate", async () => {
render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 1" });
fireEvent.click(screen.getByRole("button", { name: "Add remote switch" }));
fireEvent.change(screen.getByLabelText("New switch action"), { target: { value: "next" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Discard switch" }));
expect(remote.slots[1].pressAction).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Add remote switch" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(screen.queryByLabelText("New switch name")).not.toBeInTheDocument();
});
it.each(["Head switch", "Remote switch 1"])("protects both removal controls for %s", async (name) => {
render(<Shell />);
await screen.findByRole("heading", { name });
const remove = screen.getByRole("button", { name: `Remove ${name}` });
remove.focus();
fireEvent.click(remove);
expect(screen.getByRole("alertdialog", { name: `Remove ${name}?` })).toBeInTheDocument();
expect(mocks.invoke.mock.calls.some(([command]) => command.startsWith("save_"))).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Keep switch" }));
await waitFor(() => expect(remove).toHaveFocus());
open(name);
fireEvent.click(screen.getAllByRole("button", { name: `Remove ${name}` })[1]);
fireEvent.click(screen.getByRole("button", { name: "Remove switch" }));
await waitFor(() => expect(screen.queryByRole("heading", { name })).not.toBeInTheDocument());
});
it("retains a local assignment when deletion fails and allows keeping it", async () => {
render(<Shell />);
await screen.findByRole("heading", { name: "Head switch" });
mocks.invoke.mockImplementationOnce(async () => { throw "Disk full"; });
fireEvent.click(screen.getByRole("button", { name: "Remove Head switch" }));
fireEvent.click(screen.getByRole("button", { name: "Remove switch" }));
await screen.findByText(/Could not remove the switch/);
fireEvent.click(screen.getByRole("button", { name: "Keep switch" }));
expect(screen.getByRole("heading", { name: "Head switch" })).toBeInTheDocument();
expect(current.settings.bindings[0]).toEqual(initial.settings.bindings[0]);
});

it("hands remote draft focus to the committed input before passive work can run", async () => {
const committedFocus: (Element | null)[] = [];
function CommitProbe({ children, revision }: { children: ReactNode; revision: number }) {
useLayoutEffect(() => { if (revision) committedFocus.push(document.activeElement); }, [revision]);
return children;
}
const view = render(<CommitProbe revision={0}><Shell /></CommitProbe>);
await screen.findByRole("heading", { name: "Remote switch 1" });
const add = screen.getByRole("button", { name: "Add remote switch" });
add.focus();
act(() => {
fireEvent.click(add);
// A parent update can commit alongside asynchronous backend state. Observe
// focus at that commit, before any pending passive effects get a turn.
view.rerender(<CommitProbe revision={1}><Shell /></CommitProbe>);
});
const name = screen.getByRole("textbox", { name: "New switch name" });
expect(committedFocus).toEqual([name]);
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
view.rerender(<CommitProbe revision={2}><Shell /></CommitProbe>);
});
expect(committedFocus[1]).toBe(screen.getByRole("button", { name: "Add switch" }));
});

it.each(["remove", "discard"])("restores usable focus after confirmed %s with browser inert behavior", async (operation) => {
const focus = HTMLElement.prototype.focus;
const spy = vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(function (this: HTMLElement, options?: FocusOptions) {
// jsdom does not enforce inert; browsers refuse focus inside an inert root.
if (!this.closest("[inert]")) focus.call(this, options);
});
try {
render(<Shell />);
await screen.findByRole("heading", { name: "Remote switch 1" });
if (operation === "remove") {
fireEvent.click(screen.getByRole("button", { name: "Remove Head switch" }));
} else {
fireEvent.click(screen.getByRole("button", { name: "Add remote switch" }));
fireEvent.change(screen.getByRole("textbox", { name: "New switch name" }), { target: { value: "Foot" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" }));
}
const dialog = screen.getByRole("alertdialog");
expect(within(dialog).getByRole("button", { name: "Keep switch" })).toHaveFocus();
fireEvent.click(within(dialog).getByRole("button", { name: operation === "remove" ? "Remove switch" : "Discard switch" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: "Add switch" })).toHaveFocus();
} finally { spy.mockRestore(); }
});

it("separates an empty local configuration from the six remote presets", async () => {
Expand Down
21 changes: 21 additions & 0 deletions src/scanning/useRemoteSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,32 @@ export function useRemoteSwitches() {
setConfig(next);
save(m.revision, next);
};
const remove = async (index: number) => {
const m = model.current;
if (!m.config || m.pending || m.revision !== m.saved) return false;
const next = { ...m.config, slots: m.config.slots.map((slot, i) => i === index ? { pressAction: null, holdActions: [] } : slot) };
m.pending++;
setPending(m.pending);
setError(null);
try {
const result = await invoke<RemoteConfig>("save_remote_switches", { config: next });
m.config = result;
setConfig(result);
return true;
} catch (e) {
setError(String(e));
return false;
} finally {
m.pending--;
setPending(m.pending);
}
};
return {
config,
pending,
error,
update,
remove,
retry: () => {
if (model.current.config)
save(model.current.revision, model.current.config);
Expand Down
24 changes: 24 additions & 0 deletions src/scanning/useSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@ export function useSwitches() {
setSettings(next);
save(next, m.revision);
};
// Destructive saves keep the original assignment visible until persistence succeeds.
const remove = async (id: string) => {
const m = model.current;
if (m.pending || m.revision !== m.saved || m.state?.capture.active || !m.state?.supported) return false;
const next = { ...m.settings, bindings: m.settings.bindings.filter((binding) => binding.id !== id) };
m.pending++;
setPending(m.pending);
setError(null);
try {
const stamp = runtime.current;
const result = await invoke<SwitchState>("save_switches", { settings: next });
m.settings = result.settings;
setSettings(result.settings);
if (stamp === runtime.current) receive(result);
return true;
} catch (e) {
setError(String(e));
return false;
} finally {
m.pending--;
setPending(m.pending);
}
};
const capture = async () => {
const request = ++captureRequest.current;
setCapturing(true);
Expand Down Expand Up @@ -195,6 +218,7 @@ export function useSwitches() {
error,
capturing,
update,
remove,
capture,
cancelCapture,
setKeyboardEntry,
Expand Down
Loading
Loading