Skip to content
Open
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
227 changes: 227 additions & 0 deletions build/lunafury-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { fileURLToPath } from "node:url";
import { build } from "vite";

let controller: any;
const timers = new Set<ReturnType<typeof setTimeout>>();

after(() => { for (const timer of timers) clearTimeout(timer); });

before(async () => {
const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
(globalThis as any).localStorage = storage;
(globalThis as any).window = { localStorage: storage, location: { search: "" },
matchMedia: () => ({ matches: false }), clearTimeout,
setTimeout: (callback: () => void, delay?: number) => {
const timer = setTimeout(callback, delay); timers.add(timer); return timer;
} };
(globalThis as any).document = { title: "OpenMouse test" };
const entry = fileURLToPath(new URL("../src/device/controller.ts", import.meta.url));
const result: any = await build({
configFile: false, logLevel: "silent",
define: { __BUILD_CHANNEL__: '"stable"', __APP_VERSION__: '"test"', __BUILD_ID__: '"test"' },
plugins: [{ name: "lunafury-options-test", enforce: "pre",
transform(source, id) {
if (id.replaceAll("\\", "/") !== entry.replaceAll("\\", "/")) return null;
// Stub UI notifications; keep the real staging, validation and dispatch.
return source.replace("function emit(): void {", "function emit(): void { return;") + `
export const testWrites = [];
export const testBankWrites = [];
let testProfile, testBanks, testReader;
export function testSetup(settings, brand = "LunaFury", draft = false) {
clearPendingChanges(); testWrites.length = 0; testBankWrites.length = 0; toasts.length = 0;
testProfile = 1; testReader = null;
testBanks = new Map([1, 2, 3].map(profile => [profile, structuredClone(settings)]));
latestDeviceStatus = { brand, name: "Test mouse", dpi: 800, pollingRateHz: 1000,
batteryPercent: null, batteryState: "Unknown", firmware: [],
activeProfile: 1, profileCount: 3, lunafury: structuredClone(settings) };
matchesBaseline = null; settingInProgress = false;
dpiOptions = [800, 1600];
interfacePreferences = { ...DEFAULT_INTERFACE_PREFERENCES, locale: "en",
instantFlash: false, reducedMotion: true };
gameProfileDraft = draft ? { touched: new Set() } : null;
const write = (kind, field, value) => {
testWrites.push([kind, value]); testBankWrites.push([kind, testProfile, value]);
testBanks.get(testProfile)[field] = value;
};
active = {
setLunaFuryButtonPollingRate: async value => write("rate", "buttonPollingRateHz", value),
setLunaFuryReceiverLightMode: async value => {
testWrites.push(["light", value]);
for (const bank of testBanks.values()) bank.receiverLightMode = value;
},
setLunaFuryBottomButtonMode: async value => write("bottom", "bottomButtonMode", value),
setProfile: async profile => { testProfile = profile; testWrites.push(["profile", profile]); },
readStatus: async () => {
const status = { ...latestDeviceStatus, activeProfile: testProfile,
ui: { ...latestDeviceStatus.ui, settingsReady: true },
lunafury: structuredClone(testBanks.get(testProfile)) };
return testReader ? await testReader(status) : status;
},
};
}
export function testPending() { return pendingChanges(); }
export function testPreview() { return withPendingChanges(latestDeviceStatus); }
export function testWarnings() { return toasts.filter(toast => toast.kind === "warning"); }
export function testBank(profile, settings) { testBanks.set(profile, structuredClone(settings)); }
export function testReadWith(reader) { testReader = reader; }
`;
},
}],
build: { write: false, minify: false, lib: { entry, formats: ["es"] },
rollupOptions: { output: { inlineDynamicImports: true } } },
});
const outputs = (Array.isArray(result) ? result : [result]).flatMap(item => item.output);
const code = outputs.find((item: any) => item.type === "chunk").code;
controller = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
});

const SETTINGS = { buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000, 2000, 4000, 8000],
receiverLightMode: 6, bottomButtonMode: 2 };

test("LunaFury controls stage independently and dispatch through the published driver API", async () => {
controller.testSetup(structuredClone(SETTINGS));
controller.applyLunaFuryButtonPollingRate(4000);
controller.applyLunaFuryReceiverLightMode(0);
controller.applyLunaFuryBottomButtonMode(1);
assert.deepEqual(controller.testWrites, []);
const preview = controller.testPreview();
assert.equal(preview.pollingRateHz, 1000); // USB polling stays independent.
assert.equal(preview.lunafury.buttonPollingRateHz, 4000);
assert.equal(preview.lunafury.receiverLightMode, 0);
assert.equal(preview.lunafury.bottomButtonMode, 1);
for (const pending of controller.testPending()) await pending.apply();
assert.deepEqual(controller.testWrites, [["rate", 4000], ["light", 0], ["bottom", 1]]);
});

