@operato/board 10.0.0-beta.28 → 10.0.0-beta.29

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/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.0.0-beta.29](https://github.com/hatiolab/operato/compare/v10.0.0-beta.28...v10.0.0-beta.29) (2026-04-13)
7
+
8
+
9
+ ### :rocket: New Features
10
+
11
+ * **board:** 플레이백 YouTube 스트리밍 방식 — PlaybackBuffer + 청크 기반 Provider ([dda8b0b](https://github.com/hatiolab/operato/commit/dda8b0b675472c4e606730195e608667a4919010))
12
+
13
+
14
+ ### :mega: Other
15
+
16
+ * **board:** @types/jest 의존성 추가 — playback-buffer 테스트 빌드 오류 해결 ([c68cfb0](https://github.com/hatiolab/operato/commit/c68cfb0a75f2b7ab10e32c9d3f2446ae766a87e7))
17
+
18
+
19
+
6
20
  ## [10.0.0-beta.28](https://github.com/hatiolab/operato/compare/v10.0.0-beta.27...v10.0.0-beta.28) (2026-04-11)
7
21
 
8
22
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PlaybackBuffer — YouTube 스트리밍 방식의 청크 기반 플레이백 버퍼
3
+ *
4
+ * - 10분 단위 청크로 데이터를 fetch
5
+ * - 남은 데이터가 1분 이하이면 다음 청크를 prefetch
6
+ * - seek 시 기존 버퍼 전체 폐기 후 새 위치에서 fresh fetch
7
+ */
8
+ export interface PlaybackSnapshot {
9
+ timestamp: number;
10
+ data: Record<string, any>;
11
+ }
12
+ export interface PlaybackChunk {
13
+ fromTime: number;
14
+ toTime: number;
15
+ snapshots: PlaybackSnapshot[];
16
+ }
17
+ export interface ChunkFetcher {
18
+ (fromTime: Date, toTime: Date): Promise<PlaybackSnapshot[]>;
19
+ }
20
+ declare const CHUNK_DURATION: number;
21
+ declare const PREFETCH_THRESHOLD: number;
22
+ export declare class PlaybackBuffer {
23
+ private _chunks;
24
+ private _fetcher;
25
+ private _prefetching;
26
+ private _totalRange;
27
+ constructor(fetcher: ChunkFetcher, totalRange: {
28
+ from: Date;
29
+ to: Date;
30
+ });
31
+ get totalRange(): {
32
+ from: number;
33
+ to: number;
34
+ };
35
+ /**
36
+ * 버퍼에 로드된 시간 범위들을 반환 (시크바 버퍼 표시용)
37
+ */
38
+ getBufferedRanges(): {
39
+ from: number;
40
+ to: number;
41
+ }[];
42
+ /**
43
+ * 현재 playHead 시점의 스냅샷을 찾는다.
44
+ * playHead 이하인 스냅샷 중 가장 가까운 것을 반환.
45
+ */
46
+ getSnapshotAt(playHead: number): PlaybackSnapshot | null;
47
+ /**
48
+ * playHead와 가장 가까운 다음 스냅샷의 timestamp를 반환.
49
+ * 타이머 간격 계산에 사용.
50
+ */
51
+ getNextSnapshotTime(playHead: number): number | null;
52
+ /**
53
+ * 버퍼의 마지막 시점 (가장 먼 미래)
54
+ */
55
+ getBufferedEnd(): number;
56
+ /**
57
+ * 초기 로드: 지정 시점부터 10분 fetch
58
+ */
59
+ loadInitial(fromTime: number): Promise<void>;
60
+ /**
61
+ * prefetch 필요 여부 확인 + 자동 실행
62
+ * playHead: 현재 재생 위치 (ms)
63
+ */
64
+ checkPrefetch(playHead: number): Promise<void>;
65
+ /**
66
+ * seek: 기존 버퍼 전체 폐기 + 새 위치에서 fresh fetch
67
+ */
68
+ seek(toTime: number): Promise<void>;
69
+ /**
70
+ * 버퍼 전체 폐기
71
+ */
72
+ clear(): void;
73
+ /**
74
+ * 특정 시점이 버퍼에 있는지 확인
75
+ */
76
+ hasDataAt(time: number): boolean;
77
+ private _fetchChunk;
78
+ }
79
+ export { CHUNK_DURATION, PREFETCH_THRESHOLD };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * PlaybackBuffer — YouTube 스트리밍 방식의 청크 기반 플레이백 버퍼
3
+ *
4
+ * - 10분 단위 청크로 데이터를 fetch
5
+ * - 남은 데이터가 1분 이하이면 다음 청크를 prefetch
6
+ * - seek 시 기존 버퍼 전체 폐기 후 새 위치에서 fresh fetch
7
+ */
8
+ const CHUNK_DURATION = 10 * 60 * 1000; // 10분 (ms)
9
+ const PREFETCH_THRESHOLD = 1 * 60 * 1000; // 1분 남았을 때 prefetch
10
+ export class PlaybackBuffer {
11
+ constructor(fetcher, totalRange) {
12
+ this._chunks = [];
13
+ this._prefetching = false;
14
+ this._fetcher = fetcher;
15
+ this._totalRange = { from: totalRange.from.getTime(), to: totalRange.to.getTime() };
16
+ }
17
+ get totalRange() {
18
+ return this._totalRange;
19
+ }
20
+ /**
21
+ * 버퍼에 로드된 시간 범위들을 반환 (시크바 버퍼 표시용)
22
+ */
23
+ getBufferedRanges() {
24
+ return this._chunks.map(c => ({ from: c.fromTime, to: c.toTime }));
25
+ }
26
+ /**
27
+ * 현재 playHead 시점의 스냅샷을 찾는다.
28
+ * playHead 이하인 스냅샷 중 가장 가까운 것을 반환.
29
+ */
30
+ getSnapshotAt(playHead) {
31
+ let best = null;
32
+ for (const chunk of this._chunks) {
33
+ if (chunk.fromTime > playHead)
34
+ break;
35
+ for (const snap of chunk.snapshots) {
36
+ if (snap.timestamp <= playHead) {
37
+ best = snap;
38
+ }
39
+ else {
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ return best;
45
+ }
46
+ /**
47
+ * playHead와 가장 가까운 다음 스냅샷의 timestamp를 반환.
48
+ * 타이머 간격 계산에 사용.
49
+ */
50
+ getNextSnapshotTime(playHead) {
51
+ for (const chunk of this._chunks) {
52
+ for (const snap of chunk.snapshots) {
53
+ if (snap.timestamp > playHead) {
54
+ return snap.timestamp;
55
+ }
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+ /**
61
+ * 버퍼의 마지막 시점 (가장 먼 미래)
62
+ */
63
+ getBufferedEnd() {
64
+ if (this._chunks.length === 0)
65
+ return 0;
66
+ return this._chunks[this._chunks.length - 1].toTime;
67
+ }
68
+ /**
69
+ * 초기 로드: 지정 시점부터 10분 fetch
70
+ */
71
+ async loadInitial(fromTime) {
72
+ this._chunks = [];
73
+ await this._fetchChunk(fromTime);
74
+ }
75
+ /**
76
+ * prefetch 필요 여부 확인 + 자동 실행
77
+ * playHead: 현재 재생 위치 (ms)
78
+ */
79
+ async checkPrefetch(playHead) {
80
+ if (this._prefetching)
81
+ return;
82
+ const bufferedEnd = this.getBufferedEnd();
83
+ const remaining = bufferedEnd - playHead;
84
+ if (remaining <= PREFETCH_THRESHOLD && bufferedEnd < this._totalRange.to) {
85
+ this._prefetching = true;
86
+ try {
87
+ await this._fetchChunk(bufferedEnd);
88
+ }
89
+ finally {
90
+ this._prefetching = false;
91
+ }
92
+ }
93
+ }
94
+ /**
95
+ * seek: 기존 버퍼 전체 폐기 + 새 위치에서 fresh fetch
96
+ */
97
+ async seek(toTime) {
98
+ this._chunks = [];
99
+ this._prefetching = false;
100
+ await this._fetchChunk(toTime);
101
+ }
102
+ /**
103
+ * 버퍼 전체 폐기
104
+ */
105
+ clear() {
106
+ this._chunks = [];
107
+ this._prefetching = false;
108
+ }
109
+ /**
110
+ * 특정 시점이 버퍼에 있는지 확인
111
+ */
112
+ hasDataAt(time) {
113
+ return this._chunks.some(c => c.fromTime <= time && time < c.toTime);
114
+ }
115
+ async _fetchChunk(fromTime) {
116
+ const toTime = Math.min(fromTime + CHUNK_DURATION, this._totalRange.to);
117
+ if (fromTime >= toTime)
118
+ return;
119
+ const snapshots = await this._fetcher(new Date(fromTime), new Date(toTime));
120
+ // 중복 청크 방지
121
+ if (this._chunks.some(c => c.fromTime === fromTime))
122
+ return;
123
+ const chunk = {
124
+ fromTime,
125
+ toTime,
126
+ snapshots: snapshots.sort((a, b) => a.timestamp - b.timestamp)
127
+ };
128
+ // 시간순 삽입
129
+ const insertIdx = this._chunks.findIndex(c => c.fromTime > fromTime);
130
+ if (insertIdx === -1) {
131
+ this._chunks.push(chunk);
132
+ }
133
+ else {
134
+ this._chunks.splice(insertIdx, 0, chunk);
135
+ }
136
+ }
137
+ }
138
+ export { CHUNK_DURATION, PREFETCH_THRESHOLD };
139
+ //# sourceMappingURL=playback-buffer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"playback-buffer.js","sourceRoot":"","sources":["../../../src/graphql/playback-buffer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * PlaybackBuffer — YouTube 스트리밍 방식의 청크 기반 플레이백 버퍼\n *\n * - 10분 단위 청크로 데이터를 fetch\n * - 남은 데이터가 1분 이하이면 다음 청크를 prefetch\n * - seek 시 기존 버퍼 전체 폐기 후 새 위치에서 fresh fetch\n */\n\nexport interface PlaybackSnapshot {\n timestamp: number // ms\n data: Record<string, any> // { tag: value }\n}\n\nexport interface PlaybackChunk {\n fromTime: number // ms\n toTime: number // ms\n snapshots: PlaybackSnapshot[]\n}\n\nexport interface ChunkFetcher {\n (fromTime: Date, toTime: Date): Promise<PlaybackSnapshot[]>\n}\n\nconst CHUNK_DURATION = 10 * 60 * 1000 // 10분 (ms)\nconst PREFETCH_THRESHOLD = 1 * 60 * 1000 // 1분 남았을 때 prefetch\n\nexport class PlaybackBuffer {\n private _chunks: PlaybackChunk[] = []\n private _fetcher: ChunkFetcher\n private _prefetching: boolean = false\n private _totalRange: { from: number; to: number } // 전체 플레이백 범위\n\n constructor(fetcher: ChunkFetcher, totalRange: { from: Date; to: Date }) {\n this._fetcher = fetcher\n this._totalRange = { from: totalRange.from.getTime(), to: totalRange.to.getTime() }\n }\n\n get totalRange() {\n return this._totalRange\n }\n\n /**\n * 버퍼에 로드된 시간 범위들을 반환 (시크바 버퍼 표시용)\n */\n getBufferedRanges(): { from: number; to: number }[] {\n return this._chunks.map(c => ({ from: c.fromTime, to: c.toTime }))\n }\n\n /**\n * 현재 playHead 시점의 스냅샷을 찾는다.\n * playHead 이하인 스냅샷 중 가장 가까운 것을 반환.\n */\n getSnapshotAt(playHead: number): PlaybackSnapshot | null {\n let best: PlaybackSnapshot | null = null\n\n for (const chunk of this._chunks) {\n if (chunk.fromTime > playHead) break\n\n for (const snap of chunk.snapshots) {\n if (snap.timestamp <= playHead) {\n best = snap\n } else {\n break\n }\n }\n }\n\n return best\n }\n\n /**\n * playHead와 가장 가까운 다음 스냅샷의 timestamp를 반환.\n * 타이머 간격 계산에 사용.\n */\n getNextSnapshotTime(playHead: number): number | null {\n for (const chunk of this._chunks) {\n for (const snap of chunk.snapshots) {\n if (snap.timestamp > playHead) {\n return snap.timestamp\n }\n }\n }\n return null\n }\n\n /**\n * 버퍼의 마지막 시점 (가장 먼 미래)\n */\n getBufferedEnd(): number {\n if (this._chunks.length === 0) return 0\n return this._chunks[this._chunks.length - 1].toTime\n }\n\n /**\n * 초기 로드: 지정 시점부터 10분 fetch\n */\n async loadInitial(fromTime: number): Promise<void> {\n this._chunks = []\n await this._fetchChunk(fromTime)\n }\n\n /**\n * prefetch 필요 여부 확인 + 자동 실행\n * playHead: 현재 재생 위치 (ms)\n */\n async checkPrefetch(playHead: number): Promise<void> {\n if (this._prefetching) return\n\n const bufferedEnd = this.getBufferedEnd()\n const remaining = bufferedEnd - playHead\n\n if (remaining <= PREFETCH_THRESHOLD && bufferedEnd < this._totalRange.to) {\n this._prefetching = true\n try {\n await this._fetchChunk(bufferedEnd)\n } finally {\n this._prefetching = false\n }\n }\n }\n\n /**\n * seek: 기존 버퍼 전체 폐기 + 새 위치에서 fresh fetch\n */\n async seek(toTime: number): Promise<void> {\n this._chunks = []\n this._prefetching = false\n await this._fetchChunk(toTime)\n }\n\n /**\n * 버퍼 전체 폐기\n */\n clear(): void {\n this._chunks = []\n this._prefetching = false\n }\n\n /**\n * 특정 시점이 버퍼에 있는지 확인\n */\n hasDataAt(time: number): boolean {\n return this._chunks.some(c => c.fromTime <= time && time < c.toTime)\n }\n\n private async _fetchChunk(fromTime: number): Promise<void> {\n const toTime = Math.min(fromTime + CHUNK_DURATION, this._totalRange.to)\n if (fromTime >= toTime) return\n\n const snapshots = await this._fetcher(new Date(fromTime), new Date(toTime))\n\n // 중복 청크 방지\n if (this._chunks.some(c => c.fromTime === fromTime)) return\n\n const chunk: PlaybackChunk = {\n fromTime,\n toTime,\n snapshots: snapshots.sort((a, b) => a.timestamp - b.timestamp)\n }\n\n // 시간순 삽입\n const insertIdx = this._chunks.findIndex(c => c.fromTime > fromTime)\n if (insertIdx === -1) {\n this._chunks.push(chunk)\n } else {\n this._chunks.splice(insertIdx, 0, chunk)\n }\n }\n}\n\nexport { CHUNK_DURATION, PREFETCH_THRESHOLD }\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,261 @@
1
+ import { PlaybackBuffer, CHUNK_DURATION } from './playback-buffer';
2
+ // --- 테스트 헬퍼 ---
3
+ const BASE_TIME = new Date('2026-04-12T10:00:00Z').getTime();
4
+ const ONE_HOUR = 3600000;
5
+ const ONE_MIN = 60000;
6
+ function makeSnapshots(fromMs, toMs, intervalMs = 1000) {
7
+ const snaps = [];
8
+ for (let t = fromMs; t < toMs; t += intervalMs) {
9
+ snaps.push({
10
+ timestamp: t,
11
+ data: { vehicle: { id: 'AGV01', position: t - fromMs } }
12
+ });
13
+ }
14
+ return snaps;
15
+ }
16
+ function createMockFetcher(allSnapshots) {
17
+ const calls = [];
18
+ const fetcher = async (from, to) => {
19
+ calls.push({ from: from.getTime(), to: to.getTime() });
20
+ if (allSnapshots) {
21
+ return allSnapshots.filter(s => s.timestamp >= from.getTime() && s.timestamp < to.getTime());
22
+ }
23
+ // 기본: 1초 간격 스냅샷 생성
24
+ return makeSnapshots(from.getTime(), to.getTime());
25
+ };
26
+ return { fetcher, calls };
27
+ }
28
+ // --- PlaybackBuffer 단위 테스트 ---
29
+ describe('PlaybackBuffer', () => {
30
+ const totalRange = {
31
+ from: new Date(BASE_TIME),
32
+ to: new Date(BASE_TIME + ONE_HOUR)
33
+ };
34
+ describe('loadInitial', () => {
35
+ test('10분 청크를 로드한다', async () => {
36
+ const { fetcher, calls } = createMockFetcher();
37
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
38
+ await buffer.loadInitial(BASE_TIME);
39
+ expect(calls).toHaveLength(1);
40
+ expect(calls[0].from).toBe(BASE_TIME);
41
+ expect(calls[0].to).toBe(BASE_TIME + CHUNK_DURATION);
42
+ });
43
+ test('기존 버퍼를 폐기하고 새로 로드한다', async () => {
44
+ const { fetcher, calls } = createMockFetcher();
45
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
46
+ await buffer.loadInitial(BASE_TIME);
47
+ await buffer.loadInitial(BASE_TIME + 30 * ONE_MIN);
48
+ expect(calls).toHaveLength(2);
49
+ expect(buffer.getBufferedRanges()).toHaveLength(1);
50
+ expect(buffer.getBufferedRanges()[0].from).toBe(BASE_TIME + 30 * ONE_MIN);
51
+ });
52
+ });
53
+ describe('getSnapshotAt', () => {
54
+ test('playHead 이하인 가장 가까운 스냅샷을 반환한다', async () => {
55
+ const snaps = makeSnapshots(BASE_TIME, BASE_TIME + CHUNK_DURATION, 5000);
56
+ const { fetcher } = createMockFetcher(snaps);
57
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
58
+ await buffer.loadInitial(BASE_TIME);
59
+ const snap = buffer.getSnapshotAt(BASE_TIME + 7000);
60
+ expect(snap).not.toBeNull();
61
+ expect(snap.timestamp).toBe(BASE_TIME + 5000); // 5초 스냅샷 (7초 이하 중 가장 가까운)
62
+ });
63
+ test('버퍼에 데이터가 없으면 null', async () => {
64
+ const { fetcher } = createMockFetcher([]);
65
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
66
+ await buffer.loadInitial(BASE_TIME);
67
+ expect(buffer.getSnapshotAt(BASE_TIME + 5000)).toBeNull();
68
+ });
69
+ test('playHead가 첫 스냅샷보다 이전이면 null', async () => {
70
+ const snaps = makeSnapshots(BASE_TIME + 10000, BASE_TIME + CHUNK_DURATION, 5000);
71
+ const { fetcher } = createMockFetcher(snaps);
72
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
73
+ await buffer.loadInitial(BASE_TIME);
74
+ expect(buffer.getSnapshotAt(BASE_TIME + 5000)).toBeNull();
75
+ });
76
+ });
77
+ describe('getNextSnapshotTime', () => {
78
+ test('현재 이후 가장 가까운 스냅샷 시간을 반환한다', async () => {
79
+ const snaps = makeSnapshots(BASE_TIME, BASE_TIME + CHUNK_DURATION, 5000);
80
+ const { fetcher } = createMockFetcher(snaps);
81
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
82
+ await buffer.loadInitial(BASE_TIME);
83
+ expect(buffer.getNextSnapshotTime(BASE_TIME + 3000)).toBe(BASE_TIME + 5000);
84
+ });
85
+ test('마지막 스냅샷 이후면 null', async () => {
86
+ const snaps = makeSnapshots(BASE_TIME, BASE_TIME + 10000, 5000);
87
+ const { fetcher } = createMockFetcher(snaps);
88
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
89
+ await buffer.loadInitial(BASE_TIME);
90
+ expect(buffer.getNextSnapshotTime(BASE_TIME + 10000)).toBeNull();
91
+ });
92
+ });
93
+ describe('checkPrefetch', () => {
94
+ test('남은 버퍼가 1분 이하이면 다음 청크를 prefetch한다', async () => {
95
+ const { fetcher, calls } = createMockFetcher();
96
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
97
+ await buffer.loadInitial(BASE_TIME);
98
+ expect(calls).toHaveLength(1);
99
+ // playHead를 9분 위치로 (남은 1분)
100
+ await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN);
101
+ expect(calls).toHaveLength(2);
102
+ expect(calls[1].from).toBe(BASE_TIME + CHUNK_DURATION); // 10분 후부터
103
+ });
104
+ test('남은 버퍼가 1분 초과이면 prefetch하지 않는다', async () => {
105
+ const { fetcher, calls } = createMockFetcher();
106
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
107
+ await buffer.loadInitial(BASE_TIME);
108
+ // playHead를 5분 위치로 (남은 5분)
109
+ await buffer.checkPrefetch(BASE_TIME + 5 * ONE_MIN);
110
+ expect(calls).toHaveLength(1); // 추가 fetch 없음
111
+ });
112
+ test('전체 범위 끝에 도달하면 prefetch하지 않는다', async () => {
113
+ const shortRange = {
114
+ from: new Date(BASE_TIME),
115
+ to: new Date(BASE_TIME + CHUNK_DURATION) // 전체 10분
116
+ };
117
+ const { fetcher, calls } = createMockFetcher();
118
+ const buffer = new PlaybackBuffer(fetcher, shortRange);
119
+ await buffer.loadInitial(BASE_TIME);
120
+ // 9분 위치 — 하지만 전체 범위가 10분이므로 더 가져올 게 없음
121
+ await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN);
122
+ expect(calls).toHaveLength(1);
123
+ });
124
+ test('중복 prefetch를 방지한다', async () => {
125
+ let resolveSecond;
126
+ let fetchCount = 0;
127
+ const slowFetcher = async (from, to) => {
128
+ fetchCount++;
129
+ if (fetchCount === 2) {
130
+ // 두 번째 fetch는 느리게
131
+ await new Promise(r => { resolveSecond = r; });
132
+ }
133
+ return makeSnapshots(from.getTime(), to.getTime());
134
+ };
135
+ const buffer = new PlaybackBuffer(slowFetcher, totalRange);
136
+ await buffer.loadInitial(BASE_TIME);
137
+ // 동시에 두 번 checkPrefetch
138
+ const p1 = buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN);
139
+ const p2 = buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN);
140
+ resolveSecond();
141
+ await Promise.all([p1, p2]);
142
+ // 초기 1 + prefetch 1 = 2 (중복 방지)
143
+ expect(fetchCount).toBe(2);
144
+ });
145
+ });
146
+ describe('seek', () => {
147
+ test('기존 버퍼를 폐기하고 새 위치에서 로드한다', async () => {
148
+ const { fetcher, calls } = createMockFetcher();
149
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
150
+ await buffer.loadInitial(BASE_TIME);
151
+ expect(buffer.getBufferedRanges()).toHaveLength(1);
152
+ await buffer.seek(BASE_TIME + 30 * ONE_MIN);
153
+ expect(buffer.getBufferedRanges()).toHaveLength(1); // 이전 버퍼 폐기, 새 청크 1개
154
+ expect(buffer.getBufferedRanges()[0].from).toBe(BASE_TIME + 30 * ONE_MIN);
155
+ });
156
+ test('seek 후 이전 데이터에 접근 불가', async () => {
157
+ const snaps = makeSnapshots(BASE_TIME, BASE_TIME + ONE_HOUR, 5000);
158
+ const { fetcher } = createMockFetcher(snaps);
159
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
160
+ await buffer.loadInitial(BASE_TIME);
161
+ expect(buffer.getSnapshotAt(BASE_TIME + 5000)).not.toBeNull();
162
+ await buffer.seek(BASE_TIME + 30 * ONE_MIN);
163
+ expect(buffer.getSnapshotAt(BASE_TIME + 5000)).toBeNull(); // 이전 데이터 없음
164
+ });
165
+ });
166
+ describe('getBufferedRanges', () => {
167
+ test('로드된 청크들의 시간 범위를 반환한다', async () => {
168
+ const { fetcher } = createMockFetcher();
169
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
170
+ await buffer.loadInitial(BASE_TIME);
171
+ await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN); // prefetch 트리거
172
+ const ranges = buffer.getBufferedRanges();
173
+ expect(ranges).toHaveLength(2);
174
+ expect(ranges[0].from).toBe(BASE_TIME);
175
+ expect(ranges[0].to).toBe(BASE_TIME + CHUNK_DURATION);
176
+ expect(ranges[1].from).toBe(BASE_TIME + CHUNK_DURATION);
177
+ expect(ranges[1].to).toBe(BASE_TIME + 2 * CHUNK_DURATION);
178
+ });
179
+ });
180
+ describe('hasDataAt', () => {
181
+ test('버퍼 내 시점은 true', async () => {
182
+ const { fetcher } = createMockFetcher();
183
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
184
+ await buffer.loadInitial(BASE_TIME);
185
+ expect(buffer.hasDataAt(BASE_TIME + 5 * ONE_MIN)).toBe(true);
186
+ });
187
+ test('버퍼 외 시점은 false', async () => {
188
+ const { fetcher } = createMockFetcher();
189
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
190
+ await buffer.loadInitial(BASE_TIME);
191
+ expect(buffer.hasDataAt(BASE_TIME + 15 * ONE_MIN)).toBe(false);
192
+ });
193
+ });
194
+ describe('clear', () => {
195
+ test('모든 버퍼를 폐기한다', async () => {
196
+ const { fetcher } = createMockFetcher();
197
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
198
+ await buffer.loadInitial(BASE_TIME);
199
+ expect(buffer.getBufferedRanges()).toHaveLength(1);
200
+ buffer.clear();
201
+ expect(buffer.getBufferedRanges()).toHaveLength(0);
202
+ });
203
+ });
204
+ describe('연속 재생 시나리오', () => {
205
+ test('10분 → prefetch → 20분 연속 재생', async () => {
206
+ const { fetcher, calls } = createMockFetcher();
207
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
208
+ // 1. 초기 로드 (0~10분)
209
+ await buffer.loadInitial(BASE_TIME);
210
+ expect(calls).toHaveLength(1);
211
+ // 2. 5분 위치 — prefetch 안 함
212
+ await buffer.checkPrefetch(BASE_TIME + 5 * ONE_MIN);
213
+ expect(calls).toHaveLength(1);
214
+ // 3. 9분 위치 — prefetch 트리거 (10~20분)
215
+ await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN);
216
+ expect(calls).toHaveLength(2);
217
+ // 4. 15분 위치 — 두 번째 청크에서 데이터 조회 가능
218
+ expect(buffer.hasDataAt(BASE_TIME + 15 * ONE_MIN)).toBe(true);
219
+ // 5. 19분 위치 — 다시 prefetch (20~30분)
220
+ await buffer.checkPrefetch(BASE_TIME + 19 * ONE_MIN);
221
+ expect(calls).toHaveLength(3);
222
+ });
223
+ test('seek 후 prefetch 정상 동작', async () => {
224
+ const { fetcher, calls } = createMockFetcher();
225
+ const buffer = new PlaybackBuffer(fetcher, totalRange);
226
+ await buffer.loadInitial(BASE_TIME);
227
+ // seek to 40분
228
+ await buffer.seek(BASE_TIME + 40 * ONE_MIN);
229
+ expect(calls).toHaveLength(2); // initial + seek
230
+ // 49분 위치 — prefetch (50~60분)
231
+ await buffer.checkPrefetch(BASE_TIME + 49 * ONE_MIN);
232
+ expect(calls).toHaveLength(3);
233
+ expect(calls[2].from).toBe(BASE_TIME + 50 * ONE_MIN);
234
+ });
235
+ });
236
+ describe('경계 조건', () => {
237
+ test('전체 범위가 10분 미만', async () => {
238
+ const shortRange = {
239
+ from: new Date(BASE_TIME),
240
+ to: new Date(BASE_TIME + 5 * ONE_MIN)
241
+ };
242
+ const { fetcher, calls } = createMockFetcher();
243
+ const buffer = new PlaybackBuffer(fetcher, shortRange);
244
+ await buffer.loadInitial(BASE_TIME);
245
+ expect(calls).toHaveLength(1);
246
+ expect(calls[0].to).toBe(BASE_TIME + 5 * ONE_MIN); // 5분까지만
247
+ });
248
+ test('끝 경계에서 청크 크기 조정', async () => {
249
+ const range55min = {
250
+ from: new Date(BASE_TIME),
251
+ to: new Date(BASE_TIME + 55 * ONE_MIN)
252
+ };
253
+ const { fetcher, calls } = createMockFetcher();
254
+ const buffer = new PlaybackBuffer(fetcher, range55min);
255
+ // 50분 위치에서 시작
256
+ await buffer.loadInitial(BASE_TIME + 50 * ONE_MIN);
257
+ expect(calls[0].to).toBe(BASE_TIME + 55 * ONE_MIN); // 55분까지만 (10분이 아닌 5분)
258
+ });
259
+ });
260
+ });
261
+ //# sourceMappingURL=playback-buffer.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"playback-buffer.test.js","sourceRoot":"","sources":["../../../src/graphql/playback-buffer.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAoB,cAAc,EAAsB,MAAM,mBAAmB,CAAA;AAExG,iBAAiB;AAEjB,MAAM,SAAS,GAAG,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,OAAO,EAAE,CAAA;AAC5D,MAAM,QAAQ,GAAG,OAAO,CAAA;AACxB,MAAM,OAAO,GAAG,KAAK,CAAA;AAErB,SAAS,aAAa,CAAC,MAAc,EAAE,IAAY,EAAE,aAAqB,IAAI;IAC5E,MAAM,KAAK,GAAuB,EAAE,CAAA;IACpC,KAAK,IAAI,CAAC,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,IAAI,UAAU,EAAE,CAAC;QAC/C,KAAK,CAAC,IAAI,CAAC;YACT,SAAS,EAAE,CAAC;YACZ,IAAI,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,MAAM,EAAE,EAAE;SACzD,CAAC,CAAA;IACJ,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC;AAED,SAAS,iBAAiB,CAAC,YAAiC;IAC1D,MAAM,KAAK,GAAmC,EAAE,CAAA;IAEhD,MAAM,OAAO,GAAG,KAAK,EAAE,IAAU,EAAE,EAAQ,EAAE,EAAE;QAC7C,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,CAAA;QAEtD,IAAI,YAAY,EAAE,CAAC;YACjB,OAAO,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,IAAI,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,SAAS,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;QAC9F,CAAC;QAED,mBAAmB;QACnB,OAAO,aAAa,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;IACpD,CAAC,CAAA;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAA;AAC3B,CAAC;AAED,gCAAgC;AAEhC,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE;IAC9B,MAAM,UAAU,GAAG;QACjB,IAAI,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC;QACzB,EAAE,EAAE,IAAI,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;KACnC,CAAA;IAED,QAAQ,CAAC,aAAa,EAAE,GAAG,EAAE;QAC3B,IAAI,CAAC,cAAc,EAAE,KAAK,IAAI,EAAE;YAC9B,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC7B,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;YACrC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,cAAc,CAAC,CAAA;QACtD,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,qBAAqB,EAAE,KAAK,IAAI,EAAE;YACrC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YACnC,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;YAElD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC7B,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAClD,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;QAC3E,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,eAAe,EAAE,GAAG,EAAE;QAC7B,IAAI,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;YAC/C,MAAM,KAAK,GAAG,aAAa,CAAC,SAAS,EAAE,SAAS,GAAG,cAAc,EAAE,IAAI,CAAC,CAAA;YACxE,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;YAC5C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,IAAI,GAAG,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;YACnD,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAA;YAC3B,MAAM,CAAC,IAAK,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA,CAAC,0BAA0B;QAC3E,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;YACnC,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,EAAE,CAAC,CAAA;YACzC,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;QAC3D,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,6BAA6B,EAAE,KAAK,IAAI,EAAE;YAC7C,MAAM,KAAK,GAAG,aAAa,CAAC,SAAS,GAAG,KAAK,EAAE,SAAS,GAAG,cAAc,EAAE,IAAI,CAAC,CAAA;YAChF,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;YAC5C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;QAC3D,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,qBAAqB,EAAE,GAAG,EAAE;QACnC,IAAI,CAAC,2BAA2B,EAAE,KAAK,IAAI,EAAE;YAC3C,MAAM,KAAK,GAAG,aAAa,CAAC,SAAS,EAAE,SAAS,GAAG,cAAc,EAAE,IAAI,CAAC,CAAA;YACxE,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;YAC5C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;QAC7E,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,kBAAkB,EAAE,KAAK,IAAI,EAAE;YAClC,MAAM,KAAK,GAAG,aAAa,CAAC,SAAS,EAAE,SAAS,GAAG,KAAK,EAAE,IAAI,CAAC,CAAA;YAC/D,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;YAC5C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,SAAS,GAAG,KAAK,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;QAClE,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,eAAe,EAAE,GAAG,EAAE;QAC7B,IAAI,CAAC,kCAAkC,EAAE,KAAK,IAAI,EAAE;YAClD,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YACnC,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAE7B,2BAA2B;YAC3B,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;YAEnD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC7B,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,cAAc,CAAC,CAAA,CAAC,UAAU;QACnE,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,+BAA+B,EAAE,KAAK,IAAI,EAAE;YAC/C,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,2BAA2B;YAC3B,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;YAEnD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA,CAAC,cAAc;QAC9C,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,8BAA8B,EAAE,KAAK,IAAI,EAAE;YAC9C,MAAM,UAAU,GAAG;gBACjB,IAAI,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC;gBACzB,EAAE,EAAE,IAAI,IAAI,CAAC,SAAS,GAAG,cAAc,CAAC,CAAC,SAAS;aACnD,CAAA;YACD,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,uCAAuC;YACvC,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;YAEnD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;YACnC,IAAI,aAAyB,CAAA;YAC7B,IAAI,UAAU,GAAG,CAAC,CAAA;YAElB,MAAM,WAAW,GAAG,KAAK,EAAE,IAAU,EAAE,EAAQ,EAAE,EAAE;gBACjD,UAAU,EAAE,CAAA;gBACZ,IAAI,UAAU,KAAK,CAAC,EAAE,CAAC;oBACrB,kBAAkB;oBAClB,MAAM,IAAI,OAAO,CAAO,CAAC,CAAC,EAAE,GAAG,aAAa,GAAG,CAAC,CAAA,CAAC,CAAC,CAAC,CAAA;gBACrD,CAAC;gBACD,OAAO,aAAa,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;YACpD,CAAC,CAAA;YAED,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,WAAW,EAAE,UAAU,CAAC,CAAA;YAC1D,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,wBAAwB;YACxB,MAAM,EAAE,GAAG,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;YACxD,MAAM,EAAE,GAAG,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;YAExD,aAAc,EAAE,CAAA;YAChB,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAA;YAE3B,gCAAgC;YAChC,MAAM,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAC5B,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,MAAM,EAAE,GAAG,EAAE;QACpB,IAAI,CAAC,yBAAyB,EAAE,KAAK,IAAI,EAAE;YACzC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YACnC,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAElD,MAAM,MAAM,CAAC,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;YAE3C,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA,CAAC,oBAAoB;YACvE,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;QAC3E,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,sBAAsB,EAAE,KAAK,IAAI,EAAE;YACtC,MAAM,KAAK,GAAG,aAAa,CAAC,SAAS,EAAE,SAAS,GAAG,QAAQ,EAAE,IAAI,CAAC,CAAA;YAClE,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;YAC5C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YACnC,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAA;YAE7D,MAAM,MAAM,CAAC,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;YAC3C,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA,CAAC,YAAY;QACxE,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;QACjC,IAAI,CAAC,sBAAsB,EAAE,KAAK,IAAI,EAAE;YACtC,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,EAAE,CAAA;YACvC,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YACnC,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA,CAAC,eAAe;YAEnE,MAAM,MAAM,GAAG,MAAM,CAAC,iBAAiB,EAAE,CAAA;YACzC,MAAM,CAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC9B,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;YACtC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,cAAc,CAAC,CAAA;YACrD,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,cAAc,CAAC,CAAA;YACvD,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,CAAC,GAAG,cAAc,CAAC,CAAA;QAC3D,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;QACzB,IAAI,CAAC,eAAe,EAAE,KAAK,IAAI,EAAE;YAC/B,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,EAAE,CAAA;YACvC,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC9D,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,gBAAgB,EAAE,KAAK,IAAI,EAAE;YAChC,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,EAAE,CAAA;YACvC,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAChE,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,OAAO,EAAE,GAAG,EAAE;QACrB,IAAI,CAAC,aAAa,EAAE,KAAK,IAAI,EAAE;YAC7B,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,EAAE,CAAA;YACvC,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YACnC,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAElD,MAAM,CAAC,KAAK,EAAE,CAAA;YACd,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QACpD,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,YAAY,EAAE,GAAG,EAAE;QAC1B,IAAI,CAAC,4BAA4B,EAAE,KAAK,IAAI,EAAE;YAC5C,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,mBAAmB;YACnB,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YACnC,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAE7B,0BAA0B;YAC1B,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;YACnD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAE7B,mCAAmC;YACnC,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;YACnD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAE7B,kCAAkC;YAClC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YAE7D,mCAAmC;YACnC,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;YACpD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;QAC/B,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,uBAAuB,EAAE,KAAK,IAAI,EAAE;YACvC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,cAAc;YACd,MAAM,MAAM,CAAC,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;YAC3C,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA,CAAC,iBAAiB;YAE/C,6BAA6B;YAC7B,MAAM,MAAM,CAAC,aAAa,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;YACpD,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC7B,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;QACtD,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,OAAO,EAAE,GAAG,EAAE;QACrB,IAAI,CAAC,eAAe,EAAE,KAAK,IAAI,EAAE;YAC/B,MAAM,UAAU,GAAG;gBACjB,IAAI,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC;gBACzB,EAAE,EAAE,IAAI,IAAI,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC;aACtC,CAAA;YACD,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,CAAC,CAAA;YAEnC,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC7B,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA,CAAC,QAAQ;QAC5D,CAAC,CAAC,CAAA;QAEF,IAAI,CAAC,iBAAiB,EAAE,KAAK,IAAI,EAAE;YACjC,MAAM,UAAU,GAAG;gBACjB,IAAI,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC;gBACzB,EAAE,EAAE,IAAI,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC;aACvC,CAAA;YACD,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,iBAAiB,EAAE,CAAA;YAC9C,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,OAAO,EAAE,UAAU,CAAC,CAAA;YAEtD,cAAc;YACd,MAAM,MAAM,CAAC,WAAW,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA;YAElD,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,EAAE,GAAG,OAAO,CAAC,CAAA,CAAC,sBAAsB;QAC3E,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { PlaybackBuffer, PlaybackSnapshot, CHUNK_DURATION, PREFETCH_THRESHOLD } from './playback-buffer'\n\n// --- 테스트 헬퍼 ---\n\nconst BASE_TIME = new Date('2026-04-12T10:00:00Z').getTime()\nconst ONE_HOUR = 3600000\nconst ONE_MIN = 60000\n\nfunction makeSnapshots(fromMs: number, toMs: number, intervalMs: number = 1000): PlaybackSnapshot[] {\n const snaps: PlaybackSnapshot[] = []\n for (let t = fromMs; t < toMs; t += intervalMs) {\n snaps.push({\n timestamp: t,\n data: { vehicle: { id: 'AGV01', position: t - fromMs } }\n })\n }\n return snaps\n}\n\nfunction createMockFetcher(allSnapshots?: PlaybackSnapshot[]) {\n const calls: { from: number; to: number }[] = []\n\n const fetcher = async (from: Date, to: Date) => {\n calls.push({ from: from.getTime(), to: to.getTime() })\n\n if (allSnapshots) {\n return allSnapshots.filter(s => s.timestamp >= from.getTime() && s.timestamp < to.getTime())\n }\n\n // 기본: 1초 간격 스냅샷 생성\n return makeSnapshots(from.getTime(), to.getTime())\n }\n\n return { fetcher, calls }\n}\n\n// --- PlaybackBuffer 단위 테스트 ---\n\ndescribe('PlaybackBuffer', () => {\n const totalRange = {\n from: new Date(BASE_TIME),\n to: new Date(BASE_TIME + ONE_HOUR)\n }\n\n describe('loadInitial', () => {\n test('10분 청크를 로드한다', async () => {\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(calls).toHaveLength(1)\n expect(calls[0].from).toBe(BASE_TIME)\n expect(calls[0].to).toBe(BASE_TIME + CHUNK_DURATION)\n })\n\n test('기존 버퍼를 폐기하고 새로 로드한다', async () => {\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n await buffer.loadInitial(BASE_TIME + 30 * ONE_MIN)\n\n expect(calls).toHaveLength(2)\n expect(buffer.getBufferedRanges()).toHaveLength(1)\n expect(buffer.getBufferedRanges()[0].from).toBe(BASE_TIME + 30 * ONE_MIN)\n })\n })\n\n describe('getSnapshotAt', () => {\n test('playHead 이하인 가장 가까운 스냅샷을 반환한다', async () => {\n const snaps = makeSnapshots(BASE_TIME, BASE_TIME + CHUNK_DURATION, 5000)\n const { fetcher } = createMockFetcher(snaps)\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n const snap = buffer.getSnapshotAt(BASE_TIME + 7000)\n expect(snap).not.toBeNull()\n expect(snap!.timestamp).toBe(BASE_TIME + 5000) // 5초 스냅샷 (7초 이하 중 가장 가까운)\n })\n\n test('버퍼에 데이터가 없으면 null', async () => {\n const { fetcher } = createMockFetcher([])\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(buffer.getSnapshotAt(BASE_TIME + 5000)).toBeNull()\n })\n\n test('playHead가 첫 스냅샷보다 이전이면 null', async () => {\n const snaps = makeSnapshots(BASE_TIME + 10000, BASE_TIME + CHUNK_DURATION, 5000)\n const { fetcher } = createMockFetcher(snaps)\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(buffer.getSnapshotAt(BASE_TIME + 5000)).toBeNull()\n })\n })\n\n describe('getNextSnapshotTime', () => {\n test('현재 이후 가장 가까운 스냅샷 시간을 반환한다', async () => {\n const snaps = makeSnapshots(BASE_TIME, BASE_TIME + CHUNK_DURATION, 5000)\n const { fetcher } = createMockFetcher(snaps)\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(buffer.getNextSnapshotTime(BASE_TIME + 3000)).toBe(BASE_TIME + 5000)\n })\n\n test('마지막 스냅샷 이후면 null', async () => {\n const snaps = makeSnapshots(BASE_TIME, BASE_TIME + 10000, 5000)\n const { fetcher } = createMockFetcher(snaps)\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(buffer.getNextSnapshotTime(BASE_TIME + 10000)).toBeNull()\n })\n })\n\n describe('checkPrefetch', () => {\n test('남은 버퍼가 1분 이하이면 다음 청크를 prefetch한다', async () => {\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n expect(calls).toHaveLength(1)\n\n // playHead를 9분 위치로 (남은 1분)\n await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN)\n\n expect(calls).toHaveLength(2)\n expect(calls[1].from).toBe(BASE_TIME + CHUNK_DURATION) // 10분 후부터\n })\n\n test('남은 버퍼가 1분 초과이면 prefetch하지 않는다', async () => {\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n // playHead를 5분 위치로 (남은 5분)\n await buffer.checkPrefetch(BASE_TIME + 5 * ONE_MIN)\n\n expect(calls).toHaveLength(1) // 추가 fetch 없음\n })\n\n test('전체 범위 끝에 도달하면 prefetch하지 않는다', async () => {\n const shortRange = {\n from: new Date(BASE_TIME),\n to: new Date(BASE_TIME + CHUNK_DURATION) // 전체 10분\n }\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, shortRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n // 9분 위치 — 하지만 전체 범위가 10분이므로 더 가져올 게 없음\n await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN)\n\n expect(calls).toHaveLength(1)\n })\n\n test('중복 prefetch를 방지한다', async () => {\n let resolveSecond: () => void\n let fetchCount = 0\n\n const slowFetcher = async (from: Date, to: Date) => {\n fetchCount++\n if (fetchCount === 2) {\n // 두 번째 fetch는 느리게\n await new Promise<void>(r => { resolveSecond = r })\n }\n return makeSnapshots(from.getTime(), to.getTime())\n }\n\n const buffer = new PlaybackBuffer(slowFetcher, totalRange)\n await buffer.loadInitial(BASE_TIME)\n\n // 동시에 두 번 checkPrefetch\n const p1 = buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN)\n const p2 = buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN)\n\n resolveSecond!()\n await Promise.all([p1, p2])\n\n // 초기 1 + prefetch 1 = 2 (중복 방지)\n expect(fetchCount).toBe(2)\n })\n })\n\n describe('seek', () => {\n test('기존 버퍼를 폐기하고 새 위치에서 로드한다', async () => {\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n expect(buffer.getBufferedRanges()).toHaveLength(1)\n\n await buffer.seek(BASE_TIME + 30 * ONE_MIN)\n\n expect(buffer.getBufferedRanges()).toHaveLength(1) // 이전 버퍼 폐기, 새 청크 1개\n expect(buffer.getBufferedRanges()[0].from).toBe(BASE_TIME + 30 * ONE_MIN)\n })\n\n test('seek 후 이전 데이터에 접근 불가', async () => {\n const snaps = makeSnapshots(BASE_TIME, BASE_TIME + ONE_HOUR, 5000)\n const { fetcher } = createMockFetcher(snaps)\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n expect(buffer.getSnapshotAt(BASE_TIME + 5000)).not.toBeNull()\n\n await buffer.seek(BASE_TIME + 30 * ONE_MIN)\n expect(buffer.getSnapshotAt(BASE_TIME + 5000)).toBeNull() // 이전 데이터 없음\n })\n })\n\n describe('getBufferedRanges', () => {\n test('로드된 청크들의 시간 범위를 반환한다', async () => {\n const { fetcher } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN) // prefetch 트리거\n\n const ranges = buffer.getBufferedRanges()\n expect(ranges).toHaveLength(2)\n expect(ranges[0].from).toBe(BASE_TIME)\n expect(ranges[0].to).toBe(BASE_TIME + CHUNK_DURATION)\n expect(ranges[1].from).toBe(BASE_TIME + CHUNK_DURATION)\n expect(ranges[1].to).toBe(BASE_TIME + 2 * CHUNK_DURATION)\n })\n })\n\n describe('hasDataAt', () => {\n test('버퍼 내 시점은 true', async () => {\n const { fetcher } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(buffer.hasDataAt(BASE_TIME + 5 * ONE_MIN)).toBe(true)\n })\n\n test('버퍼 외 시점은 false', async () => {\n const { fetcher } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(buffer.hasDataAt(BASE_TIME + 15 * ONE_MIN)).toBe(false)\n })\n })\n\n describe('clear', () => {\n test('모든 버퍼를 폐기한다', async () => {\n const { fetcher } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n expect(buffer.getBufferedRanges()).toHaveLength(1)\n\n buffer.clear()\n expect(buffer.getBufferedRanges()).toHaveLength(0)\n })\n })\n\n describe('연속 재생 시나리오', () => {\n test('10분 → prefetch → 20분 연속 재생', async () => {\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n // 1. 초기 로드 (0~10분)\n await buffer.loadInitial(BASE_TIME)\n expect(calls).toHaveLength(1)\n\n // 2. 5분 위치 — prefetch 안 함\n await buffer.checkPrefetch(BASE_TIME + 5 * ONE_MIN)\n expect(calls).toHaveLength(1)\n\n // 3. 9분 위치 — prefetch 트리거 (10~20분)\n await buffer.checkPrefetch(BASE_TIME + 9 * ONE_MIN)\n expect(calls).toHaveLength(2)\n\n // 4. 15분 위치 — 두 번째 청크에서 데이터 조회 가능\n expect(buffer.hasDataAt(BASE_TIME + 15 * ONE_MIN)).toBe(true)\n\n // 5. 19분 위치 — 다시 prefetch (20~30분)\n await buffer.checkPrefetch(BASE_TIME + 19 * ONE_MIN)\n expect(calls).toHaveLength(3)\n })\n\n test('seek 후 prefetch 정상 동작', async () => {\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, totalRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n // seek to 40분\n await buffer.seek(BASE_TIME + 40 * ONE_MIN)\n expect(calls).toHaveLength(2) // initial + seek\n\n // 49분 위치 — prefetch (50~60분)\n await buffer.checkPrefetch(BASE_TIME + 49 * ONE_MIN)\n expect(calls).toHaveLength(3)\n expect(calls[2].from).toBe(BASE_TIME + 50 * ONE_MIN)\n })\n })\n\n describe('경계 조건', () => {\n test('전체 범위가 10분 미만', async () => {\n const shortRange = {\n from: new Date(BASE_TIME),\n to: new Date(BASE_TIME + 5 * ONE_MIN)\n }\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, shortRange)\n\n await buffer.loadInitial(BASE_TIME)\n\n expect(calls).toHaveLength(1)\n expect(calls[0].to).toBe(BASE_TIME + 5 * ONE_MIN) // 5분까지만\n })\n\n test('끝 경계에서 청크 크기 조정', async () => {\n const range55min = {\n from: new Date(BASE_TIME),\n to: new Date(BASE_TIME + 55 * ONE_MIN)\n }\n const { fetcher, calls } = createMockFetcher()\n const buffer = new PlaybackBuffer(fetcher, range55min)\n\n // 50분 위치에서 시작\n await buffer.loadInitial(BASE_TIME + 50 * ONE_MIN)\n\n expect(calls[0].to).toBe(BASE_TIME + 55 * ONE_MIN) // 55분까지만 (10분이 아닌 5분)\n })\n })\n})\n"]}