@aiquants/daily-report 0.20.0 → 0.21.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 (116) hide show
  1. package/README.md +229 -8
  2. package/dist/client.d.mts +201 -739
  3. package/dist/client.d.ts +201 -739
  4. package/dist/client.js +5 -6
  5. package/dist/client.mjs +5 -6
  6. package/dist/index.d.mts +5 -197
  7. package/dist/index.d.ts +5 -197
  8. package/dist/index.js +1 -2
  9. package/dist/index.mjs +1 -2
  10. package/dist/logger-D3krZrNK.d.mts +0 -14
  11. package/dist/logger-D3krZrNK.d.ts +0 -14
  12. package/dist/server.d.mts +114 -744
  13. package/dist/server.d.ts +114 -744
  14. package/dist/server.js +1 -2
  15. package/dist/server.mjs +1 -2
  16. package/dist/{sse-schema-ChSFm-4q.d.mts → sse-schema-DCxpdETM.d.mts} +1106 -1090
  17. package/dist/{sse-schema-BdUGThiz.d.ts → sse-schema-DoQw8_om.d.ts} +1106 -1090
  18. package/dist/types-CqgYQgbH.d.mts +0 -34
  19. package/dist/types-CqgYQgbH.d.ts +0 -34
  20. package/package.json +9 -10
  21. package/dist/client.js.map +0 -1
  22. package/dist/client.mjs.map +0 -1
  23. package/dist/index.js.map +0 -1
  24. package/dist/index.mjs.map +0 -1
  25. package/dist/server.js.map +0 -1
  26. package/dist/server.mjs.map +0 -1
  27. package/src/client/components/business-day-thumb-overlay.tsx +0 -19
  28. package/src/client/components/daily-report-attachment-indicator.spec.tsx +0 -51
  29. package/src/client/components/daily-report-attachment-indicator.tsx +0 -45
  30. package/src/client/components/daily-report-attachment-list.spec.tsx +0 -94
  31. package/src/client/components/daily-report-attachment-list.tsx +0 -112
  32. package/src/client/components/daily-report-comment-item.tsx +0 -89
  33. package/src/client/components/daily-report-comment-section.tsx +0 -166
  34. package/src/client/components/daily-report-detail-list.tsx +0 -842
  35. package/src/client/components/daily-report-edit-form.tsx +0 -87
  36. package/src/client/components/daily-report-ids-stream-status.spec.tsx +0 -93
  37. package/src/client/components/daily-report-ids-stream-status.tsx +0 -67
  38. package/src/client/components/daily-report-list.tsx +0 -1232
  39. package/src/client/components/daily-report-page.spec.tsx +0 -119
  40. package/src/client/components/daily-report-page.tsx +0 -140
  41. package/src/client/components/daily-report-resize-panels.spec.tsx +0 -307
  42. package/src/client/components/daily-report-resolved-content.tsx +0 -207
  43. package/src/client/components/daily-report-scroll-restoration.spec.tsx +0 -422
  44. package/src/client/components/report-views.spec.tsx +0 -375
  45. package/src/client/components/unread-indicator.tsx +0 -13
  46. package/src/client/config-context.tsx +0 -184
  47. package/src/client/contexts/daily-report-action-context.spec.tsx +0 -433
  48. package/src/client/contexts/daily-report-action-context.tsx +0 -1147
  49. package/src/client/contexts/daily-report-error-context.spec.tsx +0 -66
  50. package/src/client/contexts/daily-report-error-context.tsx +0 -98
  51. package/src/client/hooks/use-animated-number.spec.ts +0 -103
  52. package/src/client/hooks/use-animated-number.ts +0 -69
  53. package/src/client/hooks/use-daily-report-comments.ts +0 -73
  54. package/src/client/hooks/use-daily-report-sse-connection.ts +0 -86
  55. package/src/client/hooks/use-daily-report.spec.ts +0 -546
  56. package/src/client/hooks/use-daily-report.ts +0 -549
  57. package/src/client/hooks/use-dynamic-viewport-height.ts +0 -127
  58. package/src/client/hooks/use-responsive-layout.spec.ts +0 -74
  59. package/src/client/hooks/use-responsive-layout.ts +0 -63
  60. package/src/client/route-helpers.spec.ts +0 -100
  61. package/src/client/route-helpers.ts +0 -61
  62. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +0 -910
  63. package/src/client/streaming/daily-report-ids-stream-client.ts +0 -730
  64. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +0 -401
  65. package/src/client/streaming/daily-report-ids-stream-session.ts +0 -319
  66. package/src/client/ui/button.tsx +0 -42
  67. package/src/client/ui/cn.ts +0 -14
  68. package/src/client/ui/input.tsx +0 -21
  69. package/src/client/ui/label.tsx +0 -16
  70. package/src/client/ui/switch.tsx +0 -19
  71. package/src/client/ui/tabs.tsx +0 -40
  72. package/src/client/ui/textarea.tsx +0 -19
  73. package/src/client/utils/constants.spec.ts +0 -96
  74. package/src/client/utils/constants.ts +0 -84
  75. package/src/client.ts +0 -28
  76. package/src/index.ts +0 -11
  77. package/src/lucide-icons.d.ts +0 -6
  78. package/src/server/authz.spec.ts +0 -57
  79. package/src/server/authz.ts +0 -99
  80. package/src/server/cache.spec.ts +0 -556
  81. package/src/server/cache.ts +0 -303
  82. package/src/server/etag.ts +0 -18
  83. package/src/server/external-source.ts +0 -60
  84. package/src/server/handlers.action.spec.ts +0 -44
  85. package/src/server/handlers.attachment.spec.ts +0 -470
  86. package/src/server/handlers.ids-stream.spec.ts +0 -618
  87. package/src/server/handlers.ts +0 -1638
  88. package/src/server/handlers.unauthorized-cookie.spec.ts +0 -187
  89. package/src/server/handlers.visibility.spec.ts +0 -774
  90. package/src/server/ports.ts +0 -222
  91. package/src/server/response.ts +0 -37
  92. package/src/server/schema.spec.ts +0 -30
  93. package/src/server/schema.ts +0 -344
  94. package/src/server/service.spec.ts +0 -1130
  95. package/src/server/service.ts +0 -2045
  96. package/src/server/sse-reader.spec.ts +0 -302
  97. package/src/server/sse-reader.ts +0 -267
  98. package/src/server/test-helpers/handlers-config.ts +0 -321
  99. package/src/server/transaction-retry.spec.ts +0 -63
  100. package/src/server/visibility.spec.ts +0 -403
  101. package/src/server/visibility.ts +0 -373
  102. package/src/server.spec.ts +0 -160
  103. package/src/server.ts +0 -131
  104. package/src/shared/business-date.spec.ts +0 -82
  105. package/src/shared/business-date.ts +0 -104
  106. package/src/shared/comment-adapter.spec.ts +0 -38
  107. package/src/shared/comment-adapter.ts +0 -80
  108. package/src/shared/ids-stream.spec.ts +0 -154
  109. package/src/shared/ids-stream.ts +0 -279
  110. package/src/shared/logger.ts +0 -47
  111. package/src/shared/sse-schema.spec.ts +0 -50
  112. package/src/shared/sse-schema.ts +0 -198
  113. package/src/shared/text-utils.spec.ts +0 -42
  114. package/src/shared/text-utils.ts +0 -65
  115. package/src/shared/types.ts +0 -109
  116. package/src/styles/standalone.entry.css +0 -51
