github-issue-tower-defence-management 1.181.1 → 1.182.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +2 -0
  3. package/bin/adapter/entry-points/console/ui-dist/assets/index-y4IbtMEJ.js +86 -0
  4. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  5. package/bin/adapter/entry-points/console/webServer.js +6 -0
  6. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +23 -0
  9. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +25 -0
  10. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +6 -0
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTimerSettingsModalDialog.stories.tsx +49 -0
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTimerSettingsModalDialog.test.tsx +157 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTimerSettingsModalDialog.tsx +118 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectList.test.ts +74 -0
  15. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectList.ts +40 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectTimer.test.ts +59 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectTimer.ts +26 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTimerSettings.test.ts +110 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTimerSettings.ts +70 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +22 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/lib/navigation.ts +7 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/logic/timerSettings.test.ts +61 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/logic/timerSettings.ts +77 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +116 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +72 -2
  26. package/src/adapter/entry-points/console/ui/src/index.css +139 -0
  27. package/src/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +2 -0
  28. package/src/adapter/entry-points/console/ui-dist/assets/index-y4IbtMEJ.js +86 -0
  29. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  30. package/src/adapter/entry-points/console/webServer.test.ts +93 -0
  31. package/src/adapter/entry-points/console/webServer.ts +6 -0
  32. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  33. package/bin/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +0 -86
  34. package/bin/adapter/entry-points/console/ui-dist/assets/index-DeZcPNCr.css +0 -2
  35. package/src/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +0 -86
  36. package/src/adapter/entry-points/console/ui-dist/assets/index-DeZcPNCr.css +0 -2
