github-issue-tower-defence-management 1.92.1 → 1.93.1

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 (42) hide show
  1. package/.github/workflows/console-ui.yml +3 -0
  2. package/.github/workflows/publish.yml +4 -0
  3. package/CHANGELOG.md +14 -0
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
  5. package/bin/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/scripts/verifyConsoleUiDist.mjs +96 -0
  9. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +49 -1
  10. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +36 -20
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.stories.tsx +19 -0
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +24 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +30 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +30 -24
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.stories.tsx +47 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +54 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +36 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +117 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +105 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.test.ts +115 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.ts +115 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +153 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +103 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.test.ts +81 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.ts +61 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.test.ts +53 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.ts +33 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.test.ts +34 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.ts +28 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +9 -1
  31. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +51 -6
  32. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +263 -17
  33. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +85 -3
  34. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +19 -3
  35. package/src/adapter/entry-points/console/ui/src/index.css +205 -1
  36. package/src/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
  37. package/src/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
  38. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  39. package/bin/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
  40. package/bin/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
  41. package/src/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
  42. package/src/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
@@ -0,0 +1,61 @@
1
+ export type ConsoleDiffLineKind = 'hunk' | 'add' | 'del' | 'ctx';
2
+
3
+ export type ConsoleDiffLine = {
4
+ kind: ConsoleDiffLineKind;
5
+ oldLineNumber: number | null;
6
+ newLineNumber: number | null;
7
+ content: string;
8
+ };
9
+
10
+ const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
11
+
12
+ export const parseUnifiedDiff = (patch: string): ConsoleDiffLine[] => {
13
+ const rows: ConsoleDiffLine[] = [];
14
+ let oldLineNumber = 0;
15
+ let newLineNumber = 0;
16
+ for (const line of patch.split('\n')) {
17
+ if (line.startsWith('@@')) {
18
+ const match = line.match(HUNK_HEADER);
19
+ if (match !== null) {
20
+ oldLineNumber = Number(match[1]);
21
+ newLineNumber = Number(match[2]);
22
+ }
23
+ rows.push({
24
+ kind: 'hunk',
25
+ oldLineNumber: null,
26
+ newLineNumber: null,
27
+ content: line,
28
+ });
29
+ continue;
30
+ }
31
+ if (line.startsWith('+')) {
32
+ rows.push({
33
+ kind: 'add',
34
+ oldLineNumber: null,
35
+ newLineNumber: newLineNumber,
36
+ content: line,
37
+ });
38
+ newLineNumber += 1;
39
+ continue;
40
+ }
41
+ if (line.startsWith('-')) {
42
+ rows.push({
43
+ kind: 'del',
44
+ oldLineNumber: oldLineNumber,
45
+ newLineNumber: null,
46
+ content: line,
47
+ });
48
+ oldLineNumber += 1;
49
+ continue;
50
+ }
51
+ rows.push({
52
+ kind: 'ctx',
53
+ oldLineNumber: oldLineNumber,
54
+ newLineNumber: newLineNumber,
55
+ content: line,
56
+ });
57
+ oldLineNumber += 1;
58
+ newLineNumber += 1;
59
+ }
60
+ return rows;
61
+ };
@@ -0,0 +1,53 @@
1
+ import {
2
+ nextPendingKeyAfter,
3
+ nextPendingKeyBrowse,
4
+ previousPendingKeyBefore,
5
+ } from './navigation';
6
+
7
+ const keys = ['a', 'b', 'c', 'd'];
8
+
9
+ describe('nextPendingKeyAfter', () => {
10
+ it('returns the key immediately after the acted key', () => {
11
+ expect(nextPendingKeyAfter(keys, 'b')).toBe('c');
12
+ });
13
+
14
+ it('returns null when the acted key is last so the caller returns to the list', () => {
15
+ expect(nextPendingKeyAfter(keys, 'd')).toBeNull();
16
+ });
17
+
18
+ it('falls back to the first key when the acted key is absent', () => {
19
+ expect(nextPendingKeyAfter(keys, 'missing')).toBe('a');
20
+ });
21
+
22
+ it('returns null for an empty list', () => {
23
+ expect(nextPendingKeyAfter([], 'a')).toBeNull();
24
+ });
25
+ });
26
+
27
+ describe('nextPendingKeyBrowse', () => {
28
+ it('returns the next key in display order', () => {
29
+ expect(nextPendingKeyBrowse(keys, 'a')).toBe('b');
30
+ });
31
+
32
+ it('returns null at the end of the list', () => {
33
+ expect(nextPendingKeyBrowse(keys, 'd')).toBeNull();
34
+ });
35
+
36
+ it('returns null when the current key is absent', () => {
37
+ expect(nextPendingKeyBrowse(keys, 'missing')).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe('previousPendingKeyBefore', () => {
42
+ it('returns the previous key in display order', () => {
43
+ expect(previousPendingKeyBefore(keys, 'c')).toBe('b');
44
+ });
45
+
46
+ it('returns null at the start of the list', () => {
47
+ expect(previousPendingKeyBefore(keys, 'a')).toBeNull();
48
+ });
49
+
50
+ it('returns null when the current key is absent', () => {
51
+ expect(previousPendingKeyBefore(keys, 'missing')).toBeNull();
52
+ });
53
+ });
@@ -0,0 +1,33 @@
1
+ export const nextPendingKeyAfter = (
2
+ orderedKeys: string[],
3
+ actedKey: string,
4
+ ): string | null => {
5
+ const actedIndex = orderedKeys.indexOf(actedKey);
6
+ if (actedIndex < 0) {
7
+ return orderedKeys.length > 0 ? orderedKeys[0] : null;
8
+ }
9
+ const nextIndex = actedIndex + 1;
10
+ return nextIndex < orderedKeys.length ? orderedKeys[nextIndex] : null;
11
+ };
12
+
13
+ export const nextPendingKeyBrowse = (
14
+ orderedKeys: string[],
15
+ currentKey: string,
16
+ ): string | null => {
17
+ const index = orderedKeys.indexOf(currentKey);
18
+ if (index < 0 || index >= orderedKeys.length - 1) {
19
+ return null;
20
+ }
21
+ return orderedKeys[index + 1];
22
+ };
23
+
24
+ export const previousPendingKeyBefore = (
25
+ orderedKeys: string[],
26
+ currentKey: string,
27
+ ): string | null => {
28
+ const index = orderedKeys.indexOf(currentKey);
29
+ if (index <= 0) {
30
+ return null;
31
+ }
32
+ return orderedKeys[index - 1];
33
+ };
@@ -0,0 +1,34 @@
1
+ import {
2
+ isVerticallyDominant,
3
+ resolveSwipeDirection,
4
+ SWIPE_MIN_DISTANCE,
5
+ } from './swipe';
6
+
7
+ describe('resolveSwipeDirection', () => {
8
+ it('reports next for a dominant left swipe', () => {
9
+ expect(resolveSwipeDirection(-120, 10)).toBe('next');
10
+ });
11
+
12
+ it('reports previous for a dominant right swipe', () => {
13
+ expect(resolveSwipeDirection(120, 10)).toBe('previous');
14
+ });
15
+
16
+ it('ignores a gesture shorter than the minimum distance', () => {
17
+ expect(resolveSwipeDirection(-(SWIPE_MIN_DISTANCE - 1), 0)).toBeNull();
18
+ });
19
+
20
+ it('ignores a gesture that is not horizontally dominant', () => {
21
+ expect(resolveSwipeDirection(-80, 70)).toBeNull();
22
+ });
23
+ });
24
+
25
+ describe('isVerticallyDominant', () => {
26
+ it('is true for a clearly vertical drag', () => {
27
+ expect(isVerticallyDominant(10, 80)).toBe(true);
28
+ });
29
+
30
+ it('is false for a small or horizontal drag', () => {
31
+ expect(isVerticallyDominant(80, 10)).toBe(false);
32
+ expect(isVerticallyDominant(5, 15)).toBe(false);
33
+ });
34
+ });
@@ -0,0 +1,28 @@
1
+ export type ConsoleSwipeDirection = 'next' | 'previous' | null;
2
+
3
+ export const SWIPE_MIN_DISTANCE = 60;
4
+ export const SWIPE_HORIZONTAL_DOMINANCE = 1.5;
5
+
6
+ export const resolveSwipeDirection = (
7
+ deltaX: number,
8
+ deltaY: number,
9
+ ): ConsoleSwipeDirection => {
10
+ const absDeltaX = Math.abs(deltaX);
11
+ const absDeltaY = Math.abs(deltaY);
12
+ if (absDeltaX < SWIPE_MIN_DISTANCE) {
13
+ return null;
14
+ }
15
+ if (absDeltaX <= absDeltaY * SWIPE_HORIZONTAL_DOMINANCE) {
16
+ return null;
17
+ }
18
+ return deltaX < 0 ? 'next' : 'previous';
19
+ };
20
+
21
+ export const isVerticallyDominant = (
22
+ deltaX: number,
23
+ deltaY: number,
24
+ ): boolean => {
25
+ const absDeltaX = Math.abs(deltaX);
26
+ const absDeltaY = Math.abs(deltaY);
27
+ return absDeltaY > absDeltaX * SWIPE_HORIZONTAL_DOMINANCE && absDeltaY > 20;
28
+ };
@@ -55,8 +55,9 @@ const buildOperations = (): ConsoleOperationsApi => ({
55
55
  });
56
56
 
57
57
  describe('ConsoleItemDetailContainer', () => {
58
- it('wires the review action to the operations api for a PR item', async () => {
58
+ it('queues the review action and commits it through the operations api for a PR item', async () => {
59
59
  const operations = buildOperations();
60
+ const onQueueAction = jest.fn();
60
61
  const { getByText } = render(
61
62
  <ConsoleItemDetailContainer
62
63
  tab="prs"
@@ -69,12 +70,19 @@ describe('ConsoleItemDetailContainer', () => {
69
70
  storyName="TDPM Console port"
70
71
  overlayStatus={null}
71
72
  now={Date.parse('2026-06-19T12:00:00.000Z')}
73
+ onQueueAction={onQueueAction}
72
74
  />,
73
75
  );
74
76
  await waitFor(() => {
75
77
  expect(getByText('Approve')).toBeInTheDocument();
76
78
  });
77
79
  fireEvent.click(getByText('Approve'));
80
+ expect(onQueueAction).toHaveBeenCalledTimes(1);
81
+ const input = onQueueAction.mock.calls[0][0];
82
+ expect(input.kind).toEqual({ type: 'review', action: 'approve' });
83
+ expect(input.item).toBe(prItem);
84
+ expect(operations.reviewPullRequest).not.toHaveBeenCalled();
85
+ input.commit();
78
86
  expect(operations.reviewPullRequest).toHaveBeenCalledWith(
79
87
  prItem,
80
88
  prItem.url,
@@ -4,6 +4,7 @@ import { ConsoleOperationMenu } from '../components/operations/ConsoleOperationM
4
4
  import type { ConsoleCaches } from '../hooks/useConsoleCaches';
5
5
  import { useConsoleItemDetailData } from '../hooks/useConsoleItemDetailData';
6
6
  import type { ConsoleOperationsApi } from '../hooks/useConsoleOperations';
7
+ import type { ConsoleActionKind } from '../logic/actionToast';
7
8
  import { resolveStoryColorEnum } from '../logic/grouping';
8
9
  import type { ConsoleOperationHandlers } from '../logic/operations';
9
10
  import type {
@@ -15,6 +16,12 @@ import type {
15
16
  ConsoleTabName,
16
17
  } from '../logic/types';
17
18
 
19
+ export type ConsoleQueueActionInput = {
20
+ kind: ConsoleActionKind;
21
+ item: ConsoleListItem;
22
+ commit: () => void;
23
+ };
24
+
18
25
  export type ConsoleItemDetailContainerProps = {
19
26
  tab: ConsoleTabName;
20
27
  item: ConsoleListItem;
@@ -26,6 +33,7 @@ export type ConsoleItemDetailContainerProps = {
26
33
  storyName: string | null;
27
34
  overlayStatus: ConsoleOverlayStatus | null;
28
35
  now: number;
36
+ onQueueAction: (input: ConsoleQueueActionInput) => void;
29
37
  };
30
38
 
31
39
  export const ConsoleItemDetailContainer = ({
@@ -39,6 +47,7 @@ export const ConsoleItemDetailContainer = ({
39
47
  storyName,
40
48
  overlayStatus,
41
49
  now,
50
+ onQueueAction,
42
51
  }: ConsoleItemDetailContainerProps) => {
43
52
  const detail = useConsoleItemDetailData(caches, item);
44
53
  const hasPullRequest = item.isPr || detail.relatedPullRequests.length > 0;
@@ -48,22 +57,58 @@ export const ConsoleItemDetailContainer = ({
48
57
  const prUrl = item.isPr
49
58
  ? item.url
50
59
  : (detail.relatedPullRequests[0]?.pullRequest.url ?? item.url);
51
- void operations.reviewPullRequest(item, prUrl, action);
60
+ onQueueAction({
61
+ kind: { type: 'review', action },
62
+ item,
63
+ commit: () => {
64
+ void operations.reviewPullRequest(item, prUrl, action);
65
+ },
66
+ });
52
67
  },
53
68
  onSetNextActionDate: (action) => {
54
- void operations.setNextActionDate(item, action);
69
+ onQueueAction({
70
+ kind: { type: 'next_action_date', action },
71
+ item,
72
+ commit: () => {
73
+ void operations.setNextActionDate(item, action);
74
+ },
75
+ });
55
76
  },
56
77
  onSetStory: (option: ConsoleFieldOption) => {
57
- void operations.setStory(item, option);
78
+ onQueueAction({
79
+ kind: { type: 'set_story', optionName: option.name },
80
+ item,
81
+ commit: () => {
82
+ void operations.setStory(item, option);
83
+ },
84
+ });
58
85
  },
59
86
  onSetStatus: (option: ConsoleFieldOption) => {
60
- void operations.setStatus(item, option);
87
+ onQueueAction({
88
+ kind: { type: 'set_status', optionName: option.name },
89
+ item,
90
+ commit: () => {
91
+ void operations.setStatus(item, option);
92
+ },
93
+ });
61
94
  },
62
95
  onSetInTmuxByHuman: (option: ConsoleFieldOption) => {
63
- void operations.setInTmuxByHuman(item, option);
96
+ onQueueAction({
97
+ kind: { type: 'set_in_tmux_by_human', optionName: option.name },
98
+ item,
99
+ commit: () => {
100
+ void operations.setInTmuxByHuman(item, option);
101
+ },
102
+ });
64
103
  },
65
104
  onClose: (action) => {
66
- void operations.closeIssue(item, action);
105
+ onQueueAction({
106
+ kind: { type: 'close', action },
107
+ item,
108
+ commit: () => {
109
+ void operations.closeIssue(item, action);
110
+ },
111
+ });
67
112
  },
68
113
  };
69
114
 
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, waitFor } from '@testing-library/react';
1
+ import { act, fireEvent, render, waitFor } from '@testing-library/react';
2
2
  import { ConsolePage } from './ConsolePage';
3
3
 
4
4
  jest.mock('../lib/mermaidLoader', () => ({
@@ -97,27 +97,103 @@ describe('ConsolePage', () => {
97
97
  expect(getByText('snapshot: 2026-06-19T00:00:00.000Z')).toBeInTheDocument();
98
98
  });
99
99
 
100
- it('keeps a tab driven to zero at zero and does not revive its badge after switching tabs', async () => {
101
- const { getByText, queryByText, findByText } = render(<ConsolePage />);
102
- await waitFor(() => {
103
- expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
104
- });
105
- expect(
106
- getByText('Awaiting Quality Check')
107
- .closest('a')
108
- ?.querySelector('.console-tab-badge')?.textContent,
109
- ).toBe('1');
100
+ it('shows a cancellable toast and only drives the tab to zero after the five second window', async () => {
101
+ jest.useFakeTimers();
102
+ try {
103
+ const { getByText, findByText } = render(<ConsolePage />);
104
+ await waitFor(() => {
105
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
106
+ });
107
+ expect(
108
+ getByText('Awaiting Quality Check')
109
+ .closest('a')
110
+ ?.querySelector('.console-tab-badge')?.textContent,
111
+ ).toBe('1');
110
112
 
111
- fireEvent.click(getByText('Add serveConsole subcommand'));
112
- expect(await findByText('Approve')).toBeInTheDocument();
113
- fireEvent.click(getByText('Approve'));
113
+ fireEvent.click(getByText('Add serveConsole subcommand'));
114
+ expect(await findByText('Approve')).toBeInTheDocument();
115
+ fireEvent.click(getByText('Approve'));
114
116
 
115
- await waitFor(() => {
117
+ expect(getByText('Approved PR #851')).toBeInTheDocument();
118
+ expect(getByText('Undo')).toBeInTheDocument();
119
+ expect(
120
+ getByText('Awaiting Quality Check')
121
+ .closest('a')
122
+ ?.querySelector('.console-tab-badge')?.textContent,
123
+ ).toBe('1');
124
+
125
+ act(() => {
126
+ jest.advanceTimersByTime(5100);
127
+ });
128
+
129
+ await waitFor(() => {
130
+ expect(
131
+ getByText('Awaiting Quality Check')
132
+ .closest('a')
133
+ ?.querySelector('.console-tab-badge')?.textContent,
134
+ ).toBe('0');
135
+ });
136
+ } finally {
137
+ jest.useRealTimers();
138
+ }
139
+ });
140
+
141
+ it('cancels the command and keeps the item pending when Undo is clicked', async () => {
142
+ jest.useFakeTimers();
143
+ try {
144
+ const fetchMock = jest.fn(
145
+ async (_url: string, init?: { method?: string }) => {
146
+ const listMatch = _url.match(
147
+ /\/projects\/[^/]+\/([^/]+)\/list\.json/,
148
+ );
149
+ if (listMatch !== null) {
150
+ return {
151
+ ok: true,
152
+ status: 200,
153
+ json: async () => listPayload(listMatch[1]),
154
+ };
155
+ }
156
+ void init;
157
+ return {
158
+ ok: true,
159
+ status: 200,
160
+ json: async () => ({ body: '# body' }),
161
+ };
162
+ },
163
+ );
164
+ global.fetch = fetchMock as unknown as typeof fetch;
165
+
166
+ const { getByText, findByText } = render(<ConsolePage />);
167
+ await waitFor(() => {
168
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
169
+ });
170
+ fireEvent.click(getByText('Add serveConsole subcommand'));
171
+ expect(await findByText('Approve')).toBeInTheDocument();
172
+ fireEvent.click(getByText('Approve'));
173
+
174
+ fireEvent.click(getByText('Undo'));
175
+ act(() => {
176
+ jest.advanceTimersByTime(6000);
177
+ });
178
+
179
+ const postCalls = fetchMock.mock.calls.filter(
180
+ (call) => call[1]?.method === 'POST',
181
+ );
182
+ expect(postCalls.length).toBe(0);
116
183
  expect(
117
184
  getByText('Awaiting Quality Check')
118
185
  .closest('a')
119
186
  ?.querySelector('.console-tab-badge')?.textContent,
120
- ).toBe('0');
187
+ ).toBe('1');
188
+ } finally {
189
+ jest.useRealTimers();
190
+ }
191
+ });
192
+
193
+ it('does not revive a zeroed tab badge after switching tabs', async () => {
194
+ const { getByText, queryByText } = render(<ConsolePage />);
195
+ await waitFor(() => {
196
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
121
197
  });
122
198
 
123
199
  fireEvent.click(getByText('Unread'));
@@ -127,7 +203,7 @@ describe('ConsolePage', () => {
127
203
  ).toBeInTheDocument();
128
204
  });
129
205
 
130
- expect(queryByText('Awaiting Quality Check')).toBeNull();
206
+ expect(queryByText('Triage')).toBeNull();
131
207
  });
132
208
 
133
209
  it('hides zero-count tabs but keeps non-zero tabs', async () => {
@@ -151,3 +227,173 @@ describe('ConsolePage', () => {
151
227
  expect(queryByText('project: umino')).toBeNull();
152
228
  });
153
229
  });
230
+
231
+ const twoItemPrPayload = () => ({
232
+ pjcode: 'umino',
233
+ generatedAt: '2026-06-19T00:00:00.000Z',
234
+ statusOptions: [{ id: 's1', name: 'Awaiting Workspace', color: 'BLUE' }],
235
+ storyOptions: [{ id: 'st1', name: 'TDPM Console port', color: 'BLUE' }],
236
+ storyColors: { 'TDPM Console port': { color: 'BLUE' } },
237
+ items: [
238
+ {
239
+ number: 851,
240
+ title: 'Add serveConsole subcommand',
241
+ url: 'https://github.com/o/r/pull/851',
242
+ repo: 'o/r',
243
+ nameWithOwner: 'o/r',
244
+ projectItemId: 'PVTI_1',
245
+ itemId: 'PVTI_1',
246
+ isPr: true,
247
+ story: 'TDPM Console port',
248
+ labels: [],
249
+ createdAt: '2026-06-17T00:00:00.000Z',
250
+ },
251
+ {
252
+ number: 852,
253
+ title: 'Add server-side console API handlers',
254
+ url: 'https://github.com/o/r/pull/852',
255
+ repo: 'o/r',
256
+ nameWithOwner: 'o/r',
257
+ projectItemId: 'PVTI_2',
258
+ itemId: 'PVTI_2',
259
+ isPr: true,
260
+ story: 'TDPM Console port',
261
+ labels: [],
262
+ createdAt: '2026-06-17T01:00:00.000Z',
263
+ },
264
+ ],
265
+ });
266
+
267
+ const touchEvent = (
268
+ type: string,
269
+ point: { clientX: number; clientY: number },
270
+ property: 'touches' | 'changedTouches',
271
+ ): TouchEvent => {
272
+ const event = new Event(type, { bubbles: true }) as TouchEvent;
273
+ Object.defineProperty(event, property, {
274
+ value: [point],
275
+ configurable: true,
276
+ });
277
+ return event;
278
+ };
279
+
280
+ const swipeDetailScreen = (
281
+ element: HTMLElement,
282
+ from: { clientX: number; clientY: number },
283
+ to: { clientX: number; clientY: number },
284
+ ): void => {
285
+ element.dispatchEvent(touchEvent('touchstart', from, 'touches'));
286
+ element.dispatchEvent(touchEvent('touchmove', to, 'touches'));
287
+ element.dispatchEvent(touchEvent('touchend', to, 'changedTouches'));
288
+ };
289
+
290
+ describe('ConsolePage swipe navigation', () => {
291
+ beforeEach(() => {
292
+ localStorage.clear();
293
+ window.history.replaceState({}, '', '/projects/umino/prs?k=token');
294
+ const fetchMock = jest.fn(async (url: string) => {
295
+ const listMatch = url.match(/\/projects\/[^/]+\/([^/]+)\/list\.json/);
296
+ if (listMatch !== null) {
297
+ return {
298
+ ok: true,
299
+ status: 200,
300
+ json: async () =>
301
+ listMatch[1] === 'prs'
302
+ ? twoItemPrPayload()
303
+ : { ...twoItemPrPayload(), items: [] },
304
+ };
305
+ }
306
+ return { ok: true, status: 200, json: async () => ({ body: '# body' }) };
307
+ });
308
+ global.fetch = fetchMock as unknown as typeof fetch;
309
+ });
310
+
311
+ it('navigates to the next item on a left swipe of the opened detail screen', async () => {
312
+ const { container, getByText, findByText } = render(<ConsolePage />);
313
+ await waitFor(() => {
314
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
315
+ });
316
+ fireEvent.click(getByText('Add serveConsole subcommand'));
317
+ expect(await findByText('Approve')).toBeInTheDocument();
318
+ expect(window.location.hash).toBe('#item/PVTI_1');
319
+
320
+ const detailScreen = container.querySelector('.console-detail-screen');
321
+ expect(detailScreen).not.toBeNull();
322
+ swipeDetailScreen(
323
+ detailScreen as HTMLElement,
324
+ { clientX: 240, clientY: 100 },
325
+ { clientX: 40, clientY: 110 },
326
+ );
327
+
328
+ await waitFor(() => {
329
+ expect(window.location.hash).toBe('#item/PVTI_2');
330
+ });
331
+ });
332
+
333
+ it('navigates to the previous item on a right swipe of the opened detail screen', async () => {
334
+ const { container, getByText, findByText } = render(<ConsolePage />);
335
+ await waitFor(() => {
336
+ expect(
337
+ getByText('Add server-side console API handlers'),
338
+ ).toBeInTheDocument();
339
+ });
340
+ fireEvent.click(getByText('Add server-side console API handlers'));
341
+ expect(await findByText('Approve')).toBeInTheDocument();
342
+ expect(window.location.hash).toBe('#item/PVTI_2');
343
+
344
+ const detailScreen = container.querySelector('.console-detail-screen');
345
+ expect(detailScreen).not.toBeNull();
346
+ swipeDetailScreen(
347
+ detailScreen as HTMLElement,
348
+ { clientX: 40, clientY: 100 },
349
+ { clientX: 240, clientY: 110 },
350
+ );
351
+
352
+ await waitFor(() => {
353
+ expect(window.location.hash).toBe('#item/PVTI_1');
354
+ });
355
+ });
356
+ });
357
+
358
+ describe('ConsolePage auto-advance', () => {
359
+ beforeEach(() => {
360
+ localStorage.clear();
361
+ window.history.replaceState({}, '', '/projects/umino/prs?k=token');
362
+ const fetchMock = jest.fn(async (url: string) => {
363
+ const listMatch = url.match(/\/projects\/[^/]+\/([^/]+)\/list\.json/);
364
+ if (listMatch !== null) {
365
+ return {
366
+ ok: true,
367
+ status: 200,
368
+ json: async () =>
369
+ listMatch[1] === 'prs'
370
+ ? twoItemPrPayload()
371
+ : { ...twoItemPrPayload(), items: [] },
372
+ };
373
+ }
374
+ return { ok: true, status: 200, json: async () => ({ body: '# body' }) };
375
+ });
376
+ global.fetch = fetchMock as unknown as typeof fetch;
377
+ });
378
+
379
+ it('advances the detail view to the next pending item after an action', async () => {
380
+ jest.useFakeTimers();
381
+ try {
382
+ const { getByText, findByText } = render(<ConsolePage />);
383
+ await waitFor(() => {
384
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
385
+ });
386
+ fireEvent.click(getByText('Add serveConsole subcommand'));
387
+ expect(await findByText('Approve')).toBeInTheDocument();
388
+ expect(window.location.hash).toBe('#item/PVTI_1');
389
+
390
+ fireEvent.click(getByText('Approve'));
391
+
392
+ await waitFor(() => {
393
+ expect(window.location.hash).toBe('#item/PVTI_2');
394
+ });
395
+ } finally {
396
+ jest.useRealTimers();
397
+ }
398
+ });
399
+ });