@@ -1,546 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
- import type { DailyReportDetail } from "../../shared/types"
3
-
4
- /**
5
- * Unit tests for DailyReportCache public API and getGlobal behavior.
6
- * DailyReportCache のパブリック API と getGlobal の挙動を検証するユニットテスト。
7
- */
8
-
9
- // テスト用のモック日報データを生成するヘルパー関数
10
- const makeMockReport = (overrides: Partial<DailyReportDetail> = {}): DailyReportDetail =>
11
- ({
12
- reportHubId: 1,
13
- subject: "Test Report",
14
- content: "Test Content",
15
- date: "2026-01-01",
16
- labels: [],
17
- postedComments: [],
18
- isRead: false,
19
- isStar: false,
20
- updatedAt: new Date().toISOString(),
21
- ...overrides,
22
- }) as DailyReportDetail
23
-
24
- describe("DailyReportCache public API", () => {
25
- // vitest は jsdom 環境で動作するため window オブジェクトが存在する。
26
- // vi.resetModules() + 動的 import でモジュールを再読み込みし、
27
- // getGlobal がカスタム TTL を読み込むようにする。
28
-
29
- let writeCache: typeof import("./use-daily-report").writeCache
30
- let getCachedReport: typeof import("./use-daily-report").getCachedReport
31
- let updateDailyReportCache: typeof import("./use-daily-report").updateDailyReportCache
32
- let applyDailyReportServerUpdates: typeof import("./use-daily-report").applyDailyReportServerUpdates
33
- let applyBroadcastReportUpdate: typeof import("./use-daily-report").applyBroadcastReportUpdate
34
-
35
- beforeEach(async () => {
36
- // モジュールキャッシュをリセットして、getGlobal がカスタム TTL を読み込むようにする
37
- vi.resetModules()
38
- // テスト用に短い TTL を設定
39
- ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__ = 5000
40
- ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_REVIVE_MS__ = 1000
41
-
42
- // モジュールを動的にインポート(各テストで独立したキャッシュインスタンスを使用)
43
- const mod = await import("./use-daily-report")
44
- writeCache = mod.writeCache
45
- getCachedReport = mod.getCachedReport
46
- updateDailyReportCache = mod.updateDailyReportCache
47
- applyDailyReportServerUpdates = mod.applyDailyReportServerUpdates
48
- applyBroadcastReportUpdate = mod.applyBroadcastReportUpdate
49
- })
50
-
51
- afterEach(() => {
52
- // window グローバル変数をクリーンアップ
53
- delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__
54
- delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_REVIVE_MS__
55
- })
56
-
57
- describe("writeCache (DailyReportCache.set)", () => {
58
- it("レポートを格納し、ID で取得できる", () => {
59
- const report = makeMockReport()
60
- writeCache(report, Date.now() + 5000)
61
- const cached = getCachedReport(1)
62
- expect(cached).toBeDefined()
63
- expect(cached?.reportHubId).toBe(1)
64
- expect(cached?.subject).toBe("Test Report")
65
- })
66
-
67
- it("同一 ID の set は完全置換される", () => {
68
- writeCache(makeMockReport({ subject: "Original" }), Date.now() + 5000)
69
- writeCache(makeMockReport({ subject: "Replaced", content: "New" }), Date.now() + 5000)
70
- const cached = getCachedReport(1)
71
- expect(cached?.subject).toBe("Replaced")
72
- expect(cached?.content).toBe("New")
73
- })
74
-
75
- it("期限切れエントリは null を返す", () => {
76
- writeCache(makeMockReport(), Date.now() - 1)
77
- expect(getCachedReport(1)).toBeNull()
78
- })
79
- })
80
-
81
- describe("updateDailyReportCache (DailyReportCache.update)", () => {
82
- it("既存レポートの指定フィールドのみを更新し、他は保持する", () => {
83
- writeCache(makeMockReport({ subject: "Original", content: "Original Content" }), Date.now() + 5000)
84
- updateDailyReportCache(1, { subject: "Updated Title" } as Partial<DailyReportDetail>)
85
- const cached = getCachedReport(1)
86
- expect(cached?.subject).toBe("Updated Title")
87
- expect(cached?.content).toBe("Original Content")
88
- })
89
-
90
- it("updatedAt が古い更新はスキップされる (stale guard)", () => {
91
- writeCache(makeMockReport({ subject: "Current", updatedAt: "2026-01-02T00:00:00Z" }), Date.now() + 5000)
92
- updateDailyReportCache(1, {
93
- subject: "Stale",
94
- updatedAt: "2026-01-01T00:00:00Z",
95
- } as Partial<DailyReportDetail>)
96
- expect(getCachedReport(1)?.subject).toBe("Current")
97
- })
98
-
99
- it("未キャッシュの ID を更新しても何も起きない", () => {
100
- updateDailyReportCache(999, { subject: "Ghost" } as Partial<DailyReportDetail>)
101
- expect(getCachedReport(999)).toBeNull()
102
- })
103
- })
104
-
105
- describe("applyDailyReportServerUpdates (信頼経路: 自分の HTTP 応答・自分宛 status-update)", () => {
106
- it("キャッシュ済みレポートを完全置換する (per-viewer 値も配信値を採用)", () => {
107
- // 信頼経路の値は受信者自身の状態なので、そのまま書いてよい
108
- writeCache(makeMockReport({ subject: "Old", content: "Old" }), Date.now() + 1000)
109
- applyDailyReportServerUpdates(1, makeMockReport({ subject: "Fresh", content: "Fresh" }))
110
- const cached = getCachedReport(1)
111
- expect(cached?.subject).toBe("Fresh")
112
- expect(cached?.content).toBe("Fresh")
113
- })
114
-
115
- it("未キャッシュの ID でも新規エントリとして格納される", () => {
116
- applyDailyReportServerUpdates(42, makeMockReport({ reportHubId: 42, subject: "New" }))
117
- expect(getCachedReport(42)?.subject).toBe("New")
118
- })
119
- })
120
-
121
- describe("applyBroadcastReportUpdate (放送経路: 全ユーザー宛 SSE)", () => {
122
- it("共有フィールドは配信値で更新し、per-viewer (isRead/isStarred) は受信者の値を保持する", () => {
123
- // SSE の report が運ぶ isRead/isStarred は送信者 (操作者) の状態であり、
124
- // 受信者のキャッシュへそのまま書くと他人の既読/スター状態で上書きされる
125
- writeCache(makeMockReport({ subject: "Old", content: "Old", isRead: true, isStarred: true }), Date.now() + 1000)
126
- applyBroadcastReportUpdate(makeMockReport({ subject: "Fresh", content: "Fresh", isRead: false, isStarred: false }))
127
- const cached = getCachedReport(1)
128
- expect(cached?.subject).toBe("Fresh")
129
- expect(cached?.content).toBe("Fresh")
130
- // 受信者自身の per-viewer 状態が生き残る
131
- expect(cached?.isRead).toBe(true)
132
- expect(cached?.isStarred).toBe(true)
133
- })
134
-
135
- it("postedComments[].isMine は per-viewer: キャッシュ済みは保持、送信者由来の新規は false 化する", () => {
136
- // isMine はサーバーが送信者視点で計算するため、そのまま取り込むと受信者に
137
- // 他人のコメントが「自分の」削除ボタン付きで見え、自分のコメントは isMine を失う
138
- writeCache(makeMockReport({ postedComments: [{ id: 1, userName: "自分", content: "私の", createdAt: "2026-01-01 00:00:00", userId: "ENC8", isMine: true }] }) as never, Date.now() + 1000)
139
- applyBroadcastReportUpdate(
140
- makeMockReport({
141
- postedComments: [
142
- { id: 1, userName: "自分", content: "私の", createdAt: "2026-01-01 00:00:00", userId: "ENC8", isMine: false },
143
- { id: 2, userName: "他人", content: "他人の", createdAt: "2026-01-01 00:01:00", userId: "ENC9", isMine: true },
144
- ],
145
- }) as never,
146
- )
147
- const cached = getCachedReport(1)
148
- // 受信者自身のコメント (id=1) の isMine は保持、送信者が「自分の」とした id=2 は false 化
149
- expect(cached?.postedComments.find((c) => c.id === 1)?.isMine).toBe(true)
150
- expect(cached?.postedComments.find((c) => c.id === 2)?.isMine).toBe(false)
151
- })
152
-
153
- it("未キャッシュの ID も中立化して種まきする (行が stale 安全弁で消えるのを防ぐ)", () => {
154
- // 未キャッシュを書かないと、鮮度窓内のリストキャッシュに新 id が無く、行が
155
- // report=null に解決してトゥームストーン化され、作成/公開された日報が消える
156
- applyBroadcastReportUpdate(makeMockReport({ reportHubId: 42, subject: "New", isRead: true, isStarred: true, postedComments: [{ id: 9, userName: "他人", content: "x", createdAt: "2026-01-01 00:00:00", userId: "ENC9", isMine: true }] }) as never)
157
- const cached = getCachedReport(42)
158
- expect(cached?.subject).toBe("New")
159
- // per-viewer は中立値で入る (送信者の true を持ち込まない)
160
- expect(cached?.isRead).toBe(false)
161
- expect(cached?.isStarred).toBe(false)
162
- expect(cached?.postedComments[0]?.isMine).toBe(false)
163
- })
164
- })
165
- })
166
-
167
- describe("getGlobal behavior (window グローバル変数の読み取り)", () => {
168
- afterEach(() => {
169
- delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__
170
- delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_REVIVE_MS__
171
- })
172
-
173
- it("window に正の数値が設定されていれば、その値が使われる", async () => {
174
- vi.resetModules()
175
- ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__ = 7777
176
-
177
- const mod = await import("./use-daily-report")
178
- const report = makeMockReport()
179
- mod.writeCache(report, Date.now() + 7777)
180
- expect(mod.getCachedReport(1)).toBeDefined()
181
- })
182
-
183
- it("window が undefined ならデフォルト値 (300000ms) が使われる", async () => {
184
- vi.resetModules()
185
- delete (window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__
186
-
187
- const mod = await import("./use-daily-report")
188
- const report = makeMockReport()
189
- mod.writeCache(report, Date.now() + 300_000)
190
- expect(mod.getCachedReport(1)).toBeDefined()
191
- })
192
-
193
- it("window に 0 が設定されていれば 0 が使われる (|| バグの修正確認)", async () => {
194
- vi.resetModules()
195
- ;(window as unknown as Record<string, unknown>).__DAILY_REPORT_CACHE_TTL__ = 0
196
-
197
- const mod = await import("./use-daily-report")
198
- const report = makeMockReport()
199
- // TTL=0ms → expiresAt = Date.now() + 0 → 即時期限切れ → getCachedReport は null
200
- mod.writeCache(report, Date.now() + 0)
201
- expect(mod.getCachedReport(1)).toBeNull()
202
- })
203
- })
204
-
205
- describe("useDailyReportDetail の初回フェッチ (デバウンス適用範囲)", () => {
206
- let mod: typeof import("./use-daily-report")
207
-
208
- beforeEach(async () => {
209
- vi.resetModules()
210
- mod = await import("./use-daily-report")
211
- vi.useFakeTimers()
212
- })
213
-
214
- afterEach(() => {
215
- vi.useRealTimers()
216
- vi.unstubAllGlobals()
217
- })
218
-
219
- /** business-date 応答を 1 件返す fetch スタブ。呼び出し回数を記録する。 */
220
- const stubFetch = () => {
221
- const calls: string[] = []
222
- vi.stubGlobal(
223
- "fetch",
224
- vi.fn(async (input: RequestInfo | URL) => {
225
- calls.push(String(input))
226
- return new Response(JSON.stringify({ reports: [makeMockReport({ reportHubId: 7, date: "2026-01-01" })] }), { status: 200, headers: { "Content-Type": "application/json" } })
227
- }),
228
- )
229
- return calls
230
- }
231
-
232
- it("未キャッシュの初回は即フェッチする (120ms のデバウンスを掛けない)", async () => {
233
- const { renderHook, act } = await import("@testing-library/react")
234
- const calls = stubFetch()
235
- renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
236
- // タイマーを進めずマイクロタスクだけ流す → 即時 (0ms) 発火の検証
237
- await act(async () => {
238
- await vi.advanceTimersByTimeAsync(0)
239
- })
240
- expect(calls.length).toBe(1)
241
- expect(calls[0]).toContain("business-date?businessDate=2026-01-01")
242
- })
243
-
244
- it("キャッシュ済み (stale) の再取得はデバウンスされる", async () => {
245
- const { renderHook, act } = await import("@testing-library/react")
246
- const calls = stubFetch()
247
- // 期限切れのキャッシュを用意 → 表示は即、再取得はデバウンス対象
248
- mod.writeCache(makeMockReport({ reportHubId: 7, date: "2026-01-01" }), Date.now() - 1)
249
- renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
250
- await act(async () => {
251
- await vi.advanceTimersByTimeAsync(0)
252
- })
253
- // 0ms 時点では発火しない (スクロール churn 吸収のためのデバウンス)
254
- expect(calls.length).toBe(0)
255
- await act(async () => {
256
- await vi.advanceTimersByTimeAsync(120)
257
- })
258
- expect(calls.length).toBe(1)
259
- })
260
- })
261
-
262
- describe("不在確認 (stale 安全弁の盲目性対策)", () => {
263
- // ❗ 一覧キャッシュ (既定 5 分) に無いことは「サーバーに無い」の証拠にならない。
264
- // キャッシュ構築後に作成/公開された行はここで必ず取りこぼし、`report=null` が
265
- // そのまま stale 安全弁の発火条件になる。安全弁はトゥームストーンを刻み常駐
266
- // ストリームからも行を落とすため、確認を欠くと**生きている行が削除される**。
267
- let mod: typeof import("./use-daily-report")
268
-
269
- beforeEach(async () => {
270
- vi.resetModules()
271
- mod = await import("./use-daily-report")
272
- vi.useFakeTimers()
273
- })
274
-
275
- afterEach(() => {
276
- vi.useRealTimers()
277
- vi.unstubAllGlobals()
278
- })
279
-
280
- /**
281
- * 1 回目は行 1 のみ、2 回目以降は行 1 と行 7 を返す business-date スタブ。
282
- * ❗ 1 回目を空配列にしてはならない。空の一覧はキャッシュされず、後続がキャッシュ
283
- * ヒット経路に入らないため「不在確認」の分岐自体を踏めない (実測で判明)。
284
- */
285
- const stubLateArrival = () => {
286
- const calls: string[] = []
287
- vi.stubGlobal(
288
- "fetch",
289
- vi.fn(async (input: RequestInfo | URL) => {
290
- calls.push(String(input))
291
- const reports = calls.length === 1 ? [makeMockReport({ reportHubId: 1, date: "2026-01-01" })] : [makeMockReport({ reportHubId: 1, date: "2026-01-01" }), makeMockReport({ reportHubId: 7, date: "2026-01-01" })]
292
- return new Response(JSON.stringify({ reports }), { status: 200, headers: { "Content-Type": "application/json" } })
293
- }),
294
- )
295
- return calls
296
- }
297
-
298
- it("キャッシュ済み一覧に無い行は、確定前にサーバーへ確認しに行く", async () => {
299
- const { renderHook, act } = await import("@testing-library/react")
300
- const calls = stubLateArrival()
301
-
302
- // 1 回目: 一覧を取得してキャッシュさせる (行は存在しない)
303
- const first = renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
304
- await act(async () => {
305
- await vi.advanceTimersByTimeAsync(200)
306
- })
307
- expect(calls.length).toBe(1)
308
- first.unmount()
309
-
310
- // 2 回目: 一覧はキャッシュヒットするが行は無い → 不在確認が走り、行が見つかる
311
- const second = renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
312
- await act(async () => {
313
- await vi.advanceTimersByTimeAsync(200)
314
- })
315
- expect(calls.length).toBe(2)
316
- expect(second.result.current.report?.reportHubId).toBe(7)
317
- })
318
-
319
- it("不在確認は営業日ごとに間引かれる (1 画面分の行が一斉に撃たない)", async () => {
320
- const { renderHook, act } = await import("@testing-library/react")
321
- const calls = stubLateArrival()
322
-
323
- const warm = renderHook(() => mod.useDailyReportDetail(999, "2026-01-01"))
324
- await act(async () => {
325
- await vi.advanceTimersByTimeAsync(200)
326
- })
327
- warm.unmount()
328
- const afterWarm = calls.length
329
-
330
- // 同じ営業日の別の行が続けて不在確認を要求しても、追加のフェッチは 1 回だけ
331
- renderHook(() => mod.useDailyReportDetail(998, "2026-01-01"))
332
- await act(async () => {
333
- await vi.advanceTimersByTimeAsync(200)
334
- })
335
- renderHook(() => mod.useDailyReportDetail(997, "2026-01-01"))
336
- await act(async () => {
337
- await vi.advanceTimersByTimeAsync(200)
338
- })
339
- expect(calls.length).toBe(afterWarm + 1)
340
- })
341
- })
342
-
343
- describe("空一覧のキャッシュ (cacheMany の早期 return 除去)", () => {
344
- let mod: typeof import("./use-daily-report")
345
-
346
- beforeEach(async () => {
347
- vi.resetModules()
348
- mod = await import("./use-daily-report")
349
- vi.useFakeTimers()
350
- })
351
-
352
- afterEach(() => {
353
- vi.useRealTimers()
354
- vi.unstubAllGlobals()
355
- })
356
-
357
- /** 常に空の business-date 応答を返す fetch スタブ。呼び出し回数を記録する。 */
358
- const stubEmpty = () => {
359
- const calls: string[] = []
360
- vi.stubGlobal(
361
- "fetch",
362
- vi.fn(async (input: RequestInfo | URL) => {
363
- calls.push(String(input))
364
- return new Response(JSON.stringify({ reports: [] }), { status: 200, headers: { "Content-Type": "application/json" } })
365
- }),
366
- )
367
- return calls
368
- }
369
-
370
- it("0 件の営業日も一覧としてキャッシュされ、2 回目はフェッチしない", async () => {
371
- // ❗ 早期 return が lists.set ごと飛ばしていた実装事故の回帰ガード。
372
- // 「その営業日には 1 件も無い」は正当な結果であり、キャッシュしないと毎回フェッチが走る
373
- const { renderHook, act } = await import("@testing-library/react")
374
- const calls = stubEmpty()
375
-
376
- const first = renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
377
- await act(async () => {
378
- await vi.advanceTimersByTimeAsync(200)
379
- })
380
- first.unmount()
381
- const afterFirst = calls.length
382
- expect(afterFirst).toBeGreaterThan(0)
383
-
384
- // ❗ キャッシュされたことを直接観測する (フェッチ回数だけだと、早期 return 復活時の
385
- // 「未キャッシュ → 素の再フェッチ」も同じ回数になり、ミューテーションを見逃す)
386
- expect(mod.DailyReportCache.getList("2026-01-01")).not.toBeNull()
387
-
388
- renderHook(() => mod.useDailyReportDetail(8, "2026-01-01"))
389
- await act(async () => {
390
- await vi.advanceTimersByTimeAsync(200)
391
- })
392
- // 2 回目の hook は「キャッシュ済みの空一覧 → 不在確認 1 回」だけ。素通しの再フェッチは無い
393
- expect(calls.length).toBe(afterFirst + 1)
394
- })
395
-
396
- it("SSE 放送の種まきが空キャッシュ日の一覧へ行を差し込める", async () => {
397
- // T5 で新しく可能になる改善面。従来はリスト不存在で `if (listEntry)` が false になり、
398
- // 空の日への放送種まきは常に取りこぼされていた
399
- const { renderHook, act } = await import("@testing-library/react")
400
- stubEmpty()
401
-
402
- const warm = renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
403
- await act(async () => {
404
- await vi.advanceTimersByTimeAsync(200)
405
- })
406
- warm.unmount()
407
-
408
- // 放送到着 (別ユーザーが 2026-01-01 に日報を作成)
409
- mod.applyBroadcastReportUpdate(makeMockReport({ reportHubId: 55, date: "2026-01-01" }))
410
-
411
- expect(mod.DailyReportCache.getList("2026-01-01")?.some((r) => r.reportHubId === 55)).toBe(true)
412
- })
413
- })
414
-
415
- describe("不在確認の in-flight 合流", () => {
416
- let mod: typeof import("./use-daily-report")
417
-
418
- beforeEach(async () => {
419
- vi.resetModules()
420
- mod = await import("./use-daily-report")
421
- vi.useFakeTimers()
422
- })
423
-
424
- afterEach(() => {
425
- vi.useRealTimers()
426
- vi.unstubAllGlobals()
427
- })
428
-
429
- it("スロットル窓内でも進行中の確認があれば合流し、その結果で行を解決する", async () => {
430
- // ❗ タイムスタンプだけの間引きだと、1 本目の確認が消費した窓の中で
431
- // 2 本目の行が「確認不能 → null → stale 安全弁」へ落ちる
432
- const { renderHook, act } = await import("@testing-library/react")
433
- const calls: string[] = []
434
- // コールバック内でのみ代入するため、let だと TS が null へ絞り込んで呼び出せなくなる。
435
- // 可変スロットをオブジェクトに持たせて制御フロー解析の絞り込みを避ける
436
- const releaseForced: { fn: (() => void) | null } = { fn: null }
437
- vi.stubGlobal(
438
- "fetch",
439
- vi.fn(async (input: RequestInfo | URL) => {
440
- calls.push(String(input))
441
- if (calls.length === 1) {
442
- // 1 回目: 空一覧をキャッシュさせる
443
- return new Response(JSON.stringify({ reports: [] }), { status: 200, headers: { "Content-Type": "application/json" } })
444
- }
445
- // 2 回目 (= 不在確認の強制再取得): 解放されるまで保留し、両行を含む一覧を返す
446
- await new Promise<void>((resolve) => {
447
- releaseForced.fn = resolve
448
- })
449
- return new Response(JSON.stringify({ reports: [makeMockReport({ reportHubId: 7, date: "2026-01-01" }), makeMockReport({ reportHubId: 8, date: "2026-01-01" })] }), {
450
- status: 200,
451
- headers: { "Content-Type": "application/json" },
452
- })
453
- }),
454
- )
455
-
456
- // 一覧を空でキャッシュ
457
- const warm = renderHook(() => mod.useDailyReportDetail(999, "2026-01-01"))
458
- await act(async () => {
459
- await vi.advanceTimersByTimeAsync(200)
460
- })
461
- warm.unmount()
462
-
463
- // 行 7 と行 8 がほぼ同時に不在確認へ入る (確認は 1 本、2 本目は合流)
464
- const hook7 = renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
465
- const hook8 = renderHook(() => mod.useDailyReportDetail(8, "2026-01-01"))
466
- await act(async () => {
467
- await vi.advanceTimersByTimeAsync(200)
468
- })
469
- // ❗ 確認の解決前に、合流した行が「null 確定・非ロード中」で露出しないことを表明する。
470
- // これが stale 安全弁の発火条件そのものであり、合流の存在意義。最終状態は
471
- // キャッシュ通知 (cacheMany → notify) で合流が無くても収束してしまうため、
472
- // 過渡状態を見ないとこのテストはミューテーションを検知できない (実測済み)
473
- const valveEligible = (r: { report: unknown; isLoading: boolean; isRefetching: boolean; error: unknown }) => !(r.report || r.isLoading || r.isRefetching || r.error)
474
- expect(valveEligible(hook8.result.current)).toBe(false)
475
-
476
- releaseForced.fn?.()
477
- await act(async () => {
478
- await vi.advanceTimersByTimeAsync(0)
479
- })
480
-
481
- // フェッチは計 2 本 (初回 + 確認 1 本) のまま、両方の行が解決される
482
- expect(calls.length).toBe(2)
483
- expect(hook7.result.current.report?.reportHubId).toBe(7)
484
- expect(hook8.result.current.report?.reportHubId).toBe(8)
485
- })
486
- })
487
-
488
- describe("不在確認の失敗経路", () => {
489
- let mod: typeof import("./use-daily-report")
490
-
491
- beforeEach(async () => {
492
- vi.resetModules()
493
- mod = await import("./use-daily-report")
494
- vi.useFakeTimers()
495
- })
496
-
497
- afterEach(() => {
498
- vi.useRealTimers()
499
- vi.unstubAllGlobals()
500
- })
501
-
502
- it("強制再取得の失敗は error へ落ち、未処理拒否を漏らさない", async () => {
503
- // ❗ .finally() が返す派生 Promise は元の理由で再拒否する。派生鎖に catch が無いと、
504
- // フェッチ失敗のたびにブラウザへ Unhandled promise rejection が漏れる
505
- const rejections: unknown[] = []
506
- const onRejection = (reason: unknown) => {
507
- rejections.push(reason)
508
- }
509
- process.on("unhandledRejection", onRejection)
510
- try {
511
- const { renderHook, act } = await import("@testing-library/react")
512
- const calls: string[] = []
513
- vi.stubGlobal(
514
- "fetch",
515
- vi.fn(async (input: RequestInfo | URL) => {
516
- calls.push(String(input))
517
- if (calls.length === 1) {
518
- return new Response(JSON.stringify({ reports: [makeMockReport({ reportHubId: 1, date: "2026-01-01" })] }), { status: 200, headers: { "Content-Type": "application/json" } })
519
- }
520
- // 不在確認 (強制再取得) は失敗する
521
- return new Response("down", { status: 500 })
522
- }),
523
- )
524
-
525
- const warm = renderHook(() => mod.useDailyReportDetail(999, "2026-01-01"))
526
- await act(async () => {
527
- await vi.advanceTimersByTimeAsync(200)
528
- })
529
- warm.unmount()
530
-
531
- const hook = renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
532
- await act(async () => {
533
- await vi.advanceTimersByTimeAsync(200)
534
- })
535
- // マイクロタスクを流し切って派生鎖の拒否を露出させる
536
- await act(async () => {
537
- await vi.advanceTimersByTimeAsync(10)
538
- })
539
-
540
- expect(hook.result.current.error).not.toBeNull()
541
- expect(rejections).toEqual([])
542
- } finally {
543
- process.off("unhandledRejection", onRejection)
544
- }
545
- })
546
- })