test("unread controls, other brands and invalid values cannot stage writes", () => {
for (const [settings, brand] of [[{}, "LunaFury"], [SETTINGS, "Lamzu"]] as const) {
controller.testSetup(settings, brand);
controller.applyLunaFuryButtonPollingRate(4000);
controller.applyLunaFuryReceiverLightMode(0);
controller.applyLunaFuryBottomButtonMode(1);
assert.deepEqual(controller.testPending(), []);
}
controller.testSetup(SETTINGS);
controller.applyLunaFuryButtonPollingRate(125);
controller.applyLunaFuryReceiverLightMode(3);
controller.applyLunaFuryBottomButtonMode(0);
assert.deepEqual(controller.testPending(), []);
controller.testSetup({ ...SETTINGS, supportedButtonPollingRates: [1000] });
controller.applyLunaFuryButtonPollingRate(8000);
assert.deepEqual(controller.testPending(), []);
});

test("hardware-only controls stay out of Games and bank edits wait for profile selection", () => {
controller.testSetup(SETTINGS, "LunaFury", true);
controller.applyLunaFuryReceiverLightMode(0);
controller.applyLunaFuryBottomButtonMode(1);
assert.deepEqual(controller.testPending(), []);
controller.testSetup(SETTINGS);
controller.applyProfileSelection(2);
controller.applyLunaFuryButtonPollingRate(4000);
controller.applyLunaFuryBottomButtonMode(1);
assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]);
});

test("LunaFury profile switching rejects staged bank edits in reverse order", () => {
for (const stage of [
() => controller.applyLunaFuryButtonPollingRate(4000),
() => controller.applyLunaFuryBottomButtonMode(1),
() => controller.applyDpiValue(1600),
]) {
controller.testSetup(SETTINGS);
stage();
const keys = controller.testPending().map((pending: any) => pending.key);
assert.equal(keys.length, 1);
controller.applyProfileSelection(2);
assert.deepEqual(controller.testPending().map((pending: any) => pending.key), keys);
assert.equal(controller.testPreview().activeProfile, 1);
assert.equal(controller.testWarnings().length, 1);
assert.ok(controller.testWarnings()[0].title.length > 0);
assert.deepEqual(controller.testWrites, []);
controller.revertPendingChanges();
controller.applyProfileSelection(2);
assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]);
}
});

test("profile selection can be revised and preserves independent receiver edits", () => {
controller.testSetup(SETTINGS);
controller.applyProfileSelection(2);
controller.applyProfileSelection(3);
assert.equal(controller.testPreview().activeProfile, 3);
assert.equal(controller.testPending().length, 1);
controller.applyProfileSelection(1);
assert.deepEqual(controller.testPending(), []);
controller.applyLunaFuryReceiverLightMode(0);
controller.applyProfileSelection(2);
assert.deepEqual(controller.testPending().map((pending: any) => pending.key),
["lunafury-receiver-light", "onboard-profile"]);
controller.applyLunaFuryReceiverLightMode(11);
assert.equal(controller.testPending().length, 2);

controller.testSetup(SETTINGS, "Lamzu");
controller.applyDpiValue(1600);
controller.applyProfileSelection(2);
assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["dpi", "onboard-profile"]);
});

test("LunaFury edits wait for profile read-back and then target the new bank", async () => {
controller.testSetup(SETTINGS);
controller.testBank(2, { ...SETTINGS, buttonPollingRateHz: 2000, bottomButtonMode: 1 });
let started!: () => void;
const reading = new Promise<void>(resolve => { started = resolve; });
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
controller.testReadWith(async (status: unknown) => { started(); await gate; return status; });
controller.applyProfileSelection(2);
const applying = controller.flashPendingChanges();
await reading;
try {
controller.applyLunaFuryButtonPollingRate(4000);
controller.applyLunaFuryBottomButtonMode(1);
controller.applyDpiValue(1600);
assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]);
assert.deepEqual(controller.testBankWrites, []);
} finally {
release();
await applying;
}
assert.equal(controller.testPreview().activeProfile, 2);
assert.equal(controller.testPreview().lunafury.buttonPollingRateHz, 2000);
assert.deepEqual(controller.testPending(), []);
controller.applyLunaFuryButtonPollingRate(4000);
controller.applyLunaFuryBottomButtonMode(2);
await controller.flashPendingChanges();
assert.deepEqual(controller.testBankWrites, [["rate", 2, 4000], ["bottom", 2, 2]]);
});

