@aiquants/daily-report 0.6.2 → 0.8.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 +443 -23
- package/dist/client.d.ts +443 -23
- 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 +77 -3
- package/dist/index.d.ts +77 -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 +436 -6
- package/dist/server.d.ts +436 -6
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
- package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
- package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
- package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
- package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
- package/src/client/components/daily-report-attachment-list.tsx +112 -0
- package/src/client/components/daily-report-detail-list.tsx +14 -7
- 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 +19 -11
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- package/src/client/components/report-views.spec.tsx +340 -0
- package/src/client/config-context.tsx +8 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +151 -52
- 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-responsive-layout.spec.ts +74 -0
- package/src/client/hooks/use-responsive-layout.ts +56 -0
- 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 +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -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/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +6 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +671 -6
- package/src/server/ports.ts +74 -0
- package/src/server/schema.ts +80 -5
- package/src/server/service.spec.ts +280 -14
- package/src/server/service.ts +348 -25
- package/src/server/test-helpers/handlers-config.ts +181 -0
- package/src/server.ts +16 -1
- package/src/shared/ids-stream.ts +92 -0
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
|
@@ -2,35 +2,40 @@ import { Plus, RefreshCw, RotateCw } from "lucide-react"
|
|
|
2
2
|
import type { ReactNode } from "react"
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
4
4
|
import { useSearchParams } from "react-router"
|
|
5
|
-
import type { DailyReportItem } from "../../shared/types"
|
|
6
5
|
import { useDailyReportConfig } from "../config-context"
|
|
7
6
|
import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
|
|
7
|
+
import { useAnimatedNumber } from "../hooks/use-animated-number"
|
|
8
8
|
import { Button } from "../ui/button"
|
|
9
9
|
import { Label } from "../ui/label"
|
|
10
10
|
import { Switch } from "../ui/switch"
|
|
11
11
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
|
12
12
|
import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
|
|
13
13
|
import { DailyReportDetailList } from "./daily-report-detail-list"
|
|
14
|
+
import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
|
|
14
15
|
import { DailyReportList } from "./daily-report-list"
|
|
15
16
|
|
|
16
17
|
const tabTriggerClass = "min-w-24 p-2 font-medium text-slate-600 text-xs transition-colors data-[state=active]:bg-white data-[state=active]:text-slate-900 sm:min-w-36 sm:text-sm dark:text-slate-300 dark:data-[state=active]:bg-slate-800 dark:data-[state=active]:text-slate-100"
|
|
17
18
|
|
|
18
19
|
type DailyReportResolvedContentProps = {
|
|
19
|
-
dailyReportItems: DailyReportItem[]
|
|
20
20
|
userId?: string | null
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Displays
|
|
25
|
-
*
|
|
24
|
+
* Displays streamed daily reports with layout and tabbed virtual scroll views.
|
|
25
|
+
* レイアウトとタブ付き仮想スクロールでストリーム受信済み日報を表示するコンポーネント。
|
|
26
|
+
*
|
|
27
|
+
* アイテムは Action Context (ストリームチャンクをマージ済み) から読む。
|
|
26
28
|
*/
|
|
27
|
-
export const DailyReportResolvedContent = ({
|
|
29
|
+
export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedContentProps) => {
|
|
28
30
|
// 自動既読設定
|
|
29
31
|
const [autoMarkRead, setAutoMarkRead] = useState(false)
|
|
30
32
|
const { title, renderHeader, showDevControls } = useDailyReportConfig()
|
|
31
33
|
const { isSseEnabled, toggleSse, createReport, items: dailyReportItems, refetchData, clearCacheAndRefetch } = useDailyReportActionContext()
|
|
32
34
|
const [searchParams] = useSearchParams()
|
|
33
35
|
|
|
36
|
+
// 総件数の表示値 (チャンク到着ごとの跳びをイーズアウトで滑らかにする)
|
|
37
|
+
const animatedItemCount = useAnimatedNumber(dailyReportItems.length)
|
|
38
|
+
|
|
34
39
|
// 各タブのスクロール位置をアンマウント後も保持する Ref
|
|
35
40
|
const listScrollOffsetRef = useRef<number>(0)
|
|
36
41
|
const detailListScrollOffsetRef = useRef<number>(0)
|
|
@@ -49,6 +54,24 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
49
54
|
return dailyReportItems[0].reportHubId
|
|
50
55
|
})
|
|
51
56
|
|
|
57
|
+
// 初回の自動選択を一度きりにするフラグ (ユーザーの明示的な選択解除を上書きしない)
|
|
58
|
+
const hasAutoSelectedRef = useRef(false)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 最初のアイテム到着時に先頭日報を一度だけ自動選択する副作用。
|
|
62
|
+
* useState 初期化子はマウント時点で context の items がまだ空のため実質機能せず、
|
|
63
|
+
* ストリーミングでは最初のチャンク到着後に選択を確定させる必要がある。
|
|
64
|
+
* モバイルのオーバーレイは isMobileDetailOpen (明示タップ) でのみ開くため、
|
|
65
|
+
* プログラム選択でオーバーレイが開くことはない。
|
|
66
|
+
* 依存配列: dailyReportItems (チャンク到着ごと)。クリーンアップ: なし。
|
|
67
|
+
*/
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!hasAutoSelectedRef.current && dailyReportItems.length > 0) {
|
|
70
|
+
hasAutoSelectedRef.current = true
|
|
71
|
+
setSelectedItemId((prev) => prev ?? dailyReportItems[0].reportHubId)
|
|
72
|
+
}
|
|
73
|
+
}, [dailyReportItems])
|
|
74
|
+
|
|
52
75
|
const handleReload = async () => {
|
|
53
76
|
await refetchData()
|
|
54
77
|
}
|
|
@@ -123,8 +146,9 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
123
146
|
{renderHeader({
|
|
124
147
|
title,
|
|
125
148
|
annotation: (
|
|
126
|
-
<div className="flex flex-wrap gap-2 leading-1">
|
|
127
|
-
<
|
|
149
|
+
<div className="flex flex-wrap items-center gap-2 leading-1">
|
|
150
|
+
<DailyReportIdsStreamStatus />
|
|
151
|
+
<span className="tabular-nums">総件数: {animatedItemCount.toLocaleString()} 件</span>
|
|
128
152
|
<span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
|
|
129
153
|
</div>
|
|
130
154
|
),
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering guards for the daily-report views (List / DetailList).
|
|
3
|
+
* 日報ビュー (List / DetailList) の描画ガード。
|
|
4
|
+
*
|
|
5
|
+
* 実際に起きた欠陥: 添付節を DetailList タブにだけ入れて List タブのサイドペインに入れ忘れ、
|
|
6
|
+
* List タブしか使わない利用者からは「添付が 1 件も無い」ように見えていた。型チェックでも
|
|
7
|
+
* 既存のどのテストでも検出できず、画面を開いて初めて分かる種類の抜けである。
|
|
8
|
+
*
|
|
9
|
+
* ❗ **ここはソース文字列の grep では守れない。** 以前の版は
|
|
10
|
+
* `expect(source).toContain("<DailyReportAttachmentList ... />")` というソース検査だったが、
|
|
11
|
+
* それでは `{false && ...}` や JSX コメントで**描画だけ止めた**場合に素通りする
|
|
12
|
+
* (= 上記の欠陥そのものを再現しても全 green)。逆に、属性の並べ替えや改行といった
|
|
13
|
+
* 挙動の変わらない整形で赤くなる。そこで実際に描画し、
|
|
14
|
+
* `VirtualScroll` へ渡した設定も差し替えたモックで捕まえて値を検査する。
|
|
15
|
+
*
|
|
16
|
+
* 添付 1 件分の見た目そのものは `daily-report-attachment-list.spec.tsx` が担当する。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// biome-ignore lint/correctness/noNodejsModules: ビュー一覧の網羅性をディレクトリ走査で機械的に確かめるため (テスト専用)
|
|
20
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
21
|
+
// biome-ignore lint/correctness/noNodejsModules: 同上
|
|
22
|
+
import { dirname, join } from "node:path"
|
|
23
|
+
// biome-ignore lint/correctness/noNodejsModules: 同上
|
|
24
|
+
import { fileURLToPath } from "node:url"
|
|
25
|
+
import { act, render, screen } from "@testing-library/react"
|
|
26
|
+
import type { ReactNode } from "react"
|
|
27
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
28
|
+
import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
|
|
29
|
+
import { CONTENT_DRAG_POINTER_INPUTS, ITEM_VERTICAL_PADDING, TAP_SCROLL_CIRCLE_OPTIONS, VIRTUAL_SCROLL_OVERSCAN_COUNT } from "../utils/constants"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `VirtualScroll` が受け取った props。ビューごとに 1 件積まれる。
|
|
33
|
+
*
|
|
34
|
+
* ❗ **代役は窓計算を捨てて全件描画するため、渡し忘れても描画結果が変わらない props がある。**
|
|
35
|
+
* それらは値を記録して個別に検査しないと「モックだから通る」状態になる
|
|
36
|
+
* (実際に `onRangeChange` を no-op へ差し替えると窓フェッチが止まり一覧が空になるが、
|
|
37
|
+
* 記録しなければテストは緑のまま)。
|
|
38
|
+
*/
|
|
39
|
+
type CapturedVirtualScrollProps = {
|
|
40
|
+
testId?: string
|
|
41
|
+
itemCount: number
|
|
42
|
+
behaviorOptions?: { pointerDragInputs?: unknown; enablePointerDrag?: unknown }
|
|
43
|
+
scrollBarOptions?: { tapScrollCircleOptions?: unknown; renderThumbOverlay?: (props: Record<string, unknown>) => ReactNode }
|
|
44
|
+
getItemKey?: unknown
|
|
45
|
+
getItemHeight?: unknown
|
|
46
|
+
viewportSize?: unknown
|
|
47
|
+
overscanCount?: unknown
|
|
48
|
+
onRangeChange?: unknown
|
|
49
|
+
onScroll?: unknown
|
|
50
|
+
contentInsets?: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const captured: CapturedVirtualScrollProps[] = []
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stand-in for VirtualScroll that renders every item and records its options.
|
|
57
|
+
* 全アイテムを描画しつつ設定を記録する `VirtualScroll` の代役。
|
|
58
|
+
*
|
|
59
|
+
* 本物は要素の実測高さに依存するため jsdom では 1 件も描画されない。
|
|
60
|
+
* 「どのペインに何が描かれるか」を検査したいので、窓の計算は捨てて全件描画する。
|
|
61
|
+
*/
|
|
62
|
+
vi.mock("@aiquants/virtualscroll", async () => {
|
|
63
|
+
const { forwardRef: fwd, useImperativeHandle: useHandle } = await import("react")
|
|
64
|
+
return {
|
|
65
|
+
tapScrollCircleSampleVisual: () => null,
|
|
66
|
+
VirtualScroll: fwd(
|
|
67
|
+
(
|
|
68
|
+
props: {
|
|
69
|
+
itemCount: number
|
|
70
|
+
getItem: (index: number) => unknown
|
|
71
|
+
children: (item: unknown, index: number) => ReactNode
|
|
72
|
+
} & CapturedVirtualScrollProps,
|
|
73
|
+
ref: React.Ref<unknown>,
|
|
74
|
+
) => {
|
|
75
|
+
captured.push({
|
|
76
|
+
testId: props.testId,
|
|
77
|
+
itemCount: props.itemCount,
|
|
78
|
+
behaviorOptions: props.behaviorOptions,
|
|
79
|
+
scrollBarOptions: props.scrollBarOptions,
|
|
80
|
+
getItemKey: props.getItemKey,
|
|
81
|
+
getItemHeight: props.getItemHeight,
|
|
82
|
+
viewportSize: props.viewportSize,
|
|
83
|
+
overscanCount: props.overscanCount,
|
|
84
|
+
onRangeChange: props.onRangeChange,
|
|
85
|
+
onScroll: props.onScroll,
|
|
86
|
+
contentInsets: props.contentInsets,
|
|
87
|
+
})
|
|
88
|
+
useHandle(ref, () => ({ scrollTo: () => 0, scrollToIndex: () => {}, getScrollPosition: () => 0, getContentSize: () => 0, getViewportSize: () => 0, updateItemSize: () => {}, focusItemAtIndex: () => {} }))
|
|
89
|
+
return (
|
|
90
|
+
<div data-testid={props.testId}>
|
|
91
|
+
{Array.from({ length: props.itemCount }, (_, index) => (
|
|
92
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: 代役なので index キーで十分
|
|
93
|
+
<div key={index}>{props.children(props.getItem(index), index)}</div>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
},
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** 日報アクションはネットワーク/ルーターに依存するため、呼び出しを受け止めるだけの代役にする。 */
|
|
103
|
+
vi.mock("../contexts/daily-report-action-context", async (importOriginal) => {
|
|
104
|
+
const actual = await importOriginal<typeof import("../contexts/daily-report-action-context")>()
|
|
105
|
+
const noop = async () => {}
|
|
106
|
+
return {
|
|
107
|
+
...actual,
|
|
108
|
+
useDailyReportActionContext: () => ({
|
|
109
|
+
// 楽観的更新の台帳はすべて ref。1 つでも欠けると描画が例外で落ちる
|
|
110
|
+
pendingAddCommentIds: { current: new Map() },
|
|
111
|
+
pendingDeleteCommentIds: { current: new Map() },
|
|
112
|
+
pendingReadUpdates: { current: new Map() },
|
|
113
|
+
pendingStarUpdates: { current: new Map() },
|
|
114
|
+
resolvedIdMap: { current: new Map() },
|
|
115
|
+
version: 0,
|
|
116
|
+
items: [],
|
|
117
|
+
user: null,
|
|
118
|
+
isSseEnabled: false,
|
|
119
|
+
toggleSse: () => {},
|
|
120
|
+
addComment: noop,
|
|
121
|
+
deleteComment: noop,
|
|
122
|
+
toggleStar: noop,
|
|
123
|
+
toggleRead: noop,
|
|
124
|
+
createReport: async () => 0,
|
|
125
|
+
updateReport: noop,
|
|
126
|
+
publishReport: noop,
|
|
127
|
+
deleteReport: noop,
|
|
128
|
+
removeStaleItem: () => {},
|
|
129
|
+
refetchData: noop,
|
|
130
|
+
clearCacheAndRefetch: noop,
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/** 詳細取得はキャッシュ層ごと差し替え、常に用意した 1 件を返す。 */
|
|
136
|
+
const detailRef: { current: DailyReportDetail | null } = { current: null }
|
|
137
|
+
vi.mock("../hooks/use-daily-report", async (importOriginal) => {
|
|
138
|
+
const actual = await importOriginal<typeof import("../hooks/use-daily-report")>()
|
|
139
|
+
return {
|
|
140
|
+
...actual,
|
|
141
|
+
useDailyReportDetail: () => ({ report: detailRef.current, error: null, isLoading: false, isRefetching: false }),
|
|
142
|
+
useDailyReportPrefetch: () => () => {},
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const { DailyReportList } = await import("./daily-report-list")
|
|
147
|
+
const { DailyReportDetailList } = await import("./daily-report-detail-list")
|
|
148
|
+
const { DailyReportConfigProvider } = await import("../config-context")
|
|
149
|
+
|
|
150
|
+
const componentsDir = dirname(fileURLToPath(import.meta.url))
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 日報の詳細を描画するビューの一覧。**すべての検査はこの表から導出する。**
|
|
154
|
+
*
|
|
155
|
+
* ❗ 追加したらここへ足すこと。足し忘れは下の網羅テストが検知し、足せば
|
|
156
|
+
* 添付節・マーカー・スクロール結線の全検査が自動的にそのビューへ適用される
|
|
157
|
+
* (表への登録だけで黙らせられる、という抜け道を作らない)。
|
|
158
|
+
*/
|
|
159
|
+
const REPORT_DETAIL_VIEWS = [
|
|
160
|
+
{ file: "daily-report-list.tsx", testId: "daily-report-list", render: () => <DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} /> },
|
|
161
|
+
{ file: "daily-report-detail-list.tsx", testId: "daily-report-detail-list", render: () => <DailyReportDetailList dailyReportItems={[item]} /> },
|
|
162
|
+
] as const
|
|
163
|
+
|
|
164
|
+
/** `it.each` のタイトル用に (名前, 定義) の組へ落とす。 */
|
|
165
|
+
const VIEW_CASES = REPORT_DETAIL_VIEWS.map((view) => [view.file, view] as const)
|
|
166
|
+
|
|
167
|
+
const REPORT_HUB_ID = 1
|
|
168
|
+
|
|
169
|
+
const item: DailyReportItem = { reportHubId: REPORT_HUB_ID, businessDate: "2026-07-31" }
|
|
170
|
+
|
|
171
|
+
/** 添付 1 件を持つ日報詳細。 */
|
|
172
|
+
const buildDetail = (): DailyReportDetail => ({
|
|
173
|
+
reportHubId: REPORT_HUB_ID,
|
|
174
|
+
date: "2026-07-31",
|
|
175
|
+
interviewers: [],
|
|
176
|
+
comments: [],
|
|
177
|
+
isRead: false,
|
|
178
|
+
isStarred: false,
|
|
179
|
+
labels: [],
|
|
180
|
+
commentItems: [],
|
|
181
|
+
attachments: [{ id: "TOKEN1", fileName: "report.png", fileType: "image/png", fileSize: 2048, createdAt: "2026-07-31 00:00:00", state: "unknown" }],
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/** 設定コンテキストを与えて描画する。 */
|
|
185
|
+
const renderView = (node: ReactNode) =>
|
|
186
|
+
render(
|
|
187
|
+
<DailyReportConfigProvider
|
|
188
|
+
config={{
|
|
189
|
+
apiBasePath: "/daily_report/api",
|
|
190
|
+
fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" },
|
|
191
|
+
}}>
|
|
192
|
+
{node}
|
|
193
|
+
</DailyReportConfigProvider>,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 指定 testId のビューが受け取った `VirtualScroll` の props (最新のレンダー分)。
|
|
198
|
+
*
|
|
199
|
+
* ❗ 再レンダーのたびに積まれるので**最後の 1 件**を返す。先頭を返すと、
|
|
200
|
+
* 状態更新後に渡し直されたコールバックではなく古いクロージャを掴んでしまう。
|
|
201
|
+
*/
|
|
202
|
+
const optionsOf = (testId: string) => {
|
|
203
|
+
const props = [...captured].reverse().find((entry) => entry.testId === testId)
|
|
204
|
+
if (!props) throw new Error(`VirtualScroll props for ${testId} were not captured`)
|
|
205
|
+
return props
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
captured.length = 0
|
|
210
|
+
detailRef.current = buildDetail()
|
|
211
|
+
// DetailList は行の実測に ResizeObserver を使う (jsdom には無い)
|
|
212
|
+
vi.stubGlobal(
|
|
213
|
+
"ResizeObserver",
|
|
214
|
+
class {
|
|
215
|
+
observe() {}
|
|
216
|
+
unobserve() {}
|
|
217
|
+
disconnect() {}
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
vi.stubGlobal(
|
|
221
|
+
"matchMedia",
|
|
222
|
+
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() })),
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe("日報ビューの描画と結線", () => {
|
|
227
|
+
it.each(VIEW_CASES)("%s は添付節を描画する", (_file, view) => {
|
|
228
|
+
renderView(view.render())
|
|
229
|
+
expect(screen.getAllByTestId("daily-report-attachment-section").length).toBeGreaterThan(0)
|
|
230
|
+
expect(screen.getAllByTestId("daily-report-attachment-item").length).toBe(1)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it.each(VIEW_CASES)("%s は添付マーカーを描画する", (_file, view) => {
|
|
234
|
+
// 節そのものはカードを開かないと見えないため、スクロールしながら判別できる目印が要る
|
|
235
|
+
renderView(view.render())
|
|
236
|
+
expect(screen.getAllByTestId("daily-report-attachment-indicator").length).toBeGreaterThan(0)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("添付が 0 件のときは節もマーカーも描画されない (誤検知の裏取り)", () => {
|
|
240
|
+
detailRef.current = { ...buildDetail(), attachments: [] }
|
|
241
|
+
renderView(<DailyReportDetailList dailyReportItems={[item]} />)
|
|
242
|
+
expect(screen.queryByTestId("daily-report-attachment-section")).toBeNull()
|
|
243
|
+
expect(screen.queryByTestId("daily-report-attachment-indicator")).toBeNull()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it.each(VIEW_CASES)("%s はドラッグを無効化せずポインタ種別で絞る", (_file, view) => {
|
|
247
|
+
const [testId, renderNode] = [view.testId, view.render]
|
|
248
|
+
// ❗ `enablePointerDrag` を切ってはならない。ScrollPane は transform 描画で
|
|
249
|
+
// ネイティブのスクロール領域を持たないため、切るとタッチ端末から
|
|
250
|
+
// スクロール手段が完全に消える。マウスのドラッグだけを外すのが正しい
|
|
251
|
+
renderView(renderNode())
|
|
252
|
+
const { behaviorOptions } = optionsOf(testId)
|
|
253
|
+
expect(behaviorOptions?.pointerDragInputs).toBe(CONTENT_DRAG_POINTER_INPUTS)
|
|
254
|
+
expect(behaviorOptions?.enablePointerDrag).toBeUndefined()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it.each(VIEW_CASES)("%s は共有のタップスクロール設定を使う", (_file, view) => {
|
|
258
|
+
const [testId, renderNode] = [view.testId, view.render]
|
|
259
|
+
// 片方だけ独自定義にすると、同じ指の位置でタブごとに速度が何十倍も違う
|
|
260
|
+
// (是正前は実測で最大 77 倍の差があった)
|
|
261
|
+
renderView(renderNode())
|
|
262
|
+
expect(optionsOf(testId).scrollBarOptions?.tapScrollCircleOptions).toBe(TAP_SCROLL_CIRCLE_OPTIONS)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it.each(VIEW_CASES)("%s は仮想スクロールの必須結線を渡している", (_file, view) => {
|
|
266
|
+
const [testId, renderNode] = [view.testId, view.render]
|
|
267
|
+
// 代役は全件描画するため、これらを壊しても描画結果は変わらない。値そのものを検査する
|
|
268
|
+
renderView(renderNode())
|
|
269
|
+
const props = optionsOf(testId)
|
|
270
|
+
// 窓フェッチとプリフェッチの起点。no-op にすると一覧が空のままになる
|
|
271
|
+
expect(typeof props.onRangeChange).toBe("function")
|
|
272
|
+
expect(typeof props.onScroll).toBe("function")
|
|
273
|
+
expect(typeof props.getItemHeight).toBe("function")
|
|
274
|
+
// 白飛び防止のオーバースキャン。0 にするとスクロール中に空白が出る
|
|
275
|
+
expect(props.overscanCount).toBe(VIRTUAL_SCROLL_OVERSCAN_COUNT)
|
|
276
|
+
expect(props.viewportSize).toEqual(expect.any(Number))
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it("❗ onRangeChange は実際のハンドラである (型が function なだけでは足りない)", () => {
|
|
280
|
+
// no-op へ差し替えると窓の日付表示が止まる。`typeof === "function"` は
|
|
281
|
+
// no-op も通してしまうので、**呼んだ結果が観測できること**まで確かめる
|
|
282
|
+
const second: DailyReportItem = { reportHubId: 2, businessDate: "2026-08-05" }
|
|
283
|
+
renderView(<DailyReportList dailyReportItems={[item, second]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
284
|
+
|
|
285
|
+
/** つまみのバブルに出ている文言。 */
|
|
286
|
+
const overlayText = () => {
|
|
287
|
+
const renderThumbOverlay = optionsOf("daily-report-list").scrollBarOptions?.renderThumbOverlay
|
|
288
|
+
if (typeof renderThumbOverlay !== "function") throw new Error("renderThumbOverlay was not captured")
|
|
289
|
+
const view = render(<div>{renderThumbOverlay({ orientation: "vertical", thumbCenter: 0, isDragging: true, isTapScrollActive: false })}</div>)
|
|
290
|
+
const text = view.container.textContent ?? ""
|
|
291
|
+
view.unmount()
|
|
292
|
+
return text
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
expect(overlayText()).not.toContain("2026-08-05")
|
|
296
|
+
|
|
297
|
+
act(() => {
|
|
298
|
+
;(optionsOf("daily-report-list").onRangeChange as (range: { visibleStartIndex: number }) => void)({ visibleStartIndex: 1 })
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// no-op へ差し替えると、ここが古い日付のまま変わらない
|
|
302
|
+
expect(overlayText()).toContain("2026-08-05")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("DetailList は行の同一性を保つキーを渡している (React の再利用が壊れない)", () => {
|
|
306
|
+
// 可変高さのカードは key が潰れると再利用が壊れ、実測高さが別の行へ付く
|
|
307
|
+
renderView(<DailyReportDetailList dailyReportItems={[item]} />)
|
|
308
|
+
const { getItemKey } = optionsOf("daily-report-detail-list")
|
|
309
|
+
expect(typeof getItemKey).toBe("function")
|
|
310
|
+
expect((getItemKey as (index: number) => unknown)(0)).toBe(REPORT_HUB_ID)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it("List は上下のインセットを渡している (先頭・末尾の行が縁に貼り付かない)", () => {
|
|
314
|
+
renderView(<DailyReportList dailyReportItems={[item]} selectedReportHubId={REPORT_HUB_ID} onSelectItem={() => {}} />)
|
|
315
|
+
expect(optionsOf("daily-report-list").contentInsets).toEqual({ top: ITEM_VERTICAL_PADDING, bottom: ITEM_VERTICAL_PADDING })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it("レイアウト判定の閾値を各ビューが独自に持たない (単一の情報源にする)", () => {
|
|
319
|
+
// 空白の有無に左右されないよう、空白を除去してから照合する
|
|
320
|
+
for (const view of REPORT_DETAIL_VIEWS) {
|
|
321
|
+
const source = readFileSync(join(componentsDir, view.file), "utf8").replace(/\s+/g, "")
|
|
322
|
+
expect(source, view.file).not.toContain("max-width:639px")
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it("詳細を描画するビューを網羅している (新しいビューの追加を検知する)", () => {
|
|
327
|
+
// ❗ 走査対象はディレクトリの実体。登録済みリストを絞り込む書き方だと
|
|
328
|
+
// 「新しく増えたファイル」は原理的に視界へ入らない。
|
|
329
|
+
//
|
|
330
|
+
// 判定は **`use-daily-report-comments` を import しているか**で行う。
|
|
331
|
+
// 日報のコメントを取りに行くのは「その日報の詳細を描画するビュー」だけであり、
|
|
332
|
+
// import 文なので `fieldLabels.comments` のような字面の揺れ
|
|
333
|
+
// (`fieldLabels["comments"]` / 分割代入) に左右されない
|
|
334
|
+
const detailViews = readdirSync(componentsDir)
|
|
335
|
+
.filter((file) => file.endsWith(".tsx") && !file.endsWith(".spec.tsx"))
|
|
336
|
+
.filter((file) => readFileSync(join(componentsDir, file), "utf8").includes('from "../hooks/use-daily-report-comments"'))
|
|
337
|
+
.sort()
|
|
338
|
+
expect(detailViews).toEqual(REPORT_DETAIL_VIEWS.map((view) => view.file).sort())
|
|
339
|
+
})
|
|
340
|
+
})
|
|
@@ -33,6 +33,11 @@ export type DailyReportFieldLabels = {
|
|
|
33
33
|
content: string
|
|
34
34
|
interviewers: string
|
|
35
35
|
comments: string
|
|
36
|
+
attachments: string
|
|
37
|
+
/** 実体の消失が確認された添付に添える注記。 */
|
|
38
|
+
attachmentMissing: string
|
|
39
|
+
/** 添付をダウンロードするリンクのラベル。 */
|
|
40
|
+
attachmentDownload: string
|
|
36
41
|
tabArticle: string
|
|
37
42
|
tabRelations: string
|
|
38
43
|
relationsEmpty: string
|
|
@@ -92,6 +97,9 @@ export const defaultDailyReportFieldLabels: DailyReportFieldLabels = {
|
|
|
92
97
|
content: "Content",
|
|
93
98
|
interviewers: "Attendees",
|
|
94
99
|
comments: "Comments",
|
|
100
|
+
attachments: "Attachments",
|
|
101
|
+
attachmentMissing: "File is missing",
|
|
102
|
+
attachmentDownload: "Download",
|
|
95
103
|
tabArticle: "Overview",
|
|
96
104
|
tabRelations: "Related",
|
|
97
105
|
relationsEmpty: "No related view configured yet.",
|