diff --git a/artifacts/tx-os/tests/upcoming-meeting-alert-animation.spec.mjs b/artifacts/tx-os/tests/upcoming-meeting-alert-animation.spec.mjs new file mode 100644 index 00000000..583c01aa --- /dev/null +++ b/artifacts/tx-os/tests/upcoming-meeting-alert-animation.spec.mjs @@ -0,0 +1,116 @@ +// Task #438: lightweight render test for the upcoming-meeting alert +// entrance animation. Asserts the alert panel renders with the +// matching scale-in/opacity transition, an accent ring (boxShadow with +// the user's accent color), and a pulsing animate-ping halo around the +// drag handle — the visual language we adopted from the incoming-note +// popup. +import { test, expect } from "@playwright/test"; +import pg from "pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) throw new Error("DATABASE_URL must be set"); + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); +const createdMeetingIds = []; + +async function loginViaUi(page, username, password) { + await page.goto("/login"); + await page.locator("#username").fill(username); + await page.locator("#password").fill(password); + await Promise.all([ + page.waitForURL((url) => !url.pathname.endsWith("/login"), { + timeout: 15_000, + }), + page.locator('form button[type="submit"]').click(), + ]); +} + +function todayLocal() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} +function timePlusMinutes(delta) { + const d = new Date(Date.now() + delta * 60_000); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:00`; +} + +test.afterAll(async () => { + if (createdMeetingIds.length > 0) { + await pool.query( + `DELETE FROM executive_meeting_alert_state WHERE meeting_id = ANY($1::int[])`, + [createdMeetingIds], + ); + await pool.query( + `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`, + [createdMeetingIds], + ); + await pool.query( + `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, + [createdMeetingIds], + ); + await pool.query( + `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, + [createdMeetingIds], + ); + } + await pool.end(); +}); + +test("upcoming-meeting alert renders with scale-in entrance, accent ring, and ping halo", async ({ + page, +}) => { + test.setTimeout(60_000); + const date = todayLocal(); + const startTime = timePlusMinutes(3); + const endTime = timePlusMinutes(33); + const { rows: dnRows } = await pool.query( + `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n FROM executive_meetings WHERE meeting_date = $1`, + [date], + ); + const { rows } = await pool.query( + `INSERT INTO executive_meetings + (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status) + VALUES ($1, $2, $3, $4, $5, $6, 'scheduled') + RETURNING id`, + [ + dnRows[0].n, + "ALERT_ANIM_TEST AR", + "ALERT_ANIM_TEST EN", + date, + startTime, + endTime, + ], + ); + createdMeetingIds.push(rows[0].id); + + await loginViaUi(page, "admin", "admin123"); + await page.goto("/"); + + const alert = page.getByTestId("upcoming-meeting-alert"); + await expect(alert).toBeVisible({ timeout: 15_000 }); + + // After the entrance animation settles the panel is fully visible + // (opacity-100 / scale-100). We assert the resolved styles describe + // the scale + opacity transition we configured. + const transition = await alert.evaluate( + (el) => getComputedStyle(el).transition, + ); + expect(transition).toMatch(/transform/); + expect(transition).toMatch(/opacity/); + // The cubic-bezier spring curve we use for the entrance. + expect(transition).toMatch(/cubic-bezier\(0\.34,\s*1\.56,\s*0\.64,\s*1\)/); + + // Accent ring is implemented via boxShadow (so the color tracks the + // user's accent). Verify a 4px inset-less colored ring is the first + // shadow layer alongside the existing drop shadow. + const boxShadow = await alert.evaluate( + (el) => getComputedStyle(el).boxShadow, + ); + expect(boxShadow).toMatch(/rgba?\([^)]*\)\s+0px\s+0px\s+0px\s+4px/); + + // Pulsing accent halo around the drag handle (animate-ping element). + const handle = page.getByTestId("alert-drag-handle"); + await expect(handle).toBeVisible(); + const halo = handle.locator(".animate-ping").first(); + await expect(halo).toHaveCount(1); +});