test("failed LunaFury profile read-back keeps bank edits blocked until a successful retry", async () => {
for (const revert of [false, true]) {
controller.testSetup(SETTINGS);
controller.testReadWith(() => { throw new Error("Profile read-back failed"); });
controller.applyProfileSelection(2);
await controller.flashPendingChanges();
controller.applyLunaFuryButtonPollingRate(4000);
controller.applyLunaFuryBottomButtonMode(1);
assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]);
assert.deepEqual(controller.testBankWrites, []);
if (revert) {
controller.revertPendingChanges();
controller.applyLunaFuryButtonPollingRate(4000);
controller.applyLunaFuryBottomButtonMode(1);
controller.applyDpiValue(1600);
assert.deepEqual(controller.testPending(), []);
controller.applyProfileSelection(2);
}
controller.testReadWith(null);
await controller.flashPendingChanges();
assert.deepEqual(controller.testPending(), []);
assert.equal(controller.testPreview().activeProfile, 2);
controller.applyLunaFuryBottomButtonMode(1);
await controller.flashPendingChanges();
assert.deepEqual(controller.testBankWrites, [["bottom", 2, 1]]);
}
});
68 changes: 67 additions & 1 deletion src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,22 @@ function stageChange(change: PendingChange): void {
setReadStatus(st("ctl.waitFlash"));
return;
}
if (latestDeviceStatus?.brand === "LunaFury" && change.key !== "lunafury-receiver-light") {
const profileSwitch = change.key === "onboard-profile";
if (!profileSwitch && latestDeviceStatus.ui?.settingsReady === false) {
setReadStatus(st("ctl.waitingRefresh"));
return;
}
// Receiver lighting is independent of the bank; other edits use the last read profile.
const otherEdits = profileSwitch && pendingChanges().some((pending) =>
pending.key !== "onboard-profile" && pending.key !== "lunafury-receiver-light");
if (otherEdits || (!profileSwitch && isPendingChange("onboard-profile"))) {
const message = st("ctl.atkPending");
setReadStatus(message);
pushToast("warning", message);
return;
}
}
if (gameProfileDraft) {
const fields = gameProfileFieldsOf(change);
if (!fields) {
Expand Down Expand Up @@ -1841,6 +1857,7 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void {
else applyAngleTuning(s.angleTuning);
}
stageLunaFuryProfile(s.lunafury, status.brand === "LunaFury" ? status.lunafury : undefined, {
buttonRate: applyLunaFuryButtonPollingRate,
lightning: applyLunaFuryLightningMode,
button: applyLunaFuryButtonDebounce,
wheel: applyLunaFuryWheelGuard,
Expand Down Expand Up @@ -6194,7 +6211,14 @@ export function applyProfileSelection(profile: number): void {
progress: st("ctl.progProfile"),
preview: (status) => { status.activeProfile = profile; },
apply: async () => {
await requireClientMethod("setProfile", "the active profile").setProfile(profile);
const client = requireClientMethod("setProfile", "the active profile");
const refreshProfile = latestDeviceStatus?.brand === "LunaFury";
if (refreshProfile && latestDeviceStatus) {
applyStatus({ ...latestDeviceStatus, ui: { ...latestDeviceStatus.ui, settingsReady: false } });
}
await client.setProfile(profile);
// Keep the switch pending, and stale controls locked, until its bank is read back.
if (refreshProfile) applyStatus(await statusAfterWrite(client));
},
});
}
Expand Down Expand Up @@ -6569,6 +6593,48 @@ export function applyIncottFireKey(times: number, intervalMs: number): void {
});
}

export function applyLunaFuryButtonPollingRate(hertz: 1000 | 2000 | 4000 | 8000): void {
const settings = latestDeviceStatus?.lunafury;
if (latestDeviceStatus?.brand !== "LunaFury" || settings?.buttonPollingRateHz == null
|| !settings.supportedButtonPollingRates?.includes(hertz)) return;
stageChange({
key: "lunafury-button-rate",
label: `${st("luna.buttonPollingRate")} · ${hertz} Hz`,
command: `Set button polling rate to ${hertz} Hz`,
progress: `Setting button polling rate to ${hertz} Hz…`,
preview: (status) => { if (status.lunafury) status.lunafury.buttonPollingRateHz = hertz; },
apply: () => callClientMethod("setLunaFuryButtonPollingRate", "button polling rate", hertz),
});
}

