1import { describe, expect, it } from "vitest"
2
3import {
4 INITIAL_CAPTURE_STATE,
5 reduce,
6 type CaptureEvent,
7 type CaptureState,
8} from "@/features/capture/machine"
9
10function run(events: CaptureEvent[], from: CaptureState = INITIAL_CAPTURE_STATE) {
11 return events.reduce(reduce, from)
12}
13
14const ready = run([{ type: "PERMISSION_REQUEST" }, { type: "PERMISSION_GRANTED" }])
15
16describe("mesin keadaan pemotretan", () => {
17 it("mengikuti alur izin: minta, diberi, ditolak, dan minta ulang", () => {
18 expect(INITIAL_CAPTURE_STATE.phase).toBe("idle")
19
20 const requesting = reduce(INITIAL_CAPTURE_STATE, { type: "PERMISSION_REQUEST" })
21 expect(requesting.phase).toBe("requesting")
22
23 expect(reduce(requesting, { type: "PERMISSION_GRANTED" })).toEqual({
24 phase: "ready",
25 countdownValue: null,
26 targetSlot: null,
27 deniedReason: null,
28 flash: false,
29 })
30
31 const denied = reduce(requesting, {
32 type: "PERMISSION_DENIED",
33 reason: "denied",
34 })
35 expect(denied.phase).toBe("denied")
36 expect(denied.deniedReason).toBe("denied")
37
38 const unavailable = reduce(requesting, {
39 type: "PERMISSION_DENIED",
40 reason: "unavailable",
41 })
42 expect(unavailable.deniedReason).toBe("unavailable")
43
44 /* "Coba Lagi" dari layar galat meminta izin sekali lagi. */
45 expect(reduce(denied, { type: "PERMISSION_REQUEST" }).phase).toBe("requesting")
46
47 /* Alasan galat dibersihkan setelah izin benar-benar diberikan. */
48 const granted = run(
49 [{ type: "PERMISSION_REQUEST" }, { type: "PERMISSION_GRANTED" }],
50 denied
51 )
52 expect(granted.phase).toBe("ready")
53 expect(granted.deniedReason).toBeNull()
54 })
55
56 it("siap ke hitung mundur lewat tombol, tanpa atau dengan slot sasaran", () => {
57 const shot = reduce(ready, { type: "SHOOT" })
58 expect(shot.phase).toBe("countdown")
59 expect(shot.countdownValue).toBe(3)
60 expect(shot.targetSlot).toBeNull()
61
62 const retake = reduce(ready, { type: "SHOOT", target: 2 })
63 expect(retake.targetSlot).toBe(2)
64 })
65
66 it("menahan gesture berjalan siap ke menahan dan batal kembali ke siap", () => {
67 const holding = reduce(ready, { type: "HOLD_START" })
68 expect(holding.phase).toBe("holding")
69
70 expect(reduce(holding, { type: "HOLD_CANCEL" }).phase).toBe("ready")
71
72 /* GS-02: tahanan penuh memulai hitung mundur dari slot kosong pertama. */
73 const completed = reduce(holding, { type: "HOLD_COMPLETE" })
74 expect(completed.phase).toBe("countdown")
75 expect(completed.countdownValue).toBe(3)
76 expect(completed.targetSlot).toBeNull()
77
78 /* GS-05: Spasi dan tombol kamera tetap bisa dipakai saat gesture ditahan. */
79 const shot = reduce(holding, { type: "SHOOT" })
80 expect(shot.phase).toBe("countdown")
81 expect(shot.countdownValue).toBe(3)
82
83 /* Selama menahan, perintah lain tidak berpengaruh. */
84 expect(reduce(holding, { type: "TICK" })).toBe(holding)
85 })
86
87 it("hitung mundur turun 3, 2, 1 lalu memotret", () => {
88 let state = reduce(ready, { type: "SHOOT", target: 1 })
89 expect(state.countdownValue).toBe(3)
90
91 state = reduce(state, { type: "TICK" })
92 expect([state.phase, state.countdownValue]).toEqual(["countdown", 2])
93
94 state = reduce(state, { type: "TICK" })
95 expect([state.phase, state.countdownValue]).toEqual(["countdown", 1])
96
97 state = reduce(state, { type: "TICK" })
98 expect(state.phase).toBe("capturing")
99 expect(state.countdownValue).toBeNull()
100 expect(state.targetSlot).toBe(1)
101 expect(state.flash).toBe(true)
102 })
103
104 it("kilat menyala sekali lalu padam, tanpa menunggu jeda selesai", () => {
105 const capturing = run([
106 { type: "SHOOT" },
107 { type: "TICK" },
108 { type: "TICK" },
109 { type: "TICK" },
110 ], ready)
111 expect(capturing.flash).toBe(true)
112
113 const pause = reduce(capturing, { type: "SHOT_SAVED" })
114 expect(pause.phase).toBe("pause")
115 expect(pause.flash).toBe(true)
116
117 const flashedOut = reduce(pause, { type: "FLASH_DONE" })
118 expect(flashedOut.flash).toBe(false)
119 expect(flashedOut.phase).toBe("pause")
120
121 /* Foto berikutnya menyalakan kilat lagi. */
122 const again = run([
123 { type: "PAUSE_DONE", allFilled: false },
124 { type: "SHOOT" },
125 { type: "TICK" },
126 { type: "TICK" },
127 { type: "TICK" },
128 ], flashedOut)
129 expect(again.flash).toBe(true)
130 })
131
132 it("memotret lalu jeda, dan jeda mengembalikan ke siap atau lengkap", () => {
133 const capturing = run([
134 { type: "SHOOT" },
135 { type: "TICK" },
136 { type: "TICK" },
137 { type: "TICK" },
138 ], ready)
139
140 const pause = reduce(capturing, { type: "SHOT_SAVED" })
141 expect(pause.phase).toBe("pause")
142
143 const back = reduce(pause, { type: "PAUSE_DONE", allFilled: false })
144 expect(back.phase).toBe("ready")
145 expect(back.targetSlot).toBeNull()
146
147 const complete = reduce(pause, { type: "PAUSE_DONE", allFilled: true })
148 expect(complete.phase).toBe("complete")
149 })
150
151 it("lengkap hanya menerima retake pada slot tertentu", () => {
152 const complete = run([
153 { type: "SHOOT" },
154 { type: "TICK" },
155 { type: "TICK" },
156 { type: "TICK" },
157 { type: "SHOT_SAVED" },
158 { type: "PAUSE_DONE", allFilled: true },
159 ], ready)
160 expect(complete.phase).toBe("complete")
161
162 /* Tombol jepret tidak memicu foto baru saat semua slot terisi. */
163 expect(reduce(complete, { type: "SHOOT" })).toBe(complete)
164
165 const retake = reduce(complete, { type: "SHOOT", target: 3 })
166 expect(retake.phase).toBe("countdown")
167 expect(retake.countdownValue).toBe(3)
168 expect(retake.targetSlot).toBe(3)
169 })
170
171 it("tidak mengubah state saat peristiwa datang di fase yang salah", () => {
172 const countdown = reduce(ready, { type: "SHOOT" })
173 const capturing = run([
174 { type: "TICK" },
175 { type: "TICK" },
176 { type: "TICK" },
177 ], countdown)
178 const pause = reduce(capturing, { type: "SHOT_SAVED" })
179 const complete = reduce(pause, { type: "PAUSE_DONE", allFilled: true })
180
181 const ignored: [CaptureState, CaptureEvent[]][] = [
182 [INITIAL_CAPTURE_STATE, [{ type: "SHOOT" }, { type: "TICK" }, { type: "HOLD_START" }]],
183 [reduce(INITIAL_CAPTURE_STATE, { type: "PERMISSION_REQUEST" }), [{ type: "TICK" }, { type: "SHOOT" }]],
184 [ready, [{ type: "TICK" }, { type: "PAUSE_DONE", allFilled: true }, { type: "SHOT_SAVED" }]],
185 [countdown, [{ type: "SHOOT" }, { type: "SHOT_SAVED" }, { type: "PAUSE_DONE", allFilled: true }]],
186 [capturing, [{ type: "TICK" }, { type: "SHOOT" }, { type: "PAUSE_DONE", allFilled: true }]],
187 [pause, [{ type: "SHOOT" }, { type: "TICK" }, { type: "SHOT_SAVED" }]],
188 [complete, [{ type: "TICK" }, { type: "HOLD_START" }, { type: "SHOT_SAVED" }]],
189 ]
190
191 for (const [state, events] of ignored) {
192 for (const event of events) {
193 expect(reduce(state, event)).toBe(state)
194 }
195 }
196 })
197})
198