1import { expect, test } from "@playwright/test"
2
3import {
4 izinkanKamera,
5 jepret,
6 masukAmbilFoto,
7 matikanGesture,
8 srcSlot,
9} from "./helpers"
10
11/* FR-04 sampai FR-07 dengan kamera palsu Chromium (ARCHITECTURE §10).
12 Alur menuju langkah booth ada di tests/e2e/helpers.ts. */
13
14test.beforeEach(async ({ page }) => {
15 await matikanGesture(page)
16})
17
18declare global {
19 interface Window {
20 __flashSeen?: boolean
21 }
22}
23
24test("empat foto terambil, satu diulang, lalu lanjut ke hias", async ({ page }) => {
25 await page.goto("/")
26 await page.getByRole("link", { name: "Mulai Foto" }).last().click()
27 await page.waitForURL("**/booth/layout")
28 await page.getByRole("radio", { name: "Strip 4 foto" }).check()
29 await page.getByRole("button", { name: "Mulai Foto" }).click()
30 await expect(page).toHaveURL("/booth/capture")
31
32 /* FR-04: layar penjelas dulu, kamera belum diminta. */
33 await expect(
34 page.getByRole("button", { name: "Izinkan Kamera" })
35 ).toBeVisible()
36 await expect(page.locator("video")).toHaveCount(0)
37
38 await izinkanKamera(page)
39 await expect(page.getByText("0/4")).toBeVisible()
40
41 await jepret(page)
42 await expect(page.getByText("1/4")).toBeVisible()
43
44 await jepret(page)
45 await expect(page.getByText("2/4")).toBeVisible()
46 await expect(page.getByRole("button", { name: "Ulang foto 1" })).toBeVisible()
47 await expect(page.getByRole("button", { name: "Ulang foto 2" })).toBeVisible()
48
49 /* FR-07: ulang foto terakhir mengganti slot 2 tanpa menghapus slot 1. */
50 const sebelum = await srcSlot(page, 2)
51 await page.getByRole("button", { name: "Ulang foto terakhir" }).click()
52 await expect(page.getByTestId("countdown")).toBeVisible()
53 await expect(page.getByTestId("countdown")).toHaveCount(0)
54 await expect.poll(() => srcSlot(page, 2)).not.toBe(sebelum)
55
56 const slot1Sesudah = await srcSlot(page, 1)
57 expect(slot1Sesudah).not.toBeNull()
58 await expect(page.getByText("2/4")).toBeVisible()
59
60 await jepret(page)
61 await jepret(page)
62 await expect(page.getByText("4/4")).toBeVisible()
63 await expect(page.getByText("Semua foto sudah ada.")).toBeVisible()
64
65 /* FR-07: ulang foto tertentu saat semua slot terisi. */
66 const slot1Lengkap = await srcSlot(page, 1)
67 await page.getByRole("button", { name: "Ulang foto 1" }).click()
68 await expect(page.getByTestId("countdown")).toBeVisible()
69 await expect(page.getByTestId("countdown")).toHaveCount(0)
70 await expect.poll(() => srcSlot(page, 1)).not.toBe(slot1Lengkap)
71 await expect(page.getByText("4/4")).toBeVisible()
72
73 const lanjut = page.getByRole("button", { name: "Lanjut Hias" })
74 await expect(lanjut).toBeEnabled()
75 await lanjut.click()
76
77 await expect(page).toHaveURL("/booth/customize")
78 await expect(page.getByRole("heading", { name: "Hias Strip" })).toBeVisible()
79 /* Persis: rute lama tetap ada di DOM secara tersembunyi (Next menyimpannya),
80 dan HUD halaman capture memuat teks "4 foto siap dihias". */
81 await expect(page.getByText("4 foto siap", { exact: true })).toBeVisible()
82})
83
84test("izin ditolak menampilkan pesan dan jalan keluar", async ({ page }) => {
85 await page.addInitScript(() => {
86 navigator.mediaDevices.getUserMedia = () =>
87 Promise.reject(new DOMException("Denied in test", "NotAllowedError"))
88 })
89
90 await masukAmbilFoto(page)
91 await page.getByRole("button", { name: "Izinkan Kamera" }).click()
92
93 await expect(
94 page.getByText(
95 "Kamera belum diizinkan. Buka pengaturan situs di browser, izinkan kamera, lalu muat ulang."
96 )
97 ).toBeVisible()
98 await expect(page.getByRole("button", { name: "Coba Lagi" })).toBeVisible()
99 await expect(page.locator("video")).toHaveCount(0)
100})
101
102test("Spasi mengambil foto saat fokus di area kamera", async ({ page }) => {
103 await masukAmbilFoto(page)
104 await izinkanKamera(page)
105
106 await page.getByRole("group", { name: "Area kamera" }).focus()
107 await page.keyboard.press("Space")
108
109 await expect(page.getByTestId("countdown")).toHaveText("3")
110 await expect(page.getByText("1/4")).toBeVisible()
111})
112
113test("kilat tidak muncul saat prefers-reduced-motion", async ({ page }) => {
114 /* MutationObserver mencatat kalau lapisan kilat sempat dirender. */
115 await page.addInitScript(() => {
116 window.__flashSeen = false
117 new MutationObserver((records) => {
118 for (const record of records) {
119 for (const node of record.addedNodes) {
120 if (
121 node instanceof HTMLElement &&
122 (node.dataset.testid === "flash" ||
123 node.querySelector('[data-testid="flash"]') !== null)
124 ) {
125 window.__flashSeen = true
126 }
127 }
128 }
129 }).observe(document.documentElement, { childList: true, subtree: true })
130 })
131 await page.emulateMedia({ reducedMotion: "reduce" })
132
133 await masukAmbilFoto(page)
134 await izinkanKamera(page)
135 await jepret(page)
136
137 await expect(page.getByText("1/4")).toBeVisible()
138 expect(await page.evaluate(() => window.__flashSeen)).toBe(false)
139})
140
141test("foto tersimpan sama dengan pratinjau tercermin dan berasio slot", async ({
142 page,
143}) => {
144 /* Stream sintetis: kanvas hijau dengan persegi merah di kiri tengah.
145 Stream baru tiap panggilan: react-webcam meminta ulang saat mount ganda
146 dan menghentikan stream yang dianggap basi. */
147 await page.addInitScript(() => {
148 const buatStream = () => {
149 const canvas = document.createElement("canvas")
150 canvas.width = 1280
151 canvas.height = 720
152 canvas.style.cssText =
153 "position:fixed;top:0;left:0;width:4px;height:4px;opacity:0.01;pointer-events:none"
154 const context = canvas.getContext("2d")
155 if (context === null) {
156 throw new Error("Konteks 2D tidak tersedia untuk stream uji")
157 }
158 const gambar = () => {
159 context.fillStyle = "#00e000"
160 context.fillRect(0, 0, canvas.width, canvas.height)
161 context.fillStyle = "#e00000"
162 context.fillRect(200, 320, 80, 80)
163 }
164 gambar()
165 document.body.appendChild(canvas)
166 const stream = canvas.captureStream(30)
167 /* Kanvas harus terus digambar supaya bingkai mengalir ke elemen video. */
168 setInterval(gambar, 100)
169 return stream
170 }
171 navigator.mediaDevices.getUserMedia = async () => buatStream()
172 })
173
174 await masukAmbilFoto(page)
175 await izinkanKamera(page)
176 await jepret(page)
177 await expect(page.getByText("1/4")).toBeVisible()
178
179 const sample = await page.evaluate(async () => {
180 const img = document.querySelector<HTMLImageElement>(
181 'button[aria-label="Ulang foto 1"] img'
182 )
183 const canvas = document.createElement("canvas")
184 const context = canvas.getContext("2d")
185 if (img === null || context === null) {
186 return null
187 }
188 await img.decode()
189 canvas.width = img.naturalWidth
190 canvas.height = img.naturalHeight
191 context.drawImage(img, 0, 0)
192 const baca = (x: number, y: number) =>
193 Array.from(context.getImageData(x, y, 1, 1).data)
194 return {
195 width: img.naturalWidth,
196 height: img.naturalHeight,
197 kiriTengah: baca(Math.round(img.naturalWidth * 0.109), Math.round(img.naturalHeight * 0.5)),
198 kananTengah: baca(Math.round(img.naturalWidth * 0.891), Math.round(img.naturalHeight * 0.5)),
199 }
200 })
201
202 expect(sample).not.toBeNull()
203 if (sample === null) {
204 return
205 }
206
207 /* Rasio crop mengikuti slot strip-4 (528 x 372). */
208 expect(sample.width / sample.height).toBeCloseTo(528 / 372, 2)
209
210 /* Persegi merah semula di kiri; hasil tercermin menaruhnya di kanan. */
211 const [kiriR, kiriG] = sample.kiriTengah
212 const [kananR, kananG] = sample.kananTengah
213 expect(kiriG).toBeGreaterThan(180)
214 expect(kiriR).toBeLessThan(80)
215 expect(kananR).toBeGreaterThan(180)
216 expect(kananG).toBeLessThan(80)
217})
218