1import { readFileSync } from "node:fs"
2import path from "node:path"
3import { expect, test, type Page } from "@playwright/test"
4
5import { canvasChecksum, masukAmbilFoto, masukHias, matikanGesture } from "./helpers"
6
7/* M4: halaman Hias Strip dan ekspor PNG (FR-09, FR-11, FR-10, FR-13).
8 Konvensi navigasi ada di tests/e2e/helpers.ts. */
9
10const PNG_BYTE_LIMIT = 2_097_152
11const FIXTURE_DIR = path.join(process.cwd(), "tests", "e2e", "fixtures")
12
13test.beforeEach(async ({ page }) => {
14 await matikanGesture(page)
15})
16
17/** Warna piksel kanvas strip dalam koordinat kanvas (device pixel). */
18async function canvasPixel(
19 page: Page,
20 x: number,
21 y: number
22): Promise<number[] | null> {
23 return page.evaluate(
24 ({ px, py }) => {
25 const canvas = document.querySelector("canvas")
26 if (canvas === null) {
27 return null
28 }
29 const context = canvas.getContext("2d")
30 if (context === null) {
31 return null
32 }
33 return Array.from(context.getImageData(px, py, 1, 1).data)
34 },
35 { px: x, py: y }
36 )
37}
38
39/* Piksel di tengah atas strip: di antara garis tepi dan slot pertama, jadi
40 isinya selalu warna latar frame. */
41async function stripBackgroundPixel(page: Page): Promise<number[] | null> {
42 const size = await page.evaluate(() => {
43 const canvas = document.querySelector("canvas")
44 return canvas === null ? null : [canvas.width, canvas.height]
45 })
46 if (size === null) {
47 return null
48 }
49 return canvasPixel(page, Math.round(size[0] / 2), Math.round(size[0] * 0.02))
50}
51
52async function collectScriptText(page: Page): Promise<string> {
53 const sources = await page.evaluate(() =>
54 Array.from(document.scripts)
55 .map((script) => script.src)
56 .filter((src) => src.length > 0)
57 )
58
59 const codes: string[] = []
60 for (const source of sources) {
61 const response = await page.request.get(source)
62 if (response.ok()) {
63 codes.push(await response.text())
64 }
65 }
66 return codes.join("\n")
67}
68
69test("hias lalu unduh PNG 600 x 1800 di bawah 2 MB", async ({ page }) => {
70 await masukHias(page)
71 await expect(page.getByRole("heading", { name: "Hias Strip" })).toBeVisible()
72
73 /* Token warna benar-benar terbaca kanvas: latar bawaan Klasik adalah paper. */
74 await expect
75 .poll(() => stripBackgroundPixel(page))
76 .toEqual([255, 253, 247, 255])
77
78 await page.getByRole("button", { name: "Malam", exact: true }).click()
79 await expect
80 .poll(() => stripBackgroundPixel(page))
81 .toEqual([20, 20, 20, 255])
82
83 /* Filter efek benar-benar mengubah gambar di kanvas. */
84 const sebelumEfek = await canvasChecksum(page)
85 await page.getByRole("radio", { name: "Efek" }).click()
86 await page.getByRole("button", { name: "Retro", exact: true }).click()
87 await expect.poll(() => canvasChecksum(page)).not.toBe(sebelumEfek)
88
89 await page.getByRole("radio", { name: "Stiker" }).click()
90 await page.getByRole("button", { name: "Tempel Bintang" }).click()
91 await expect(page.getByText("Stiker Bintang")).toBeVisible()
92
93 /* M5: aksi utama menavigasi ke langkah Hasil; unduhan dipicu di sana. */
94 await page.getByRole("link", { name: "Selesai & Unduh" }).click()
95 await page.waitForURL("**/booth/result")
96 await expect(page.getByAltText("Pratinjau strip")).toBeVisible()
97
98 const [download] = await Promise.all([
99 page.waitForEvent("download"),
100 page.getByRole("button", { name: "Selesai & Unduh" }).click(),
101 ])
102
103 await expect(page.getByText("Strip tersimpan di perangkatmu.")).toBeVisible()
104 expect(download.suggestedFilename()).toMatch(/^snapme-\d{8}-\d{4}\.png$/)
105
106 const berkas = await download.path()
107 if (berkas === null) {
108 throw new Error("Berkas unduhan tidak tersedia di disk")
109 }
110 const bytes = readFileSync(berkas)
111
112 /* FR-11: PNG 600 x 1800 px, di bawah 2 MB, tanpa metadata (kanvas tidak
113 pernah menulis EXIF). */
114 expect(bytes.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a")
115 expect(bytes.toString("ascii", 12, 16)).toBe("IHDR")
116 expect(bytes.readUInt32BE(16)).toBe(600)
117 expect(bytes.readUInt32BE(20)).toBe(1800)
118 expect(bytes.byteLength).toBeLessThan(PNG_BYTE_LIMIT)
119})
120
121test("stiker dikelola hanya dengan keyboard", async ({ page }) => {
122 await masukHias(page)
123
124 /* Menempel, memindah, memutar, memperbesar, dan menghapus tanpa satu pun
125 klik penunjuk: fokus dipindah lalu Enter ditekan. Tab "Stiker" dibuka
126 dengan cara yang sama. */
127 const tabStiker = page.getByRole("radio", { name: "Stiker" })
128 await tabStiker.focus()
129 await page.keyboard.press("Enter")
130
131 const tempel = page.getByRole("button", { name: "Tempel Bintang" })
132 await tempel.focus()
133 await page.keyboard.press("Enter")
134 await expect(page.getByText("x 300 · y 900 · 120 px · -6°")).toBeVisible()
135
136 const pindah = page.getByRole("button", { name: "Pindah kanan: Bintang" })
137 await pindah.focus()
138 await page.keyboard.press("Enter")
139 await expect(page.getByText("x 308 · y 900 · 120 px · -6°")).toBeVisible()
140
141 const putar = page.getByRole("button", { name: "Putar: Bintang" })
142 await putar.focus()
143 await page.keyboard.press("Enter")
144 await expect(page.getByText("x 308 · y 900 · 120 px · 9°")).toBeVisible()
145
146 const perbesar = page.getByRole("button", { name: "Perbesar: Bintang" })
147 await perbesar.focus()
148 await page.keyboard.press("Enter")
149 await expect(page.getByText("x 308 · y 900 · 132 px · 9°")).toBeVisible()
150
151 const hapus = page.getByRole("button", { name: "Hapus: Bintang" })
152 await hapus.focus()
153 await page.keyboard.press("Enter")
154 await expect(page.getByText("Stiker Bintang")).toHaveCount(0)
155 await expect(page.getByRole("region", { name: "Isi strip" })).toHaveCount(0)
156})
157
158test("pilihan hias di lembar bawah pada 360 px", async ({ page }) => {
159 await page.setViewportSize({ width: 360, height: 640 })
160 await masukHias(page)
161
162 /* Di layar sempit hanya tombol pembuka yang tampil, bukan rel pilihan. */
163 const pembuka = page.getByRole("button", { name: "Hiasan" })
164 await expect(pembuka).toBeVisible()
165 await pembuka.click()
166
167 const lembar = page.getByRole("dialog")
168 await expect(lembar.getByRole("radio", { name: "Stiker" })).toBeVisible()
169 await lembar.getByRole("radio", { name: "Stiker" }).click()
170 await lembar.getByRole("button", { name: "Tempel Bintang" }).click()
171 await expect(page.getByText("Stiker Bintang")).toBeVisible()
172
173 await lembar.getByRole("radio", { name: "Frame" }).click()
174 await lembar.getByRole("button", { name: "Malam", exact: true }).click()
175 /* Kanvas digambar ulang setelah state berubah, jadi pikselnya ditunggu:
176 di server produksi, penggambaran ulang itu baru mendarat di bingkai
177 berikutnya (M9). */
178 await expect.poll(() => stripBackgroundPixel(page)).toEqual([20, 20, 20, 255])
179})
180
181test("konva tidak ikut termuat di / dan /booth/capture", async ({ page }) => {
182 /* Konva memasang dirinya ke `window` saat modulnya dievaluasi
183 (`_injectGlobal` di Global.js), jadi ada tidaknya `window.Konva` adalah
184 bukti langsung bahwa pustakanya tidak ikut bundel halaman ini. Pemeriksaan
185 teks skrip dipakai untuk menyisir potongan kode penyunting kami, bukan
186 pustaka Konva: kata "Konva" juga muncul di komentar biasa. */
187 const konvaLoaded = () => page.evaluate(() => "Konva" in window)
188
189 await page.goto("/")
190 await expect(page.getByRole("link", { name: "Mulai Foto" }).first()).toBeVisible()
191 expect(await konvaLoaded()).toBe(false)
192 expect(await collectScriptText(page)).not.toContain("editor-overlay")
193
194 /* Langkah Ambil Foto butuh layout terpilih, jadi masuk lewat alurnya. */
195 await masukAmbilFoto(page)
196 await expect(page.getByRole("button", { name: "Izinkan Kamera" })).toBeVisible()
197 expect(await konvaLoaded()).toBe(false)
198 expect(await collectScriptText(page)).not.toContain("editor-overlay")
199})
200
201test("pakai foto galeri mengisi slot tanpa kamera", async ({ page }) => {
202 await page.goto("/")
203
204 /* Empat berkas masuk ke sesi bawaan Strip 4 lalu langsung ke Hias. */
205 const chooser = page.waitForEvent("filechooser")
206 await page.getByRole("button", { name: "Pakai Foto Galeri" }).click()
207 await (await chooser).setFiles([
208 path.join(FIXTURE_DIR, "foto-1.png"),
209 path.join(FIXTURE_DIR, "foto-2.png"),
210 path.join(FIXTURE_DIR, "foto-3.png"),
211 path.join(FIXTURE_DIR, "foto-4.png"),
212 ])
213
214 await page.waitForURL("**/booth/customize")
215 await expect(page.getByText("4 foto siap", { exact: true })).toBeVisible()
216})
217
218test("dua foto galeri dilengkapi di kamera", async ({ page }) => {
219 await page.goto("/")
220
221 const chooser = page.waitForEvent("filechooser")
222 await page.getByRole("button", { name: "Pakai Foto Galeri" }).click()
223 await (await chooser).setFiles([
224 path.join(FIXTURE_DIR, "foto-1.png"),
225 path.join(FIXTURE_DIR, "foto-2.png"),
226 ])
227
228 await page.waitForURL("**/booth/capture")
229 await expect(page.getByText("2 foto terpasang. Lengkapi sisanya di kamera.")).toBeVisible()
230 await expect(page.getByText("2/4")).toBeVisible()
231})
232
233test("teks dibatasi 24 karakter dan tanggal bisa dinyalakan", async ({ page }) => {
234 await masukHias(page)
235
236 await page.getByRole("radio", { name: "Teks" }).click()
237 const input = page.locator("#strip-text")
238 const panjang = "tiga puluh karakter lebih panjang"
239 await input.fill(panjang)
240 /* FR-10: maksimal 24 karakter, dijaga oleh store, bukan hanya oleh input. */
241 await expect(input).toHaveValue(panjang.slice(0, 24))
242
243 await page.getByRole("switch", { name: "Tanggal" }).click()
244 await expect(page.getByText("Nyala", { exact: true }).last()).toBeVisible()
245})
246