@aiquants/daily-report 0.7.0 → 0.9.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.
- package/README.md +13 -6
- package/dist/client.d.mts +438 -26
- package/dist/client.d.ts +438 -26
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +115 -3
- package/dist/index.d.ts +115 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +15 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +9 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +9 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
- package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-comment-item.tsx +6 -1
- package/src/client/components/daily-report-detail-list.tsx +65 -34
- package/src/client/components/daily-report-edit-form.tsx +6 -0
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-list.tsx +88 -55
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +62 -69
- package/src/client/components/daily-report-resolved-content.tsx +36 -10
- package/src/client/components/report-views.spec.tsx +37 -2
- package/src/client/config-context.tsx +18 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
- package/src/client/contexts/daily-report-action-context.tsx +203 -65
- package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
- package/src/client/contexts/daily-report-error-context.tsx +98 -0
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/hooks/use-daily-report.spec.ts +50 -2
- package/src/client/hooks/use-daily-report.ts +30 -1
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +704 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +677 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client.ts +5 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.action.spec.ts +44 -0
- package/src/server/handlers.ids-stream.spec.ts +414 -0
- package/src/server/handlers.ts +324 -8
- package/src/server/service.spec.ts +83 -7
- package/src/server/service.ts +100 -3
- package/src/server/test-helpers/handlers-config.ts +48 -1
- package/src/server.spec.ts +49 -0
- package/src/server.ts +7 -0
- package/src/shared/business-date.spec.ts +22 -1
- package/src/shared/business-date.ts +20 -0
- package/src/shared/comment-adapter.spec.ts +38 -0
- package/src/shared/comment-adapter.ts +9 -7
- package/src/shared/ids-stream.spec.ts +65 -0
- package/src/shared/ids-stream.ts +139 -0
- package/src/shared/sse-schema.ts +5 -0
- package/src/shared/text-utils.spec.ts +42 -0
- package/src/shared/text-utils.ts +8 -0
|
@@ -34,6 +34,9 @@ export const DailyReportEditForm = ({ report, onCancel, onPublishSuccess }: Dail
|
|
|
34
34
|
setIsSaving(true)
|
|
35
35
|
try {
|
|
36
36
|
await updateReport(report.reportHubId, { title, content }, report.date ?? "")
|
|
37
|
+
} catch {
|
|
38
|
+
// 失敗はアクションコンテキストが role="alert" バナー / onError で可視化する。
|
|
39
|
+
// ここで握るのは未処理 Promise 拒否を防ぐためだけ (成功時のみ後続へ進む)
|
|
37
40
|
} finally {
|
|
38
41
|
setIsSaving(false)
|
|
39
42
|
}
|
|
@@ -45,7 +48,10 @@ export const DailyReportEditForm = ({ report, onCancel, onPublishSuccess }: Dail
|
|
|
45
48
|
// Ensure latest changes are saved before publishing
|
|
46
49
|
await updateReport(report.reportHubId, { title, content }, report.date ?? "")
|
|
47
50
|
await publishReport(report.reportHubId, report.date ?? "")
|
|
51
|
+
// 保存・公開の両方が成功したときだけ成功コールバックを呼ぶ
|
|
48
52
|
onPublishSuccess?.()
|
|
53
|
+
} catch {
|
|
54
|
+
// 失敗はアクションコンテキストが可視化する。onPublishSuccess は呼ばない
|
|
49
55
|
} finally {
|
|
50
56
|
setIsSaving(false)
|
|
51
57
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-phase rendering tests for the ids stream status badge.
|
|
3
|
+
* ids ストリーム状態バッジの局面別描画テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **進捗率は 0-100 にクランプされ、total 0/null では表示されない** (0 除算・>100% 防止)。
|
|
8
|
+
* - **failed バッジの再試行ボタンはセッション retry へ直結する。**
|
|
9
|
+
* - **idle / 完走済み (再検証なし) では何も描画しない** (ヘッダーを汚さない)。
|
|
10
|
+
*/
|
|
11
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
12
|
+
import { describe, expect, it, vi } from "vitest"
|
|
13
|
+
import type { DailyReportIdsStreamState } from "../streaming/daily-report-ids-stream-client"
|
|
14
|
+
import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
|
|
15
|
+
|
|
16
|
+
let mockStreamState: DailyReportIdsStreamState
|
|
17
|
+
const retryMock = vi.fn()
|
|
18
|
+
|
|
19
|
+
vi.mock("../streaming/daily-report-ids-stream-session", () => ({
|
|
20
|
+
useDailyReportIdsStream: () => mockStreamState,
|
|
21
|
+
retryDailyReportIdsStream: () => retryMock(),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
/** 状態を組み立ててバッジを描画する。 */
|
|
25
|
+
const renderStatus = (partial: Partial<DailyReportIdsStreamState>) => {
|
|
26
|
+
mockStreamState = {
|
|
27
|
+
items: [],
|
|
28
|
+
loadedCount: 0,
|
|
29
|
+
totalCount: null,
|
|
30
|
+
phase: "idle",
|
|
31
|
+
isRevalidating: false,
|
|
32
|
+
hasCompletedOnce: false,
|
|
33
|
+
completedAt: null,
|
|
34
|
+
error: null,
|
|
35
|
+
...partial,
|
|
36
|
+
}
|
|
37
|
+
return render(<DailyReportIdsStreamStatus />)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("DailyReportIdsStreamStatus", () => {
|
|
41
|
+
it("streaming + total あり: 進捗率付きピルを表示する", () => {
|
|
42
|
+
renderStatus({ phase: "streaming", loadedCount: 250, totalCount: 1000 })
|
|
43
|
+
const badge = screen.getByTestId("daily-report-ids-stream-status")
|
|
44
|
+
expect(badge).toHaveAttribute("data-phase", "streaming")
|
|
45
|
+
expect(badge.textContent).toContain("読み込み中")
|
|
46
|
+
expect(badge.textContent).toContain("(25%)")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("streaming + total 未着: 件数のみで率を出さない", () => {
|
|
50
|
+
renderStatus({ phase: "streaming", loadedCount: 42, totalCount: null })
|
|
51
|
+
const badge = screen.getByTestId("daily-report-ids-stream-status")
|
|
52
|
+
expect(badge.textContent).toContain("件")
|
|
53
|
+
expect(badge.textContent).not.toContain("%")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("streaming + total 0: 0 除算せず率を出さない", () => {
|
|
57
|
+
renderStatus({ phase: "streaming", loadedCount: 0, totalCount: 0 })
|
|
58
|
+
expect(screen.getByTestId("daily-report-ids-stream-status").textContent).not.toContain("%")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("loadedCount > total でも率は 100% にクランプされる", () => {
|
|
62
|
+
renderStatus({ phase: "streaming", loadedCount: 12, totalCount: 10 })
|
|
63
|
+
expect(screen.getByTestId("daily-report-ids-stream-status").textContent).toContain("(100%)")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("retrying: 受信済み件数付きの再接続表示", () => {
|
|
67
|
+
renderStatus({ phase: "retrying", loadedCount: 123 })
|
|
68
|
+
const badge = screen.getByTestId("daily-report-ids-stream-status")
|
|
69
|
+
expect(badge).toHaveAttribute("data-phase", "retrying")
|
|
70
|
+
expect(badge.textContent).toContain("再接続中")
|
|
71
|
+
expect(badge.textContent).toContain("123")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("failed: 再試行ボタンがセッション retry を呼ぶ", () => {
|
|
75
|
+
retryMock.mockClear()
|
|
76
|
+
renderStatus({ phase: "failed", error: new Error("x") })
|
|
77
|
+
expect(screen.getByTestId("daily-report-ids-stream-status")).toHaveAttribute("data-phase", "failed")
|
|
78
|
+
fireEvent.click(screen.getByTestId("daily-report-ids-stream-retry"))
|
|
79
|
+
expect(retryMock).toHaveBeenCalledTimes(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("complete + 再検証中: 控えめな最新化インジケーター", () => {
|
|
83
|
+
renderStatus({ phase: "complete", isRevalidating: true, hasCompletedOnce: true })
|
|
84
|
+
expect(screen.getByTestId("daily-report-ids-stream-status")).toHaveAttribute("data-phase", "revalidating")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("idle / complete (再検証なし) は何も描画しない", () => {
|
|
88
|
+
const { container: idleContainer } = renderStatus({ phase: "idle" })
|
|
89
|
+
expect(idleContainer.firstChild).toBeNull()
|
|
90
|
+
const { container: completeContainer } = renderStatus({ phase: "complete", hasCompletedOnce: true })
|
|
91
|
+
expect(completeContainer.firstChild).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header badge that visualizes the ids stream lifecycle.
|
|
3
|
+
* ID ストリームのライフサイクルをヘッダーへ可視化するバッジコンポーネント。
|
|
4
|
+
*
|
|
5
|
+
* 局面ごとの表示:
|
|
6
|
+
* - streaming: 進捗ピル「読み込み中 n / total (p%)」(total 未着なら件数のみ)
|
|
7
|
+
* - retrying: 「再接続中... (n 件受信済)」
|
|
8
|
+
* - failed: 失敗表示 + 再試行ボタン (受信済みデータは保持されている)
|
|
9
|
+
* - complete + 再検証中: 控えめな「最新化中」インジケーター
|
|
10
|
+
* - それ以外 (idle / complete): 何も描画しない
|
|
11
|
+
*/
|
|
12
|
+
import { RefreshCw } from "lucide-react"
|
|
13
|
+
import { useAnimatedNumber } from "../hooks/use-animated-number"
|
|
14
|
+
import { retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders the streaming progress / recovery badge for the ids stream.
|
|
18
|
+
* ids ストリームの進捗・復旧バッジを描画するコンポーネント。
|
|
19
|
+
*/
|
|
20
|
+
export const DailyReportIdsStreamStatus = () => {
|
|
21
|
+
const stream = useDailyReportIdsStream()
|
|
22
|
+
const animatedLoaded = useAnimatedNumber(stream.loadedCount)
|
|
23
|
+
const animatedTotal = useAnimatedNumber(stream.totalCount ?? 0)
|
|
24
|
+
|
|
25
|
+
if (stream.phase === "streaming") {
|
|
26
|
+
// total 到着前は件数のみ、到着後は分母と進捗率を添える
|
|
27
|
+
const percent = stream.totalCount && stream.totalCount > 0 ? Math.min(100, Math.round((stream.loadedCount / stream.totalCount) * 100)) : null
|
|
28
|
+
return (
|
|
29
|
+
<span data-testid="daily-report-ids-stream-status" data-phase="streaming" className="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-800 text-xs tabular-nums dark:bg-blue-900/40 dark:text-blue-300">
|
|
30
|
+
<span aria-hidden="true" className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500 dark:bg-blue-400" />
|
|
31
|
+
読み込み中 {animatedLoaded.toLocaleString()}
|
|
32
|
+
{percent !== null ? ` / ${animatedTotal.toLocaleString()} (${percent}%)` : " 件"}
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (stream.phase === "retrying") {
|
|
38
|
+
return (
|
|
39
|
+
<span role="status" data-testid="daily-report-ids-stream-status" data-phase="retrying" className="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-800 text-xs tabular-nums dark:bg-amber-900/40 dark:text-amber-300">
|
|
40
|
+
<RefreshCw aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
41
|
+
再接続中... ({stream.loadedCount.toLocaleString()} 件受信済)
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (stream.phase === "failed") {
|
|
47
|
+
return (
|
|
48
|
+
<span role="status" data-testid="daily-report-ids-stream-status" data-phase="failed" className="inline-flex items-center gap-1.5 rounded-full bg-rose-100 px-2 py-0.5 font-medium text-rose-800 text-xs dark:bg-rose-900/40 dark:text-rose-300">
|
|
49
|
+
読み込みに失敗しました
|
|
50
|
+
<button type="button" data-testid="daily-report-ids-stream-retry" onClick={() => retryDailyReportIdsStream()} className="rounded bg-rose-200 px-1.5 py-0.5 font-semibold text-rose-900 hover:bg-rose-300 dark:bg-rose-800 dark:text-rose-100 dark:hover:bg-rose-700">
|
|
51
|
+
再試行
|
|
52
|
+
</button>
|
|
53
|
+
</span>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (stream.isRevalidating) {
|
|
58
|
+
return (
|
|
59
|
+
<span data-testid="daily-report-ids-stream-status" data-phase="revalidating" className="inline-flex items-center gap-1 text-slate-400 text-xs dark:text-slate-500">
|
|
60
|
+
<RefreshCw aria-hidden="true" className="h-3 w-3 animate-spin" />
|
|
61
|
+
最新化中
|
|
62
|
+
</span>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { SwipeCloseGuardContext, useSwipeGuardState, useSwipeToDismissOverlay, useTapClick, useTransitionDisplay } from "@aiquants/swipe-overlay"
|
|
6
6
|
import { type ScrollBarThumbOverlayRenderProps, VirtualScroll, type VirtualScrollHandle } from "@aiquants/virtualscroll"
|
|
7
7
|
import { Check, Star, Trash2 } from "lucide-react"
|
|
8
|
-
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
8
|
+
import { type CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
9
9
|
import { fallbackText, formatToIsoDate } from "../../shared/text-utils"
|
|
10
10
|
import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
|
|
11
11
|
import { resolveSourceTypeConfig, useDailyReportConfig } from "../config-context"
|
|
@@ -45,8 +45,6 @@ type TestableVirtualScrollHandle = VirtualScrollHandle & {
|
|
|
45
45
|
|
|
46
46
|
type DailyReportListProps = {
|
|
47
47
|
dailyReportItems: DailyReportItem[]
|
|
48
|
-
initialSelectedBusinessDate?: string | null
|
|
49
|
-
initialSelectedReportHubId?: number | null
|
|
50
48
|
autoMarkRead?: boolean
|
|
51
49
|
selectedReportHubId: number | null
|
|
52
50
|
onSelectItem: (reportHubId: number | null) => void
|
|
@@ -234,15 +232,16 @@ const useAutoRead = (report: DailyReportDetail | null, isLoading: boolean, autoM
|
|
|
234
232
|
* Daily report list cards with key metadata and preview content via virtual scrolling.
|
|
235
233
|
* 主なメタデータと内容プレビューを仮想スクロール表示する日報リスト。
|
|
236
234
|
*/
|
|
237
|
-
export const DailyReportList = ({ dailyReportItems,
|
|
235
|
+
export const DailyReportList = ({ dailyReportItems, autoMarkRead = false, selectedReportHubId, onSelectItem, userId, initialScrollOffset, onScrollOffsetChange }: DailyReportListProps) => {
|
|
238
236
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
|
239
237
|
const virtualScrollRef = useRef<VirtualScrollHandle>(null)
|
|
238
|
+
// list ロールの aria-label に使う (消費側が注入する業務ドメイン名。既定 "Daily Reports")
|
|
239
|
+
const { title } = useDailyReportConfig()
|
|
240
240
|
const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef)
|
|
241
241
|
const [overlayLabel, setOverlayLabel] = useState<string>("")
|
|
242
242
|
const [isWheelScrollActive, setIsWheelScrollActive] = useState(false)
|
|
243
243
|
const isMobileLayout = useIsMobileLayout()
|
|
244
244
|
const wheelResetTimerRef = useRef<number | null>(null)
|
|
245
|
-
const appliedInitialSignatureRef = useRef<string | null>(null)
|
|
246
245
|
const itemCount = dailyReportItems.length
|
|
247
246
|
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
248
247
|
|
|
@@ -285,34 +284,16 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
285
284
|
}, [updateViewportHeight])
|
|
286
285
|
|
|
287
286
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
287
|
+
* Clears the selection when the list becomes empty.
|
|
288
|
+
* 一覧が空になったとき選択を解除する副作用。
|
|
290
289
|
*
|
|
291
|
-
*
|
|
292
|
-
* Dependencies: [dailyReportItems, initialSelectedBusinessDate, initialSelectedReportHubId, selectedReportHubId, itemCount, onSelectItem]
|
|
293
|
-
* Cleanup: None.
|
|
290
|
+
* 依存配列: [itemCount, selectedReportHubId, onSelectItem]。クリーンアップ: なし。
|
|
294
291
|
*/
|
|
295
292
|
useEffect(() => {
|
|
296
|
-
if (!itemCount) {
|
|
297
|
-
|
|
298
|
-
if (selectedReportHubId !== null) onSelectItem(null)
|
|
299
|
-
return
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const signature = `${initialSelectedReportHubId ?? ""}|${initialSelectedBusinessDate ?? ""}`
|
|
303
|
-
const hasInitial = (initialSelectedReportHubId ?? null) !== null || (initialSelectedBusinessDate ?? null) !== null
|
|
304
|
-
|
|
305
|
-
if (!hasInitial) appliedInitialSignatureRef.current = null
|
|
306
|
-
|
|
307
|
-
if (hasInitial && appliedInitialSignatureRef.current !== signature) {
|
|
308
|
-
const match = (initialSelectedReportHubId && dailyReportItems.find((i) => i.reportHubId === initialSelectedReportHubId)) || (initialSelectedBusinessDate && dailyReportItems.find((i) => i.businessDate === initialSelectedBusinessDate))
|
|
309
|
-
if (match) {
|
|
310
|
-
appliedInitialSignatureRef.current = signature
|
|
311
|
-
if (selectedReportHubId !== match.reportHubId) onSelectItem(match.reportHubId)
|
|
312
|
-
return
|
|
313
|
-
}
|
|
293
|
+
if (!itemCount && selectedReportHubId !== null) {
|
|
294
|
+
onSelectItem(null)
|
|
314
295
|
}
|
|
315
|
-
}, [
|
|
296
|
+
}, [itemCount, selectedReportHubId, onSelectItem])
|
|
316
297
|
|
|
317
298
|
/**
|
|
318
299
|
* Restores focus after closing overlay (Desktop only).
|
|
@@ -329,7 +310,15 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
329
310
|
|
|
330
311
|
const index = dailyReportItems.findIndex((item) => item.reportHubId === targetId)
|
|
331
312
|
if (index >= 0) {
|
|
332
|
-
|
|
313
|
+
// スクロールで行を DOM に入れてから、カード本体 (tabIndex=0) へフォーカスを戻す。
|
|
314
|
+
// focusItemAtIndex は enableKeyboardNavigation:false では no-op のため使えない
|
|
315
|
+
// (行ラッパーではなくカードへ直接フォーカスするのが本来の意図でもある)
|
|
316
|
+
virtualScrollRef.current?.scrollToIndex(index)
|
|
317
|
+
requestAnimationFrame(() => {
|
|
318
|
+
// detail-list の行も同じ data 属性を持つため、一覧側スコープで限定する
|
|
319
|
+
const card = document.querySelector<HTMLElement>(`[data-testid="daily-report-list"] [data-daily-report-button="${targetId}"]`)
|
|
320
|
+
card?.focus()
|
|
321
|
+
})
|
|
333
322
|
}
|
|
334
323
|
}, [isMobileDetailOpen, isMobileLayout, dailyReportItems, lastClosedItemIdRef])
|
|
335
324
|
|
|
@@ -377,6 +366,13 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
377
366
|
const getItem = useCallback((index: number) => (index < itemCount ? dailyReportItems[index] : EMPTY_ITEM), [dailyReportItems, itemCount])
|
|
378
367
|
const getItemHeight = useCallback(() => DEFAULT_ITEM_HEIGHT, [])
|
|
379
368
|
|
|
369
|
+
// 行レンダラーを安定参照にする。インライン矢印だと毎レンダー新関数になり、
|
|
370
|
+
// React.memo の ListReportItem が全行で再レンダーへ落ちる (選択変更で全行が再描画)
|
|
371
|
+
const renderListRow = useCallback(
|
|
372
|
+
(item: DailyReportItem, index: number) => <ListReportItem key={item.reportHubId} item={item} index={index} setSize={itemCount} isActive={item.reportHubId === selectedReportHubId} onSelect={handleItemSelect} />,
|
|
373
|
+
[itemCount, selectedReportHubId, handleItemSelect],
|
|
374
|
+
)
|
|
375
|
+
|
|
380
376
|
const handleRangeChange = useCallback(
|
|
381
377
|
(range: { visibleStartIndex: number }) => {
|
|
382
378
|
const item = getItem(range.visibleStartIndex)
|
|
@@ -389,17 +385,6 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
389
385
|
[getItem],
|
|
390
386
|
)
|
|
391
387
|
|
|
392
|
-
const handleItemFocus = useCallback(
|
|
393
|
-
(index: number) => {
|
|
394
|
-
const item = getItem(index)
|
|
395
|
-
if (item && item.reportHubId > 0) {
|
|
396
|
-
if (item.reportHubId === selectedReportHubId) return
|
|
397
|
-
onSelectItem(item.reportHubId)
|
|
398
|
-
}
|
|
399
|
-
},
|
|
400
|
-
[getItem, onSelectItem, selectedReportHubId],
|
|
401
|
-
)
|
|
402
|
-
|
|
403
388
|
const handleScroll = useCallback(() => {
|
|
404
389
|
if (virtualScrollRef.current && onScrollOffsetChange) {
|
|
405
390
|
onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
|
|
@@ -457,14 +442,25 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
457
442
|
pointerDragInputs: CONTENT_DRAG_POINTER_INPUTS,
|
|
458
443
|
inertiaOptions: FAST_SCROLL_INERTIA_OPTIONS,
|
|
459
444
|
wheelSpeedMultiplier: FAST_SCROLL_WHEEL_MULTIPLIER,
|
|
445
|
+
// 行ラッパーへの tabIndex 付与 (タブ順汚染) と pointerdown フォーカス移動を止める。
|
|
446
|
+
// 「★/既読タップで詳細ペインが切り替わる」不具合は 2 経路あり、これはその 1 つ目
|
|
447
|
+
// (focus 経由)。2 つ目 (useTapClick の pointerup バブリング) はボタン側の
|
|
448
|
+
// stopPointerPropagation が塞ぐ。onItemFocus は発火しなくなる
|
|
449
|
+
enableKeyboardNavigation: false,
|
|
460
450
|
}}
|
|
461
451
|
onRangeChange={handleRangeChange}
|
|
462
452
|
onScroll={handleScroll}
|
|
463
|
-
onItemFocus={handleItemFocus}
|
|
464
453
|
contentInsets={{ top: ITEM_VERTICAL_PADDING, bottom: ITEM_VERTICAL_PADDING }}
|
|
465
454
|
testId="daily-report-list"
|
|
466
|
-
className="relative w-full"
|
|
467
|
-
|
|
455
|
+
className="relative w-full"
|
|
456
|
+
contentProps={{
|
|
457
|
+
// 行 (role="option" ではなく role="listitem") だけを所有する list を、
|
|
458
|
+
// スクロールバーと兄弟のコンテンツ要素に置く (virtualscroll 1.23.0 の口)。
|
|
459
|
+
// 各行が aria-posinset/aria-setsize を持つので SR は「全 N 件中 M 番目」を読める
|
|
460
|
+
role: "list",
|
|
461
|
+
"aria-label": title,
|
|
462
|
+
}}>
|
|
463
|
+
{renderListRow}
|
|
468
464
|
</VirtualScroll>
|
|
469
465
|
</div>
|
|
470
466
|
<DailyReportSidePane dailyReportItems={dailyReportItems} selectedItem={selectedItem} heightStyle={columnHeightStyle} autoMarkRead={!isMobileLayout && autoMarkRead} isActive={selectedReportHubId !== null} userId={userId} />
|
|
@@ -482,9 +478,12 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
482
478
|
/**
|
|
483
479
|
* Loading skeleton representation for daily report list items.
|
|
484
480
|
* 日報リストアイテム用読み込み中スケルトン表示。
|
|
481
|
+
*
|
|
482
|
+
* `wrapperProps` は仮想リストの位置属性 (role="listitem" / aria-posinset / aria-setsize) を
|
|
483
|
+
* 外側ラッパーへ透過するための口。読み込み中の行も一覧内の位置を SR へ公開する。
|
|
485
484
|
*/
|
|
486
|
-
const ListSkeletonItem = () => (
|
|
487
|
-
<div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
485
|
+
const ListSkeletonItem = (wrapperProps: React.HTMLAttributes<HTMLDivElement> & { "aria-posinset"?: number; "aria-setsize"?: number }) => (
|
|
486
|
+
<div {...wrapperProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
488
487
|
<div data-skeleton className="flex h-full animate-pulse flex-col justify-between rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
|
489
488
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
|
490
489
|
<div className="h-4 w-24 rounded bg-slate-200"></div>
|
|
@@ -657,6 +656,15 @@ const DailyReportSidePane = ({
|
|
|
657
656
|
)
|
|
658
657
|
}
|
|
659
658
|
|
|
659
|
+
/**
|
|
660
|
+
* Skeleton line placeholder (module-scope so it is not re-created every render).
|
|
661
|
+
* スケルトンの 1 行プレースホルダー (レンダーごとに再生成しないようモジュールスコープに置く)。
|
|
662
|
+
*
|
|
663
|
+
* 以前は DailyReportDisplayPane の render 本体で宣言していたため、レンダーのたびに
|
|
664
|
+
* 別コンポーネント型となり全スケルトンが再マウントしていた。
|
|
665
|
+
*/
|
|
666
|
+
const SkeletonText = ({ className = "w-32" }: { className?: string }) => <div data-skeleton className={`h-4 animate-pulse rounded bg-slate-200 dark:bg-slate-700 ${className}`} />
|
|
667
|
+
|
|
660
668
|
/**
|
|
661
669
|
* Shows the selected report details in the display tab, including content and metadata.
|
|
662
670
|
* 表示タブ内で選択した日報の詳細とメタデータを表示するコンポーネント。
|
|
@@ -756,8 +764,6 @@ const DailyReportDisplayPane = ({
|
|
|
756
764
|
const isMine = !!(userId && report?.userId === userId)
|
|
757
765
|
const hasComments = uiComments.length > 0
|
|
758
766
|
|
|
759
|
-
const SkeletonText = ({ className = "w-32" }: { className?: string }) => <div data-skeleton className={`h-4 animate-pulse rounded bg-slate-200 dark:bg-slate-700 ${className}`} />
|
|
760
|
-
|
|
761
767
|
return (
|
|
762
768
|
// biome-ignore lint/a11y/noStaticElementInteractions: Double click to edit feature
|
|
763
769
|
<div className="relative space-y-4 text-slate-700 dark:text-slate-200" onDoubleClick={onDoubleClick}>
|
|
@@ -938,10 +944,13 @@ const extractPreviewLines = (content: string | null): string[] => {
|
|
|
938
944
|
* Daily report summary card rendered inside the list tab.
|
|
939
945
|
* List タブ内で表示する日報サマリーカード。
|
|
940
946
|
*/
|
|
941
|
-
const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; isActive: boolean; onSelect: (id: number) => void }) => {
|
|
947
|
+
const ListReportItem = memo(({ item, index, setSize, isActive, onSelect }: { item: DailyReportItem; index: number; setSize: number; isActive: boolean; onSelect: (id: number) => void }) => {
|
|
942
948
|
const { fieldLabels, sourceTypeConfigs } = useDailyReportConfig()
|
|
943
949
|
const { toggleStar, toggleRead, removeStaleItem } = useDailyReportActionContext()
|
|
944
950
|
const { report, error, isLoading, isRefetching } = useDailyReportDetail(item.reportHubId, item.businessDate)
|
|
951
|
+
// list 内の絶対位置を SR へ公開する (仮想化で描画されるのは窓分だけなので必須)。
|
|
952
|
+
// どの表示状態 (読み込み中・エラー・本体) でも同じ位置属性を載せる
|
|
953
|
+
const listItemProps = { role: "listitem", "aria-posinset": index + 1, "aria-setsize": setSize } as const
|
|
945
954
|
|
|
946
955
|
// フェッチが一度でも試行されたかを追跡する ref
|
|
947
956
|
const hasEverFetched = useRef(false)
|
|
@@ -959,19 +968,19 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
959
968
|
|
|
960
969
|
if (error) {
|
|
961
970
|
return (
|
|
962
|
-
<div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
963
|
-
<div className="flex h-full items-center justify-center rounded-2xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm">
|
|
971
|
+
<div {...listItemProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
972
|
+
<div className="flex h-full items-center justify-center rounded-2xl border border-red-200 bg-red-50 p-4 text-red-700 text-sm dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-300">
|
|
964
973
|
<p>日報 {item.reportHubId.toLocaleString()} の概要読み込みに失敗しました。</p>
|
|
965
974
|
</div>
|
|
966
975
|
</div>
|
|
967
976
|
)
|
|
968
977
|
}
|
|
969
978
|
|
|
970
|
-
if (isLoading) return <ListSkeletonItem />
|
|
979
|
+
if (isLoading) return <ListSkeletonItem {...listItemProps} />
|
|
971
980
|
|
|
972
981
|
if (!report) {
|
|
973
982
|
return (
|
|
974
|
-
<div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
983
|
+
<div {...listItemProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
975
984
|
<div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-500 text-sm dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-400">
|
|
976
985
|
<p>日報が見つかりません</p>
|
|
977
986
|
</div>
|
|
@@ -1006,8 +1015,19 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
1006
1015
|
}
|
|
1007
1016
|
}
|
|
1008
1017
|
|
|
1018
|
+
/**
|
|
1019
|
+
* Keeps card selection from firing when an inner action button is tapped.
|
|
1020
|
+
* カード内のアクションボタン操作でカード選択を発火させないための伝播停止。
|
|
1021
|
+
*
|
|
1022
|
+
* カードの選択は useTapClick (pointerdown/pointerup 駆動) なので、click の
|
|
1023
|
+
* stopPropagation だけでは止まらない。pointer イベント自体を止める必要がある。
|
|
1024
|
+
*/
|
|
1025
|
+
const stopPointerPropagation = (e: React.PointerEvent) => {
|
|
1026
|
+
e.stopPropagation()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1009
1029
|
return (
|
|
1010
|
-
<div className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
1030
|
+
<div {...listItemProps} className="px-2 pb-3" style={{ height: ITEM_CONTAINER_HEIGHT }}>
|
|
1011
1031
|
{/* biome-ignore lint/a11y/useSemanticElements: Card contains nested interactive elements (Star/Read buttons) */}
|
|
1012
1032
|
<div
|
|
1013
1033
|
data-daily-report-button={item.reportHubId}
|
|
@@ -1017,7 +1037,11 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
1017
1037
|
style={{ touchAction: "manipulation" }}
|
|
1018
1038
|
role="button"
|
|
1019
1039
|
tabIndex={0}
|
|
1040
|
+
// aria-current が「一覧の現在項目」の意味論を担う。aria-pressed は
|
|
1041
|
+
// 消費アプリの E2E ヘルパーが選択判定に使う既存契約のため併記を維持する
|
|
1042
|
+
aria-current={isActive ? "true" : undefined}
|
|
1020
1043
|
aria-pressed={isActive}
|
|
1044
|
+
aria-label={`${date} ${creator}`}
|
|
1021
1045
|
{...tapHandlers}>
|
|
1022
1046
|
<div className="mb-3 flex items-center gap-3 text-slate-600 text-sm">
|
|
1023
1047
|
<span className="inline-flex shrink-0 items-center rounded-full bg-slate-100 px-3 py-1 font-semibold text-xs dark:bg-slate-800 dark:text-slate-200">
|
|
@@ -1038,16 +1062,24 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
1038
1062
|
<button
|
|
1039
1063
|
type="button"
|
|
1040
1064
|
title={report.isStarred ? "スターを外す" : "スターを付ける"}
|
|
1065
|
+
aria-label={report.isStarred ? "スターを外す" : "スターを付ける"}
|
|
1066
|
+
aria-pressed={report.isStarred}
|
|
1041
1067
|
data-list-star-button={item.reportHubId}
|
|
1042
1068
|
onClick={handleToggleStar}
|
|
1069
|
+
onPointerDown={stopPointerPropagation}
|
|
1070
|
+
onPointerUp={stopPointerPropagation}
|
|
1043
1071
|
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 dark:hover:bg-slate-700">
|
|
1044
1072
|
{report.isStarred ? <Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" /> : <Star className="h-3.5 w-3.5 text-slate-300 dark:text-slate-500" />}
|
|
1045
1073
|
</button>
|
|
1046
1074
|
<button
|
|
1047
1075
|
type="button"
|
|
1048
1076
|
title={report.isRead ? "未読にする" : "既読にする"}
|
|
1077
|
+
aria-label={report.isRead ? "未読にする" : "既読にする"}
|
|
1078
|
+
aria-pressed={report.isRead}
|
|
1049
1079
|
data-list-read-button={item.reportHubId}
|
|
1050
1080
|
onClick={handleToggleRead}
|
|
1081
|
+
onPointerDown={stopPointerPropagation}
|
|
1082
|
+
onPointerUp={stopPointerPropagation}
|
|
1051
1083
|
className="flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-slate-200 dark:hover:bg-slate-700">
|
|
1052
1084
|
{report.isRead ? <Check className="h-3.5 w-3.5 text-blue-500" /> : <UnreadIndicator className="text-xs" />}
|
|
1053
1085
|
</button>
|
|
@@ -1067,4 +1099,5 @@ const ListReportItem = ({ item, isActive, onSelect }: { item: DailyReportItem; i
|
|
|
1067
1099
|
</div>
|
|
1068
1100
|
</div>
|
|
1069
1101
|
)
|
|
1070
|
-
}
|
|
1102
|
+
})
|
|
1103
|
+
ListReportItem.displayName = "ListReportItem"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-gating tests for the stream-driven daily report page.
|
|
3
|
+
* ストリーム駆動日報ページの局面ゲーティングテスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **最初のチャンク到着 (items > 0) で即座に本体を描画する** — 全件解決を待つ
|
|
8
|
+
* ゲートが存在しないことがストリーミング化の成果そのもの。
|
|
9
|
+
* - **failed でも受信済みアイテムがあれば本体を描画し続ける** (データを捨てない)。
|
|
10
|
+
* - **マウント時にセッションを ensure する** (clientLoader を経ないマウントの保険)。
|
|
11
|
+
* - **auth-required はフルリロードへ倒す** (サーバー loader のリダイレクトに委譲)。
|
|
12
|
+
*/
|
|
13
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
14
|
+
import { describe, expect, it, vi } from "vitest"
|
|
15
|
+
import type { DailyReportItem } from "../../shared/types"
|
|
16
|
+
import type { DailyReportIdsStreamState } from "../streaming/daily-report-ids-stream-client"
|
|
17
|
+
import { DailyReportPage } from "./daily-report-page"
|
|
18
|
+
|
|
19
|
+
/** テストが差し替えるストリーム状態 (各テスト冒頭で上書きする)。 */
|
|
20
|
+
let mockStreamState: DailyReportIdsStreamState = {
|
|
21
|
+
items: [],
|
|
22
|
+
loadedCount: 0,
|
|
23
|
+
totalCount: null,
|
|
24
|
+
phase: "idle",
|
|
25
|
+
isRevalidating: false,
|
|
26
|
+
hasCompletedOnce: false,
|
|
27
|
+
completedAt: null,
|
|
28
|
+
error: null,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ensureMock = vi.fn()
|
|
32
|
+
const retryMock = vi.fn()
|
|
33
|
+
const reloadMock = vi.fn()
|
|
34
|
+
|
|
35
|
+
vi.mock("../streaming/daily-report-ids-stream-session", () => ({
|
|
36
|
+
useDailyReportIdsStream: () => mockStreamState,
|
|
37
|
+
ensureDailyReportIdsStreamSession: (options: unknown) => ensureMock(options),
|
|
38
|
+
retryDailyReportIdsStream: () => retryMock(),
|
|
39
|
+
refreshDailyReportIdsStream: vi.fn(),
|
|
40
|
+
primeDailyReportIdsStreamSession: vi.fn(),
|
|
41
|
+
reloadDocument: () => reloadMock(),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
/** 重量級の子孫 (仮想スクロール・SSE) はマーカーへ置換し、ゲーティングだけを検証する。 */
|
|
45
|
+
vi.mock("./daily-report-resolved-content", () => ({
|
|
46
|
+
DailyReportResolvedContent: () => <div data-testid="resolved-content" />,
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
/** provider は initialItems の受け取りを記録する透過スタブにする。 */
|
|
50
|
+
const providerItemsLog: DailyReportItem[][] = []
|
|
51
|
+
vi.mock("../contexts/daily-report-action-context", () => ({
|
|
52
|
+
DailyReportActionProvider: ({ children, initialItems }: { children: React.ReactNode; initialItems?: DailyReportItem[] }) => {
|
|
53
|
+
providerItemsLog.push(initialItems ?? [])
|
|
54
|
+
return <div>{children}</div>
|
|
55
|
+
},
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
/** 状態を部分上書きして描画するヘルパー。 */
|
|
59
|
+
const renderPage = (partial: Partial<DailyReportIdsStreamState>) => {
|
|
60
|
+
mockStreamState = { ...mockStreamState, items: [], loadedCount: 0, totalCount: null, phase: "idle", isRevalidating: false, hasCompletedOnce: false, completedAt: null, error: null, ...partial }
|
|
61
|
+
return render(<DailyReportPage userId="ENC8" />)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const twoItems: DailyReportItem[] = [
|
|
65
|
+
{ reportHubId: 2, businessDate: "2026-08-02" },
|
|
66
|
+
{ reportHubId: 1, businessDate: "2026-08-01" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
describe("DailyReportPage / 局面ゲーティング", () => {
|
|
70
|
+
it("未受信 (idle/streaming) はローディング画面を表示する", () => {
|
|
71
|
+
renderPage({ phase: "streaming" })
|
|
72
|
+
expect(screen.getByText("日報 ID を読み込み中です...")).toBeInTheDocument()
|
|
73
|
+
expect(screen.queryByTestId("resolved-content")).not.toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("最初のチャンク到着 (items > 0) で本体を描画し、provider へアイテムを渡す", () => {
|
|
77
|
+
providerItemsLog.length = 0
|
|
78
|
+
renderPage({ phase: "streaming", items: twoItems, loadedCount: 2 })
|
|
79
|
+
expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
|
|
80
|
+
expect(providerItemsLog.at(-1)).toEqual(twoItems)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("空リストの完走 (complete) は本体を描画する (0 件表示は下層の責務)", () => {
|
|
84
|
+
renderPage({ phase: "complete", hasCompletedOnce: true })
|
|
85
|
+
expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("未受信のまま failed ならエラー画面を出し、再試行がセッション retry を呼ぶ", () => {
|
|
89
|
+
retryMock.mockClear()
|
|
90
|
+
renderPage({ phase: "failed", error: new Error("x") })
|
|
91
|
+
expect(screen.getByText("日報データの読み込みに失敗しました。")).toBeInTheDocument()
|
|
92
|
+
fireEvent.click(screen.getByTestId("daily-report-load-error-retry"))
|
|
93
|
+
expect(retryMock).toHaveBeenCalledTimes(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("failed でも受信済みアイテムがあれば本体を描画し続ける", () => {
|
|
97
|
+
renderPage({ phase: "failed", items: twoItems, loadedCount: 2, error: new Error("x") })
|
|
98
|
+
expect(screen.getByTestId("resolved-content")).toBeInTheDocument()
|
|
99
|
+
expect(screen.queryByText("日報データの読み込みに失敗しました。")).not.toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("マウント時にセッションを ensure する (userKey = 難読化ユーザー ID)", () => {
|
|
103
|
+
ensureMock.mockClear()
|
|
104
|
+
renderPage({ phase: "streaming" })
|
|
105
|
+
expect(ensureMock).toHaveBeenCalledWith({ apiBasePath: "/daily_report/api", userKey: "ENC8" })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("auth-required はドキュメント再読込へ倒す (サーバー loader のリダイレクトに委譲)", () => {
|
|
109
|
+
reloadMock.mockClear()
|
|
110
|
+
renderPage({ phase: "auth-required" })
|
|
111
|
+
expect(reloadMock).toHaveBeenCalledTimes(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("auth-required 以外の局面ではドキュメント再読込しない", () => {
|
|
115
|
+
reloadMock.mockClear()
|
|
116
|
+
renderPage({ phase: "streaming", items: twoItems, loadedCount: 2 })
|
|
117
|
+
expect(reloadMock).not.toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
})
|