export function applyLunaFuryBottomButtonMode(mode: 1 | 2): void {
if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury"
|| latestDeviceStatus.lunafury?.bottomButtonMode == null || (mode !== 1 && mode !== 2)) return;
const label = st(mode === 1 ? "luna.bottomPower" : "luna.bottomPowerDpi");
stageChange({
key: "lunafury-bottom-button",
label: `${st("luna.bottomButton")} · ${label}`,
command: `Set bottom-button mode to ${mode}`,
progress: "Setting bottom-button mode…",
preview: (status) => { if (status.lunafury) status.lunafury.bottomButtonMode = mode; },
apply: () => callClientMethod("setLunaFuryBottomButtonMode", "bottom-button behavior", mode),
});
}

export function applyLunaFuryReceiverLightMode(mode: 0 | 2 | 6 | 11): void {
if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury"
|| latestDeviceStatus.lunafury?.receiverLightMode == null || ![0, 2, 6, 11].includes(mode)) return;
const label = st(mode === 2 ? "luna.lightMixed" : mode === 6 ? "luna.lightBattery" : mode === 11 ? "luna.lightDpi" : "common.off");
stageChange({
key: "lunafury-receiver-light",
label: `${st("luna.receiverLight")} · ${label}`,
command: `Set receiver light mode to ${mode}`,
progress: "Setting receiver light mode…",
preview: (status) => { if (status.lunafury) status.lunafury.receiverLightMode = mode; },
apply: () => callClientMethod("setLunaFuryReceiverLightMode", "receiver lighting", mode),
});
}

export function applyLunaFuryLightningMode(mode: 0 | 1 | 2): void {
if (latestDeviceStatus?.lunafury?.lightningMode == null) return;
stageChange({
Expand Down
23 changes: 23 additions & 0 deletions src/device/lunafury-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ test("invalid imported values never become protocol writes", () => {
test("profile staging calls only supported controls and ignores unknown commands", () => {
const calls: unknown[] = [];
const actions: LunaFuryProfileActions = {
buttonRate: () => {},
lightning: (mode) => { calls.push(["lightning", mode]); },
button: (button, milliseconds) => { calls.push([button, milliseconds]); },
wheel: (guard) => { calls.push(["wheel", guard]); },
Expand All @@ -48,6 +49,7 @@ test("profile staging calls only supported controls and ignores unknown commands
test("apply and restore preserve zero values and do not write unrelated peers", () => {
const current = structuredClone(settings);
const actions: LunaFuryProfileActions = {
buttonRate: () => {},
lightning: (mode) => { current.lightningMode = mode; },
button: (button, milliseconds) => { current[`${button}DebounceMs`] = milliseconds; },
wheel: (guard) => { current.wheelGuard = { ...guard }; },
Expand All @@ -70,6 +72,7 @@ test("pending batches honor lightning disable/latency/enable ordering for game p
});
try {
stageLunaFuryProfile({ lightningMode: mode, leftDebounceMs: 1, rightDebounceMs: 2 }, settings, {
buttonRate: () => {},
lightning: (value) => stage("lightning", lunaFuryLightningPriority(value)),
button: (button) => stage(button),
wheel: () => stage("wheel"),
Expand All @@ -81,3 +84,23 @@ test("pending batches honor lightning disable/latency/enable ordering for game p
}
}
});

test("button polling profiles use supported rates and exclude hardware-only settings", () => {
assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: 4000, receiverLightMode: 0, bottomButtonMode: 1 }),
{ buttonPollingRateHz: 4000 });
for (const rate of [0, 125, 16000, "1000", NaN]) {
assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: rate }), {});
}
const calls: number[] = [];
const actions: LunaFuryProfileActions = {
buttonRate: rate => { calls.push(rate); }, lightning: () => {}, button: () => {}, wheel: () => {},
};
stageLunaFuryProfile({ buttonPollingRateHz: 4000 },
{ buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000] }, actions);
stageLunaFuryProfile({ buttonPollingRateHz: 4000 },
{ buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000, 4000] }, actions);
assert.deepEqual(calls, [4000]);
const current = { lunafury: { buttonPollingRateHz: 1000, receiverLightMode: 6 } } as MouseStatus;
assert.deepEqual(pickSnapshot(current, { lunafury: { buttonPollingRateHz: 4000 } }),
{ lunafury: { buttonPollingRateHz: 1000 } });
});
Loading
Loading