feat(desktop): add tray menu template builder with state-driven labels
Pure buildTrayMenuTemplate function returns MenuItemConstructorOptions[] without constructing real Menu objects, enabling full test coverage. All 7 new tests pass (22 total); MenuActions interface and checkForUpdatesItem helper are intentionally unexported.
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { RecoveryStateStore, extractErrorCode, type RecoveryState } from './recovery';
|
import type { MenuItemConstructorOptions } from 'electron';
|
||||||
|
import {
|
||||||
|
RecoveryStateStore,
|
||||||
|
extractErrorCode,
|
||||||
|
buildTrayMenuTemplate,
|
||||||
|
type RecoveryState,
|
||||||
|
} from './recovery';
|
||||||
|
|
||||||
describe('RecoveryStateStore', () => {
|
describe('RecoveryStateStore', () => {
|
||||||
it('returns the initial state', () => {
|
it('returns the initial state', () => {
|
||||||
@@ -132,3 +138,76 @@ describe('extractErrorCode', () => {
|
|||||||
expect(extractErrorCode({ code: 'NET_FAIL' })).toBe('NET_FAIL');
|
expect(extractErrorCode({ code: 'NET_FAIL' })).toBe('NET_FAIL');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function defaultState(overrides?: Partial<RecoveryState>): RecoveryState {
|
||||||
|
return {
|
||||||
|
mode: 'normal',
|
||||||
|
reason: null,
|
||||||
|
updateState: 'idle',
|
||||||
|
updateVersion: null,
|
||||||
|
lastUpdateError: null,
|
||||||
|
lastCheckResult: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildTrayMenuTemplate', () => {
|
||||||
|
it('includes Show/Hide/Change Instance/Quit base items', () => {
|
||||||
|
const items = buildTrayMenuTemplate(defaultState());
|
||||||
|
const labels = items.map((i) => i.label);
|
||||||
|
expect(labels).toContain('Show Backspace');
|
||||||
|
expect(labels).toContain('Hide');
|
||||||
|
expect(labels).toContain('Change Instance');
|
||||||
|
expect(labels).toContain('Quit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Check for Updates with idle label when updateState=idle', () => {
|
||||||
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'idle' }));
|
||||||
|
const item = items.find((i) => i.id === 'check-for-updates');
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item!.label).toBe('Check for Updates…');
|
||||||
|
expect(item!.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Check for Updates while checking', () => {
|
||||||
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'checking' }));
|
||||||
|
const item = items.find((i) => i.id === 'check-for-updates');
|
||||||
|
expect(item!.label).toBe('Checking for Updates…');
|
||||||
|
expect(item!.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Check for Updates while downloading', () => {
|
||||||
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'downloading' }));
|
||||||
|
const item = items.find((i) => i.id === 'check-for-updates');
|
||||||
|
expect(item!.label).toBe('Downloading Update…');
|
||||||
|
expect(item!.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Update Ready label when downloaded, with Check disabled (Restart is the action)', () => {
|
||||||
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'downloaded' }));
|
||||||
|
const item = items.find((i) => i.id === 'check-for-updates');
|
||||||
|
expect(item!.label).toBe('Update Ready');
|
||||||
|
expect(item!.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error suffix on Check for Updates label when updateState=error', () => {
|
||||||
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'error' }));
|
||||||
|
const item = items.find((i) => i.id === 'check-for-updates');
|
||||||
|
expect(item!.label).toBe('Check for Updates… (last attempt failed)');
|
||||||
|
expect(item!.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts Restart to Install Update only when updateState=downloaded', () => {
|
||||||
|
expect(
|
||||||
|
buildTrayMenuTemplate(defaultState({ updateState: 'idle' })).find((i) => i.id === 'restart-to-install'),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
buildTrayMenuTemplate(defaultState({ updateState: 'downloading' })).find((i) => i.id === 'restart-to-install'),
|
||||||
|
).toBeUndefined();
|
||||||
|
const downloadedItem = buildTrayMenuTemplate(defaultState({ updateState: 'downloaded' }))
|
||||||
|
.find((i) => i.id === 'restart-to-install');
|
||||||
|
expect(downloadedItem).toBeDefined();
|
||||||
|
expect(downloadedItem!.label).toBe('Restart to Install Update');
|
||||||
|
expect(downloadedItem!.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { MenuItemConstructorOptions } from 'electron';
|
||||||
|
|
||||||
export type RecoveryReasonCode =
|
export type RecoveryReasonCode =
|
||||||
| 'load-failed'
|
| 'load-failed'
|
||||||
| 'render-gone'
|
| 'render-gone'
|
||||||
@@ -79,3 +81,58 @@ export function extractErrorCode(err: unknown): string | null {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MenuActions {
|
||||||
|
onShow: () => void;
|
||||||
|
onHide: () => void;
|
||||||
|
onChangeInstance: () => void;
|
||||||
|
onCheckForUpdates: () => void;
|
||||||
|
onRestartToInstall: () => void;
|
||||||
|
onQuit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForUpdatesItem(state: RecoveryState, click: () => void): MenuItemConstructorOptions {
|
||||||
|
switch (state.updateState) {
|
||||||
|
case 'checking':
|
||||||
|
return { id: 'check-for-updates', label: 'Checking for Updates…', enabled: false };
|
||||||
|
case 'downloading':
|
||||||
|
return { id: 'check-for-updates', label: 'Downloading Update…', enabled: false };
|
||||||
|
case 'downloaded':
|
||||||
|
return { id: 'check-for-updates', label: 'Update Ready', enabled: false };
|
||||||
|
case 'error':
|
||||||
|
return { id: 'check-for-updates', label: 'Check for Updates… (last attempt failed)', enabled: true, click };
|
||||||
|
case 'idle':
|
||||||
|
default:
|
||||||
|
return { id: 'check-for-updates', label: 'Check for Updates…', enabled: true, click };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrayMenuTemplate(
|
||||||
|
state: RecoveryState,
|
||||||
|
actions?: Partial<MenuActions>,
|
||||||
|
): MenuItemConstructorOptions[] {
|
||||||
|
const items: MenuItemConstructorOptions[] = [
|
||||||
|
{ label: 'Show Backspace', click: actions?.onShow },
|
||||||
|
{ label: 'Hide', click: actions?.onHide },
|
||||||
|
{ type: 'separator' },
|
||||||
|
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (state.updateState === 'downloaded') {
|
||||||
|
items.push({
|
||||||
|
id: 'restart-to-install',
|
||||||
|
label: 'Restart to Install Update',
|
||||||
|
enabled: true,
|
||||||
|
click: actions?.onRestartToInstall,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Change Instance', click: actions?.onChangeInstance },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Quit', click: actions?.onQuit },
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user