@@ -0,0 +1,59 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useConsoleProjectTimer } from './useConsoleProjectTimer';
3
+
4
+ describe('useConsoleProjectTimer', () => {
5
+ beforeEach(() => {
6
+ jest.spyOn(Date, 'now');
7
+ });
8
+
9
+ afterEach(() => {
10
+ jest.restoreAllMocks();
11
+ });
12
+
13
+ it('returns false before the configured minutes have elapsed', () => {
14
+ (Date.now as jest.Mock).mockReturnValue(1000);
15
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
16
+ (Date.now as jest.Mock).mockReturnValue(1000 + 4 * 60 * 1000);
17
+ expect(result.current.isTimerExpired(5)).toBe(false);
18
+ });
19
+
20
+ it('returns true once the configured minutes have elapsed', () => {
21
+ (Date.now as jest.Mock).mockReturnValue(0);
22
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
23
+ (Date.now as jest.Mock).mockReturnValue(5 * 60 * 1000);
24
+ expect(result.current.isTimerExpired(5)).toBe(true);
25
+ });
26
+
27
+ it('returns true after more than the configured minutes have elapsed', () => {
28
+ (Date.now as jest.Mock).mockReturnValue(0);
29
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
30
+ (Date.now as jest.Mock).mockReturnValue(10 * 60 * 1000);
31
+ expect(result.current.isTimerExpired(5)).toBe(true);
32
+ });
33
+
34
+ it('returns false when minutes is 0 regardless of elapsed time', () => {
35
+ (Date.now as jest.Mock).mockReturnValue(0);
36
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
37
+ (Date.now as jest.Mock).mockReturnValue(10 * 60 * 1000);
38
+ expect(result.current.isTimerExpired(0)).toBe(false);
39
+ });
40
+
41
+ it('returns false when minutes is negative', () => {
42
+ (Date.now as jest.Mock).mockReturnValue(0);
43
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
44
+ (Date.now as jest.Mock).mockReturnValue(10 * 60 * 1000);
45
+ expect(result.current.isTimerExpired(-1)).toBe(false);
46
+ });
47
+
48
+ it('resets the start time when pjcode changes', () => {
49
+ (Date.now as jest.Mock).mockReturnValue(0);
50
+ const { result, rerender } = renderHook(
51
+ ({ pjcode }: { pjcode: string | null }) => useConsoleProjectTimer(pjcode),
52
+ { initialProps: { pjcode: 'alpha' } },
53
+ );
54
+ (Date.now as jest.Mock).mockReturnValue(4 * 60 * 1000);
55
+ rerender({ pjcode: 'beta' });
56
+ (Date.now as jest.Mock).mockReturnValue(4 * 60 * 1000 + 30 * 1000);
57
+ expect(result.current.isTimerExpired(5)).toBe(false);
58
+ });
59
+ });
@@ -0,0 +1,26 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ export type ConsoleProjectTimerState = {
4
+ isTimerExpired: (minutes: number) => boolean;
5
+ };
6
+
7
+ export const useConsoleProjectTimer = (
8
+ pjcode: string | null,
9
+ ): ConsoleProjectTimerState => {
10
+ const pjcodeRef = useRef(pjcode);
11
+ const startRef = useRef<number>(Date.now());
12
+
13
+ if (pjcodeRef.current !== pjcode) {
14
+ pjcodeRef.current = pjcode;
15
+ startRef.current = Date.now();
16
+ }
17
+
18
+ const isTimerExpired = useCallback((minutes: number): boolean => {
19
+ if (minutes <= 0) {
20
+ return false;
21
+ }
22
+ return Date.now() - startRef.current >= minutes * 60 * 1000;
23
+ }, []);
24
+
25
+ return { isTimerExpired };
26
+ };
@@ -0,0 +1,110 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { TIMER_SETTINGS_KEY } from '../logic/timerSettings';
3
+ import { useConsoleTimerSettings } from './useConsoleTimerSettings';
4
+
5
+ describe('useConsoleTimerSettings', () => {
6
+ beforeEach(() => {
7
+ localStorage.clear();
8
+ });
9
+
10
+ it('initialises with timer mode off and empty project minutes when localStorage is empty', () => {
11
+ const { result } = renderHook(() => useConsoleTimerSettings());
12
+ expect(result.current.timerMode).toBe(false);
13
+ expect(result.current.projectMinutes).toEqual({});
14
+ expect(result.current.isOpen).toBe(false);
15
+ });
16
+
17
+ it('openSettings resets draft to the current saved settings, not the previous draft', () => {
18
+ localStorage.setItem(
19
+ TIMER_SETTINGS_KEY,
20
+ JSON.stringify({ timerMode: true, projectMinutes: { acme: 30 } }),
21
+ );
22
+ const { result } = renderHook(() => useConsoleTimerSettings());
23
+ act(() => {
24
+ result.current.openSettings();
25
+ });
26
+ act(() => {
27
+ result.current.changeDraftMinutes('acme', 99);
28
+ });
29
+ act(() => {
30
+ result.current.closeSettings();
31
+ });
32
+ act(() => {
33
+ result.current.openSettings();
34
+ });
35
+ expect(result.current.draftTimerMode).toBe(true);
36
+ expect(result.current.draftProjectMinutes).toEqual({ acme: 30 });
37
+ });
38
+
39
+ it('openSettings sets isOpen to true', () => {
40
+ const { result } = renderHook(() => useConsoleTimerSettings());
41
+ act(() => {
42
+ result.current.openSettings();
43
+ });
44
+ expect(result.current.isOpen).toBe(true);
45
+ });
46
+
47
+ it('changeDraftMinutes mutates draft but does not change saved or localStorage', () => {
48
+ const { result } = renderHook(() => useConsoleTimerSettings());
49
+ act(() => {
50
+ result.current.openSettings();
51
+ });
52
+ act(() => {
53
+ result.current.changeDraftMinutes('acme', 45);
54
+ });
55
+ expect(result.current.draftProjectMinutes).toEqual({ acme: 45 });
56
+ expect(result.current.projectMinutes).toEqual({});
57
+ expect(localStorage.getItem(TIMER_SETTINGS_KEY)).toBeNull();
58
+ });
59
+
60
+ it('saveSettings writes to localStorage with key tdpm-timer-settings and closes the dialog', () => {
61
+ const { result } = renderHook(() => useConsoleTimerSettings());
62
+ act(() => {
63
+ result.current.openSettings();
64
+ });
65
+ act(() => {
66
+ result.current.toggleDraftTimerMode(true);
67
+ });
68
+ act(() => {
69
+ result.current.changeDraftMinutes('acme', 20);
70
+ });
71
+ act(() => {
72
+ result.current.saveSettings();
73
+ });
74
+ expect(result.current.isOpen).toBe(false);
75
+ const stored = localStorage.getItem(TIMER_SETTINGS_KEY);
76
+ expect(stored).not.toBeNull();
77
+ const parsed = JSON.parse(stored as string) as unknown;
78
+ expect(parsed).toEqual({ timerMode: true, projectMinutes: { acme: 20 } });
79
+ expect(result.current.timerMode).toBe(true);
80
+ expect(result.current.projectMinutes).toEqual({ acme: 20 });
81
+ });
82
+
83
+ it('closeSettings discards draft without writing to localStorage', () => {
84
+ const { result } = renderHook(() => useConsoleTimerSettings());
85
+ act(() => {
86
+ result.current.openSettings();
87
+ });
88
+ act(() => {
89
+ result.current.changeDraftMinutes('acme', 60);
90
+ });
91
+ act(() => {
92
+ result.current.closeSettings();
93
+ });
94
+ expect(result.current.isOpen).toBe(false);
95
+ expect(localStorage.getItem(TIMER_SETTINGS_KEY)).toBeNull();
96
+ expect(result.current.projectMinutes).toEqual({});
97
+ });
98
+
99
+ it('toggleDraftTimerMode updates draftTimerMode without affecting saved timerMode', () => {
100
+ const { result } = renderHook(() => useConsoleTimerSettings());
101
+ act(() => {
102
+ result.current.openSettings();
103
+ });
104
+ act(() => {
105
+ result.current.toggleDraftTimerMode(true);
106
+ });
107
+ expect(result.current.draftTimerMode).toBe(true);
108
+ expect(result.current.timerMode).toBe(false);
109
+ });
110
+ });
@@ -0,0 +1,70 @@
1
+ import { useCallback, useState } from 'react';
2
+ import {
3
+ readTimerSettings,
4
+ type TimerSettings,
5
+ writeTimerSettings,
6
+ } from '../logic/timerSettings';
7
+
8
+ export type ConsoleTimerSettingsState = {
9
+ timerMode: boolean;
10
+ projectMinutes: Record<string, number>;
11
+ isOpen: boolean;
12
+ draftTimerMode: boolean;
13
+ draftProjectMinutes: Record<string, number>;
14
+ openSettings: () => void;
15
+ closeSettings: () => void;
16
+ saveSettings: () => void;
17
+ toggleDraftTimerMode: (enabled: boolean) => void;
18
+ changeDraftMinutes: (pjcode: string, minutes: number) => void;
19
+ };
20
+
21
+ export const useConsoleTimerSettings = (): ConsoleTimerSettingsState => {
22
+ const [saved, setSaved] = useState<TimerSettings>(readTimerSettings);
23
+ const [isOpen, setIsOpen] = useState(false);
24
+ const [draft, setDraft] = useState<TimerSettings>(saved);
25
+
26
+ const openSettings = useCallback(() => {
27
+ setSaved((currentSaved) => {
28
+ setDraft(currentSaved);
29
+ return currentSaved;
30
+ });
31
+ setIsOpen(true);
32
+ }, []);
33
+
34
+ const closeSettings = useCallback(() => {
35
+ setIsOpen(false);
36
+ }, []);
37
+
38
+ const saveSettings = useCallback(() => {
39
+ setDraft((currentDraft) => {
40
+ writeTimerSettings(currentDraft);
41
+ setSaved(currentDraft);
42
+ return currentDraft;
43
+ });
44
+ setIsOpen(false);
45
+ }, []);
46
+
47
+ const toggleDraftTimerMode = useCallback((enabled: boolean) => {
48
+ setDraft((prev) => ({ ...prev, timerMode: enabled }));
49
+ }, []);
50
+
51
+ const changeDraftMinutes = useCallback((pjcode: string, minutes: number) => {
52
+ setDraft((prev) => ({
53
+ ...prev,
54
+ projectMinutes: { ...prev.projectMinutes, [pjcode]: minutes },
55
+ }));
56
+ }, []);
57
+
58
+ return {
59
+ timerMode: saved.timerMode,
60
+ projectMinutes: saved.projectMinutes,
61
+ isOpen,
62
+ draftTimerMode: draft.timerMode,
63
+ draftProjectMinutes: draft.projectMinutes,
64
+ openSettings,
65
+ closeSettings,
66
+ saveSettings,
67
+ toggleDraftTimerMode,
68
+ changeDraftMinutes,
69
+ };
70
+ };
@@ -438,3 +438,25 @@ export const postConsoleReorderStory = async (
438
438
  throw new Error(await readOperationErrorReason(response));
439
439
  }
