@aiquants/daily-report 0.10.0 → 0.12.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/dist/client.d.mts +40 -8
- package/dist/client.d.ts +40 -8
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/package.json +4 -4
- package/src/client/components/daily-report-detail-list.tsx +108 -17
- package/src/client/components/daily-report-list.tsx +36 -30
- package/src/client/components/daily-report-resolved-content.tsx +10 -27
- package/src/client/components/daily-report-scroll-restoration.spec.tsx +422 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll position restoration guards for the tab-unmounted list views.
|
|
3
|
+
* タブアンマウントをまたぐスクロール位置復元のガード。
|
|
4
|
+
*
|
|
5
|
+
* 実際に起きた欠陥 (両方とも本番ビルド + 実機ブラウザで再現・計測済み):
|
|
6
|
+
* 1. 初期表示が 6px (ITEM_VERTICAL_PADDING) スクロールされて見える —
|
|
7
|
+
* 親が ref の「値」をレンダー時に読んで props へ焼き込み、`initialScrollOffset: 0` (数値) が
|
|
8
|
+
* 旧 virtualscroll の `toPanePositionWithInset(0, top) = top` に落ちてペイン 6px で開始した。
|
|
9
|
+
* 2. タブ往復でスクロール位置がリセットされる (実測 3206 → 6) — タブは非制御で親は
|
|
10
|
+
* タブ切替時に再レンダーされないため、復帰マウントには memo 計算時点の古い焼き込み値が渡った。
|
|
11
|
+
*
|
|
12
|
+
* 対策の契約 (ここで固定する):
|
|
13
|
+
* - 親は ref **オブジェクト**を渡し、子が**マウント時に一度だけ**値を読む (レンダー時読取の排除)。
|
|
14
|
+
* - 保存座標は onScroll が配信する**論理座標** (getScrollPosition のペイン座標ではない)。
|
|
15
|
+
* - DetailList はアンカー (記録時の選択日報) が一致するときだけ復元し、
|
|
16
|
+
* 復元マウントでは初回の「選択日報へのスクロール」を 1 回だけスキップする。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { act, fireEvent, render, screen } from "@testing-library/react"
|
|
20
|
+
import type { ReactNode } from "react"
|
|
21
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
22
|
+
import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
|
|
23
|
+
|
|
24
|
+
/** `VirtualScroll` 代役が記録する props (マウント/再レンダーごとに 1 件)。 */
|
|
25
|
+
type CapturedProps = {
|
|
26
|
+
testId?: string
|
|
27
|
+
initialScrollOffset?: number
|
|
28
|
+
initialScrollAnchor?: { index: number; offsetPx?: number }
|
|
29
|
+
onScroll?: (position: number, totalHeight: number) => void
|
|
30
|
+
getItemHeight?: (index: number) => number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const captured: CapturedProps[] = []
|
|
34
|
+
/** 代役の命令ハンドル経由で観測した scrollToIndex 呼び出し。 */
|
|
35
|
+
const scrollToIndexCalls: Array<{ testId?: string; index: number }> = []
|
|
36
|
+
/** 代役の getScrollAnchor が返す値 (テストごとに差し替える)。 */
|
|
37
|
+
let scriptedAnchor: { index: number; offsetPx: number } | null = { index: 0, offsetPx: 0 }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stand-in for VirtualScroll that renders every item and records scroll wiring.
|
|
41
|
+
* スクロール結線を記録しつつ全アイテムを描画する `VirtualScroll` の代役。
|
|
42
|
+
*/
|
|
43
|
+
vi.mock("@aiquants/virtualscroll", async () => {
|
|
44
|
+
const { forwardRef: fwd, useImperativeHandle: useHandle } = await import("react")
|
|
45
|
+
return {
|
|
46
|
+
tapScrollCircleSampleVisual: () => null,
|
|
47
|
+
VirtualScroll: fwd(
|
|
48
|
+
(
|
|
49
|
+
props: {
|
|
50
|
+
itemCount: number
|
|
51
|
+
getItem: (index: number) => unknown
|
|
52
|
+
children: (item: unknown, index: number) => ReactNode
|
|
53
|
+
contentProps?: Record<string, unknown>
|
|
54
|
+
} & CapturedProps,
|
|
55
|
+
ref: React.Ref<unknown>,
|
|
56
|
+
) => {
|
|
57
|
+
captured.push({
|
|
58
|
+
testId: props.testId,
|
|
59
|
+
initialScrollOffset: props.initialScrollOffset,
|
|
60
|
+
initialScrollAnchor: (props as CapturedProps).initialScrollAnchor,
|
|
61
|
+
onScroll: props.onScroll,
|
|
62
|
+
getItemHeight: (props as { getItemHeight?: (index: number) => number }).getItemHeight,
|
|
63
|
+
})
|
|
64
|
+
useHandle(ref, () => ({
|
|
65
|
+
scrollTo: () => 0,
|
|
66
|
+
scrollToIndex: (index: number) => {
|
|
67
|
+
scrollToIndexCalls.push({ testId: props.testId, index })
|
|
68
|
+
},
|
|
69
|
+
// テストごとに scriptedAnchor で差し替える (handleScroll の保存経路の検証用)
|
|
70
|
+
getScrollAnchor: () => scriptedAnchor,
|
|
71
|
+
getScrollPosition: () => 0,
|
|
72
|
+
getContentSize: () => 0,
|
|
73
|
+
getViewportSize: () => 0,
|
|
74
|
+
updateItemSize: () => {},
|
|
75
|
+
focusItemAtIndex: () => {},
|
|
76
|
+
}))
|
|
77
|
+
return (
|
|
78
|
+
<div data-testid={props.testId} {...props.contentProps}>
|
|
79
|
+
{Array.from({ length: props.itemCount }, (_, index) => (
|
|
80
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: 代役なので index キーで十分
|
|
81
|
+
<div key={index}>{props.children(props.getItem(index), index)}</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** 統合テストでアクションコンテキストが返すアイテム (テストごとに差し替える)。 */
|
|
91
|
+
const contextItemsRef: { current: DailyReportItem[] } = { current: [] }
|
|
92
|
+
|
|
93
|
+
/** 日報アクションはネットワーク/ルーターに依存するため、呼び出しを受け止めるだけの代役にする。 */
|
|
94
|
+
vi.mock("../contexts/daily-report-action-context", async (importOriginal) => {
|
|
95
|
+
const actual = await importOriginal<typeof import("../contexts/daily-report-action-context")>()
|
|
96
|
+
const noop = async () => {}
|
|
97
|
+
return {
|
|
98
|
+
...actual,
|
|
99
|
+
useDailyReportActionContext: () => ({
|
|
100
|
+
pendingAddCommentIds: { current: new Map() },
|
|
101
|
+
pendingDeleteCommentIds: { current: new Map() },
|
|
102
|
+
pendingReadUpdates: { current: new Map() },
|
|
103
|
+
pendingStarUpdates: { current: new Map() },
|
|
104
|
+
resolvedIdMap: { current: new Map() },
|
|
105
|
+
version: 0,
|
|
106
|
+
items: contextItemsRef.current,
|
|
107
|
+
user: null,
|
|
108
|
+
isSseEnabled: false,
|
|
109
|
+
toggleSse: () => {},
|
|
110
|
+
addComment: noop,
|
|
111
|
+
deleteComment: noop,
|
|
112
|
+
toggleStar: noop,
|
|
113
|
+
toggleRead: noop,
|
|
114
|
+
createReport: async () => 0,
|
|
115
|
+
updateReport: noop,
|
|
116
|
+
publishReport: noop,
|
|
117
|
+
deleteReport: noop,
|
|
118
|
+
removeStaleItem: () => {},
|
|
119
|
+
refetchData: noop,
|
|
120
|
+
clearCacheAndRefetch: noop,
|
|
121
|
+
}),
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** 詳細取得はキャッシュ層ごと差し替え、常に用意した 1 件を返す。 */
|
|
126
|
+
vi.mock("../hooks/use-daily-report", async (importOriginal) => {
|
|
127
|
+
const actual = await importOriginal<typeof import("../hooks/use-daily-report")>()
|
|
128
|
+
return {
|
|
129
|
+
...actual,
|
|
130
|
+
useDailyReportDetail: () => ({ report: buildDetail(), error: null, isLoading: false, isRefetching: false }),
|
|
131
|
+
useDailyReportPrefetch: () => () => {},
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/** resolved-content が使う useSearchParams をルーター無しで満たす。 */
|
|
136
|
+
vi.mock("react-router", async (importOriginal) => {
|
|
137
|
+
const actual = await importOriginal<typeof import("react-router")>()
|
|
138
|
+
return { ...actual, useSearchParams: () => [new URLSearchParams(), () => {}] as const }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const { DailyReportList } = await import("./daily-report-list")
|
|
142
|
+
const { DailyReportDetailList } = await import("./daily-report-detail-list")
|
|
143
|
+
const { DailyReportResolvedContent } = await import("./daily-report-resolved-content")
|
|
144
|
+
const { DailyReportConfigProvider } = await import("../config-context")
|
|
145
|
+
|
|
146
|
+
const buildItem = (reportHubId: number): DailyReportItem => ({ reportHubId, businessDate: "2026-07-31" })
|
|
147
|
+
|
|
148
|
+
const buildDetail = (): DailyReportDetail => ({
|
|
149
|
+
reportHubId: 1,
|
|
150
|
+
date: "2026-07-31",
|
|
151
|
+
interviewers: [],
|
|
152
|
+
comments: [],
|
|
153
|
+
isRead: false,
|
|
154
|
+
isStarred: false,
|
|
155
|
+
labels: [],
|
|
156
|
+
commentItems: [],
|
|
157
|
+
attachments: [],
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/** 設定コンテキストを与えて描画する。 */
|
|
161
|
+
const renderView = (node: ReactNode) =>
|
|
162
|
+
render(
|
|
163
|
+
<DailyReportConfigProvider
|
|
164
|
+
config={{
|
|
165
|
+
apiBasePath: "/daily_report/api",
|
|
166
|
+
fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" },
|
|
167
|
+
}}>
|
|
168
|
+
{node}
|
|
169
|
+
</DailyReportConfigProvider>,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
/** 指定 testId の最新キャプチャ (再レンダーで積み直されるため末尾を返す)。 */
|
|
173
|
+
const latestOf = (testId: string) => {
|
|
174
|
+
const props = [...captured].reverse().find((entry) => entry.testId === testId)
|
|
175
|
+
if (!props) throw new Error(`VirtualScroll props for ${testId} were not captured`)
|
|
176
|
+
return props
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 指定 testId の「マウント初回」キャプチャ列 (initialScrollOffset の変遷検査用)。 */
|
|
180
|
+
const allOf = (testId: string) => captured.filter((entry) => entry.testId === testId)
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
captured.length = 0
|
|
184
|
+
scrollToIndexCalls.length = 0
|
|
185
|
+
scriptedAnchor = { index: 0, offsetPx: 0 }
|
|
186
|
+
contextItemsRef.current = []
|
|
187
|
+
vi.stubGlobal(
|
|
188
|
+
"ResizeObserver",
|
|
189
|
+
class {
|
|
190
|
+
observe() {}
|
|
191
|
+
unobserve() {}
|
|
192
|
+
disconnect() {}
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
vi.stubGlobal(
|
|
196
|
+
"matchMedia",
|
|
197
|
+
vi.fn((query: string) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() })),
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe("DailyReportList のスクロール共有スロット", () => {
|
|
202
|
+
it("マウント時に一度だけ ref を読み、その後の再レンダーで値を読み直さない (焼き込み排除)", () => {
|
|
203
|
+
const items = [buildItem(1), buildItem(2)]
|
|
204
|
+
const scrollOffsetRef = { current: 1234 }
|
|
205
|
+
const view = renderView(<DailyReportList dailyReportItems={items} selectedReportHubId={1} onSelectItem={() => {}} scrollOffsetRef={scrollOffsetRef} />)
|
|
206
|
+
expect(latestOf("daily-report-list").initialScrollOffset).toBe(1234)
|
|
207
|
+
|
|
208
|
+
// 外部で ref が動いても、同一マウント中の initialScrollOffset は変化しない
|
|
209
|
+
scrollOffsetRef.current = 9999
|
|
210
|
+
view.rerender(
|
|
211
|
+
<DailyReportConfigProvider config={{ apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }}>
|
|
212
|
+
<DailyReportList dailyReportItems={items} selectedReportHubId={2} onSelectItem={() => {}} scrollOffsetRef={scrollOffsetRef} />
|
|
213
|
+
</DailyReportConfigProvider>,
|
|
214
|
+
)
|
|
215
|
+
expect(latestOf("daily-report-list").initialScrollOffset).toBe(1234)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("onScroll が配信する論理座標をそのまま ref へ書き戻す", () => {
|
|
219
|
+
const scrollOffsetRef = { current: 0 }
|
|
220
|
+
renderView(<DailyReportList dailyReportItems={[buildItem(1)]} selectedReportHubId={null} onSelectItem={() => {}} scrollOffsetRef={scrollOffsetRef} />)
|
|
221
|
+
act(() => latestOf("daily-report-list").onScroll?.(3206, 100000))
|
|
222
|
+
expect(scrollOffsetRef.current).toBe(3206)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("ref 未指定でも 0 で開始し例外にならない (スロットは opt-in)", () => {
|
|
226
|
+
renderView(<DailyReportList dailyReportItems={[buildItem(1)]} selectedReportHubId={null} onSelectItem={() => {}} />)
|
|
227
|
+
expect(latestOf("daily-report-list").initialScrollOffset).toBe(0)
|
|
228
|
+
act(() => latestOf("daily-report-list").onScroll?.(10, 100000))
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe("DailyReportDetailList の同一性アンカー復元", () => {
|
|
233
|
+
const cfg = { apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }
|
|
234
|
+
const slot = (over: Partial<{ topItem: { reportHubId: number; index: number; offsetPx: number } | null; anchorReportHubId: number | null }> = {}) => ({
|
|
235
|
+
current: { topItem: null as { reportHubId: number; index: number; offsetPx: number } | null, anchorReportHubId: null as number | null, measuredHeights: new Map<number, number>(), ...over },
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("選択アンカー一致なら topItem を同一性で再解決して復元し、初回の選択スクロールはスキップする", () => {
|
|
239
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 137 }, anchorReportHubId: 42 })
|
|
240
|
+
renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
241
|
+
expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 0, offsetPx: 137 })
|
|
242
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("留守中に一覧が並び替わっても、topItem は reportHubId の同一性で新しい index へ再解決される", () => {
|
|
246
|
+
// 保存時: カード 41 が index 0。復帰時: 先頭に 43 が挿入され 41 は index 1 へ移動
|
|
247
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 137 }, anchorReportHubId: 42 })
|
|
248
|
+
renderView(<DailyReportDetailList dailyReportItems={[buildItem(43), buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
249
|
+
expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 1, offsetPx: 137 })
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("topItem のカードが留守中に消えていたら、保存時 index への近似フォールバック (行内オフセットは破棄)", () => {
|
|
253
|
+
const restoreRef = slot({ topItem: { reportHubId: 99, index: 1, offsetPx: 137 }, anchorReportHubId: 42 })
|
|
254
|
+
renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
255
|
+
expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 1, offsetPx: 0 })
|
|
256
|
+
// 復元マウント扱いなので初回の選択スクロールはスキップされる
|
|
257
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("先頭 (topItem が行 0 の offsetPx 0) の復元でも復元マウント扱いで、選択スクロールが先頭を上書きしない", () => {
|
|
261
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 0 }, anchorReportHubId: 42 })
|
|
262
|
+
renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
263
|
+
expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 0, offsetPx: 0 })
|
|
264
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("選択アンカー不一致なら復元せず、従来どおり選択日報へスクロールする", () => {
|
|
268
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 41 })
|
|
269
|
+
renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
270
|
+
expect(latestOf("daily-report-detail-list").initialScrollAnchor).toBeUndefined()
|
|
271
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 1 }])
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("消えたアンカー選択の復元でも、次の正当な選択変更のスクロールを飲み込まない", () => {
|
|
275
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
|
|
276
|
+
const items = [buildItem(41), buildItem(43)] // 選択 42 は削除済み
|
|
277
|
+
const view = renderView(<DailyReportDetailList dailyReportItems={items} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
278
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
|
|
279
|
+
|
|
280
|
+
view.rerender(
|
|
281
|
+
<DailyReportConfigProvider config={cfg}>
|
|
282
|
+
<DailyReportDetailList dailyReportItems={items} selectedItemId={43} scrollRestoreRef={restoreRef} />
|
|
283
|
+
</DailyReportConfigProvider>,
|
|
284
|
+
)
|
|
285
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 1 }])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("null アンカーの復元マウントでも、最初の選択確定のスクロールを飲み込まない", () => {
|
|
289
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: null })
|
|
290
|
+
const items = [buildItem(41), buildItem(42)]
|
|
291
|
+
const view = renderView(<DailyReportDetailList dailyReportItems={items} selectedItemId={null} scrollRestoreRef={restoreRef} />)
|
|
292
|
+
view.rerender(
|
|
293
|
+
<DailyReportConfigProvider config={cfg}>
|
|
294
|
+
<DailyReportDetailList dailyReportItems={items} selectedItemId={41} scrollRestoreRef={restoreRef} />
|
|
295
|
+
</DailyReportConfigProvider>,
|
|
296
|
+
)
|
|
297
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 0 }])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("復元スキップの消費は「スクロール済み」として記録され、直後の items 参照更新で復元が壊れない", () => {
|
|
301
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
|
|
302
|
+
const view = renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
303
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
|
|
304
|
+
|
|
305
|
+
view.rerender(
|
|
306
|
+
<DailyReportConfigProvider config={cfg}>
|
|
307
|
+
<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />
|
|
308
|
+
</DailyReportConfigProvider>,
|
|
309
|
+
)
|
|
310
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it("不一致経路の武装解除は恒久: 後からマウント時と同じ選択へ戻っても飲み込まない", () => {
|
|
314
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
|
|
315
|
+
const view = renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(43)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
316
|
+
|
|
317
|
+
view.rerender(
|
|
318
|
+
<DailyReportConfigProvider config={cfg}>
|
|
319
|
+
<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(43)]} selectedItemId={43} scrollRestoreRef={restoreRef} />
|
|
320
|
+
</DailyReportConfigProvider>,
|
|
321
|
+
)
|
|
322
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 1 }])
|
|
323
|
+
|
|
324
|
+
const revived = [buildItem(41), buildItem(43), buildItem(42)]
|
|
325
|
+
view.rerender(
|
|
326
|
+
<DailyReportConfigProvider config={cfg}>
|
|
327
|
+
<DailyReportDetailList dailyReportItems={revived} selectedItemId={42} scrollRestoreRef={restoreRef} />
|
|
328
|
+
</DailyReportConfigProvider>,
|
|
329
|
+
)
|
|
330
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([
|
|
331
|
+
{ testId: "daily-report-detail-list", index: 1 },
|
|
332
|
+
{ testId: "daily-report-detail-list", index: 2 },
|
|
333
|
+
])
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it("実測カード高さは共有スロットの Map をそのまま使い、アンマウント越しに座標空間を保存する", () => {
|
|
337
|
+
const measuredHeights = new Map<number, number>([[42, 777]])
|
|
338
|
+
const restoreRef = { current: { topItem: { reportHubId: 42, index: 1, offsetPx: 900 }, anchorReportHubId: 42, measuredHeights } }
|
|
339
|
+
renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
340
|
+
const getItemHeight = latestOf("daily-report-detail-list").getItemHeight
|
|
341
|
+
expect(getItemHeight?.(1)).toBe(777)
|
|
342
|
+
expect(getItemHeight?.(0)).not.toBe(777)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it("選択変更時にアンカー同期の副作用が anchorReportHubId を新しい選択へ追従させる (null 以外からの変更も)", () => {
|
|
346
|
+
// 復元判定の生命線: この同期が「非 null からの変更」で止まると、古い選択のまま
|
|
347
|
+
// アンカー一致判定が誤爆し、次回復元の可否が壊れる
|
|
348
|
+
const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
|
|
349
|
+
const items = [buildItem(41), buildItem(42), buildItem(43)]
|
|
350
|
+
const view = renderView(<DailyReportDetailList dailyReportItems={items} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
351
|
+
expect(restoreRef.current.anchorReportHubId).toBe(42)
|
|
352
|
+
|
|
353
|
+
view.rerender(
|
|
354
|
+
<DailyReportConfigProvider config={cfg}>
|
|
355
|
+
<DailyReportDetailList dailyReportItems={items} selectedItemId={43} scrollRestoreRef={restoreRef} />
|
|
356
|
+
</DailyReportConfigProvider>,
|
|
357
|
+
)
|
|
358
|
+
expect(restoreRef.current.anchorReportHubId).toBe(43)
|
|
359
|
+
expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 2 }])
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it("アンマウント時の RAF flush (ref null 化後) でも最終アンカーが書き戻される (ハンドルミラー)", () => {
|
|
363
|
+
// React は削除コミットで virtualScrollRef を null 化してから RAF flush の onScroll が
|
|
364
|
+
// 届く。ミラーが無いと最終フリング分のアンカーが失われる
|
|
365
|
+
const restoreRef = slot({ anchorReportHubId: null })
|
|
366
|
+
const view = renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
367
|
+
// マウント中に一度スクロール (ミラーがハンドルを捕捉)
|
|
368
|
+
scriptedAnchor = { index: 0, offsetPx: 11 }
|
|
369
|
+
const onScroll = latestOf("daily-report-detail-list").onScroll
|
|
370
|
+
act(() => onScroll?.(11, 100000))
|
|
371
|
+
expect(restoreRef.current.topItem).toEqual({ reportHubId: 41, index: 0, offsetPx: 11 })
|
|
372
|
+
|
|
373
|
+
// アンマウント → React が ref を null 化 → その後に flush 相当の onScroll が届く
|
|
374
|
+
view.unmount()
|
|
375
|
+
scriptedAnchor = { index: 1, offsetPx: 77 }
|
|
376
|
+
onScroll?.(777, 100000)
|
|
377
|
+
expect(restoreRef.current.topItem).toEqual({ reportHubId: 42, index: 1, offsetPx: 77 })
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it("スクロールのたびに getScrollAnchor の結果が topItem (同一性 + 行内オフセット) として書き戻される", () => {
|
|
381
|
+
const restoreRef = slot({ anchorReportHubId: null })
|
|
382
|
+
renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
|
|
383
|
+
scriptedAnchor = { index: 1, offsetPx: 55 }
|
|
384
|
+
act(() => latestOf("daily-report-detail-list").onScroll?.(555, 100000))
|
|
385
|
+
expect(restoreRef.current.topItem).toEqual({ reportHubId: 42, index: 1, offsetPx: 55 })
|
|
386
|
+
// アンカー (選択同期) は副作用が管理している
|
|
387
|
+
expect(restoreRef.current.anchorReportHubId).toBe(42)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
describe("タブ往復の統合 (実 Radix Tabs + 実リスト)", () => {
|
|
392
|
+
/** Radix Tabs のトリガーを実ブラウザ相当のイベント列で押す。 */
|
|
393
|
+
const clickTab = (name: RegExp) => {
|
|
394
|
+
const trigger = screen.getByRole("tab", { name })
|
|
395
|
+
fireEvent.mouseDown(trigger)
|
|
396
|
+
fireEvent.focus(trigger)
|
|
397
|
+
fireEvent.click(trigger)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
it("スクロール → 他タブ → 復帰で、復帰マウントの initialScrollOffset が最新のスクロール位置になる", () => {
|
|
401
|
+
contextItemsRef.current = [buildItem(1), buildItem(2), buildItem(3)]
|
|
402
|
+
renderView(<DailyReportResolvedContent userId="ENC1" />)
|
|
403
|
+
|
|
404
|
+
// 初回マウントは 0 (数値 0 は virtualscroll 側でペイン 0 = 本当の先頭に写像される)
|
|
405
|
+
const firstMount = allOf("daily-report-list")[0]
|
|
406
|
+
expect(firstMount?.initialScrollOffset).toBe(0)
|
|
407
|
+
|
|
408
|
+
// ユーザーのスクロールを模す (論理座標 3206)
|
|
409
|
+
act(() => latestOf("daily-report-list").onScroll?.(3206, 100000))
|
|
410
|
+
|
|
411
|
+
// DetailList タブへ → List がアンマウントされる
|
|
412
|
+
clickTab(/DetailList/)
|
|
413
|
+
expect(screen.queryByTestId("daily-report-list")).toBeNull()
|
|
414
|
+
|
|
415
|
+
// List タブへ復帰 → 復帰マウントは最新位置で開始する (旧実装は焼き込み値へリセットされた)
|
|
416
|
+
const capturedBefore = allOf("daily-report-list").length
|
|
417
|
+
clickTab(/^📄 List$/)
|
|
418
|
+
const returnedMounts = allOf("daily-report-list").slice(capturedBefore)
|
|
419
|
+
expect(returnedMounts.length).toBeGreaterThan(0)
|
|
420
|
+
expect(returnedMounts[0]?.initialScrollOffset).toBe(3206)
|
|
421
|
+
})
|
|
422
|
+
})
|