440
440
  };
441
+
442
+ export const fetchProjectList = async (): Promise<string[]> => {
443
+ const response = await fetch('/api/projects');
444
+ if (!response.ok) {
445
+ throw new Error(`HTTP ${response.status}`);
446
+ }
447
+ const payload: unknown = await response.json();
448
+ if (
449
+ payload === null ||
450
+ typeof payload !== 'object' ||
451
+ Array.isArray(payload)
452
+ ) {
453
+ return [];
454
+ }
455
+ const record = payload as Record<string, unknown>;
456
+ if (!Array.isArray(record.pjcodes)) {
457
+ return [];
458
+ }
459
+ return record.pjcodes.filter(
460
+ (entry): entry is string => typeof entry === 'string',
461
+ );
462
+ };
@@ -0,0 +1,7 @@
1
+ export const navigateReplace = (url: string): void => {
2
+ window.location.replace(url);
3
+ };
4
+
5
+ export const navigateAssign = (url: string): void => {
6
+ window.location.assign(url);
7
+ };
@@ -0,0 +1,61 @@
1
+ import { findNextPjcodeWithMinutes } from './timerSettings';
2
+
3
+ describe('findNextPjcodeWithMinutes', () => {
4
+ const pjcodes = ['alpha', 'beta', 'gamma'];
5
+
6
+ it('returns the next pjcode after the current one with minutes > 0', () => {
7
+ const minutes = { alpha: 5, beta: 5, gamma: 5 };
8
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBe('beta');
9
+ expect(findNextPjcodeWithMinutes(pjcodes, 'beta', minutes)).toBe('gamma');
10
+ });
11
+
12
+ it('wraps around to the first pjcode after the last one', () => {
13
+ const minutes = { alpha: 5, beta: 5, gamma: 5 };
14
+ expect(findNextPjcodeWithMinutes(pjcodes, 'gamma', minutes)).toBe('alpha');
15
+ });
16
+
17
+ it('returns null when all projects have zero minutes', () => {
18
+ const minutes = { alpha: 0, beta: 0, gamma: 0 };
19
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBeNull();
20
+ expect(findNextPjcodeWithMinutes(pjcodes, null, minutes)).toBeNull();
21
+ });
22
+
23
+ it('skips projects with zero minutes', () => {
24
+ const minutes = { alpha: 5, beta: 0, gamma: 5 };
25
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBe('gamma');
26
+ });
27
+
28
+ it('skips consecutive zero-minute projects and wraps around', () => {
29
+ const minutes = { alpha: 5, beta: 0, gamma: 0 };
30
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBeNull();
31
+
32
+ const minutes2 = { alpha: 0, beta: 5, gamma: 0 };
33
+ expect(findNextPjcodeWithMinutes(pjcodes, 'gamma', minutes2)).toBe('beta');
34
+ });
35
+
36
+ it('returns the first pjcode with minutes > 0 when currentPjcode is null', () => {
37
+ const minutes = { alpha: 0, beta: 5, gamma: 3 };
38
+ expect(findNextPjcodeWithMinutes(pjcodes, null, minutes)).toBe('beta');
39
+ });
40
+
41
+ it('returns the first pjcode when currentPjcode is null and first has minutes > 0', () => {
42
+ const minutes = { alpha: 10, beta: 5, gamma: 3 };
43
+ expect(findNextPjcodeWithMinutes(pjcodes, null, minutes)).toBe('alpha');
44
+ });
45
+
46
+ it('treats missing projectMinutes entry as zero', () => {
47
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', {})).toBeNull();
48
+ });
49
+
50
+ it('returns null for an empty pjcodes list', () => {
51
+ expect(findNextPjcodeWithMinutes([], null, {})).toBeNull();
52
+ expect(findNextPjcodeWithMinutes([], 'alpha', {})).toBeNull();
53
+ });
54
+
55
+ it('handles currentPjcode not found in the list like null-origin', () => {
56
+ const minutes = { alpha: 5, beta: 5, gamma: 5 };
57
+ expect(findNextPjcodeWithMinutes(pjcodes, 'unknown', minutes)).toBe(
58
+ 'alpha',
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,77 @@
1
+ export type TimerSettings = {
2
+ timerMode: boolean;
3
+ projectMinutes: Record<string, number>;
4
+ };
5
+
6
+ export const TIMER_SETTINGS_KEY = 'tdpm-timer-settings';
7
+
8
+ const defaultSettings = (): TimerSettings => ({
9
+ timerMode: false,
10
+ projectMinutes: {},
11
+ });
12
+
13
+ export const readTimerSettings = (): TimerSettings => {
14
+ if (typeof localStorage === 'undefined') {
15
+ return defaultSettings();
16
+ }
17
+ const raw = localStorage.getItem(TIMER_SETTINGS_KEY);
18
+ if (raw === null) {
19
+ return defaultSettings();
20
+ }
21
+ try {
22
+ const parsed: unknown = JSON.parse(raw);
23
+ if (
24
+ parsed === null ||
25
+ typeof parsed !== 'object' ||
26
+ Array.isArray(parsed)
27
+ ) {
28
+ return defaultSettings();
29
+ }
30
+ const record = parsed as Record<string, unknown>;
31
+ const timerMode =
32
+ typeof record.timerMode === 'boolean' ? record.timerMode : false;
33
+ const projectMinutesRaw = record.projectMinutes;
34
+ const projectMinutes: Record<string, number> =
35
+ projectMinutesRaw !== null &&
36
+ typeof projectMinutesRaw === 'object' &&
37
+ !Array.isArray(projectMinutesRaw)
38
+ ? Object.fromEntries(
39
+ Object.entries(projectMinutesRaw as Record<string, unknown>)
40
+ .filter(([, v]) => typeof v === 'number')
41
+ .map(([k, v]) => [k, v as number]),
42
+ )
43
+ : {};
44
+ return { timerMode, projectMinutes };
45
+ } catch {
46
+ return defaultSettings();
47
+ }
48
+ };
49
+
50
+ export const writeTimerSettings = (settings: TimerSettings): void => {
51
+ if (typeof localStorage !== 'undefined') {
52
+ localStorage.setItem(TIMER_SETTINGS_KEY, JSON.stringify(settings));
53
+ }
54
+ };
55
+
56
+ export const findNextPjcodeWithMinutes = (
57
+ pjcodes: string[],
58
+ currentPjcode: string | null,
59
+ projectMinutes: Record<string, number>,
60
+ ): string | null => {
61
+ if (pjcodes.length === 0) {
62
+ return null;
63
+ }
64
+ const currentIndex =
65
+ currentPjcode !== null ? pjcodes.indexOf(currentPjcode) : -1;
66
+ const startIndex =
67
+ currentIndex === -1 ? 0 : (currentIndex + 1) % pjcodes.length;
68
+ const count = currentIndex === -1 ? pjcodes.length : pjcodes.length - 1;
69
+ for (let i = 0; i < count; i++) {
70
+ const index = (startIndex + i) % pjcodes.length;
71
+ const pjcode = pjcodes[index];
72
+ if ((projectMinutes[pjcode] ?? 0) > 0) {
73
+ return pjcode;
74
+ }
75
+ }
76
+ return null;
77
+ };
@@ -19,6 +19,11 @@ jest.mock('../lib/mermaidLoader', () => ({
19
19
  renderMermaidToSvg: jest.fn(async () => '<svg></svg>'),
20
20
  }));
21
21
 
22
+ jest.mock('../lib/navigation', () => ({
23
+ navigateReplace: jest.fn(),
24
+ navigateAssign: jest.fn(),
25
+ }));
26
+
22
27
  const listPayload = (tab: string) => ({
23
28
  pjcode: 'acme',
24
29
  generatedAt: '2026-06-19T00:00:00.000Z',
@@ -81,6 +86,13 @@ const installFetch = (): void => {
81
86
  json: async () => listPayload(listMatch[1]),
82
87
  };
83
88
  }
89
+ if (url === '/api/projects') {
90
+ return {
91
+ ok: true,
92
+ status: 200,
93
+ json: async () => ({ pjcodes: ['acme'] }),
94
+ };
95
+ }
84
96
  return { ok: true, status: 200, json: async () => ({ body: '# body' }) };
85
97
  });
86
98
  global.fetch = fetchMock as unknown as typeof fetch;
@@ -862,4 +874,108 @@ describe('ConsolePage auto-advance tab', () => {
862
874
  jest.useRealTimers();
863
875
  }
864
876
  });
877
+
878
+ it('renders the gear button in the tab bar', async () => {
879
+ const { getByText, getByRole } = render(<ConsolePage />);
880
+ await waitFor(() => {
881
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
882
+ });
883
+ const gearBtn = getByRole('button', { name: 'Console Settings' });
884
+ expect(gearBtn).toBeInTheDocument();
885
+ expect(gearBtn.closest('nav.console-tabbar')).not.toBeNull();
886
+ });
887
+
888
+ it('opens the settings dialog when the gear button is clicked', async () => {
889
+ const { getByText, getByRole, queryByRole } = render(<ConsolePage />);
890
+ await waitFor(() => {
891
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
892
+ });
893
+ expect(queryByRole('dialog')).toBeNull();
894
+ fireEvent.click(getByRole('button', { name: 'Console Settings' }));
895
+ expect(getByRole('dialog')).toBeInTheDocument();
896
+ expect(getByRole('dialog')).toHaveAttribute(
897
+ 'aria-label',
898
+ 'Console Settings',
899
+ );
900
+ });
901
+
902
+ it('saves timer settings to localStorage and closes the dialog when Save and Close is clicked', async () => {
903
+ const { getByText, getByRole, queryByRole, getByLabelText } = render(
904
+ <ConsolePage />,
905
+ );
906
+ await waitFor(() => {
907
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
908
+ });
909
+ fireEvent.click(getByRole('button', { name: 'Console Settings' }));
910
+ fireEvent.click(getByLabelText('Timer Mode'));
911
+ fireEvent.click(getByText('Save and Close'));
912
+ expect(queryByRole('dialog')).toBeNull();
913
+ const stored = localStorage.getItem('tdpm-timer-settings');
914
+ expect(stored).not.toBeNull();
915
+ const parsed: unknown = JSON.parse(stored as string);
916
+ expect(parsed).toMatchObject({ timerMode: true });
917
+ });
918
+
919
+ it('redirects to the first project with minutes > 0 when timer mode is on and pjcode is null', async () => {
920
+ localStorage.setItem(
921
+ 'tdpm-timer-settings',
922
+ JSON.stringify({ timerMode: true, projectMinutes: { acme: 30 } }),
923
+ );
924
+ window.history.replaceState({}, '', '/');
925
+ const { navigateReplace } = jest.requireMock<{
926
+ navigateReplace: jest.Mock;
927
+ }>('../lib/navigation');
928
+ navigateReplace.mockClear();
929
+ render(<ConsolePage />);
930
+ await waitFor(() => {
931
+ expect(navigateReplace).toHaveBeenCalledWith('/projects/acme');
932
+ });
933
+ });
934
+
935
+ it('navigates to the next project when a completing action fires after the project timer elapses', async () => {
936
+ localStorage.setItem(
937
+ 'tdpm-timer-settings',
938
+ JSON.stringify({ timerMode: true, projectMinutes: { acme: 1, beta: 5 } }),
939
+ );
940
+ global.fetch = jest.fn(async (url: string) => {
941
+ const listMatch = url.match(/\/projects\/[^/]+\/([^/]+)\/list\.json/);
942
+ if (listMatch !== null) {
943
+ return {
944
+ ok: true,
945
+ status: 200,
946
+ json: async () => listPayload(listMatch[1]),
947
+ };
948
+ }
949
+ if (url === '/api/projects') {
950
+ return {
951
+ ok: true,
952
+ status: 200,
953
+ json: async () => ({ pjcodes: ['acme', 'beta'] }),
954
+ };
955
+ }
956
+ return { ok: true, status: 200, json: async () => ({ body: '# body' }) };
957
+ }) as unknown as typeof fetch;
958
+ jest.useFakeTimers({ now: 0 });
959
+ try {
960
+ const { getByText, findByText } = render(<ConsolePage />);
961
+ await waitFor(() => {
962
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
963
+ });
964
+ jest.setSystemTime(61 * 1000);
965
+ fireEvent.click(getByText('Add serveConsole subcommand'));
966
+ expect(await findByText('Approve & Merge')).toBeInTheDocument();
967
+ fireEvent.click(getByText('Approve & Merge'));
968
+ act(() => {
969
+ jest.advanceTimersByTime(5100);
970
+ });
971
+ const { navigateAssign } = jest.requireMock<{
972
+ navigateAssign: jest.Mock;
973
+ }>('../lib/navigation');
974
+ await waitFor(() => {
975
+ expect(navigateAssign).toHaveBeenCalledWith('/projects/beta');
976
+ });
977
+ } finally {
978
+ jest.useRealTimers();
979
+ }
980
+ });
865
981
  });