Files
TX/artifacts/tx-os/tests/executive-meetings-attendee-subheadings.spec.mjs
T
riyadhafraa 76f0dfacb4 Restart numbering for individuals after each subheading
Modify attendee numbering logic to reset at each subheading, ensuring proper sequence and display across different views and PDF outputs.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 17c29c3f-e55d-4765-875c-0387b6936812
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/piUNOmy
Replit-Helium-Checkpoint-Created: true
2026-04-30 12:47:50 +00:00

388 lines
13 KiB
JavaScript

import { test, expect } from "@playwright/test";
import pg from "pg";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load the same locale bundles the app reads from so any copy edits to
// the subheading labels stay covered without test churn.
function loadLocale(lang) {
const p = join(__dirname, "..", "src", "locales", `${lang}.json`);
return JSON.parse(readFileSync(p, "utf8"));
}
const localeBundles = { en: loadLocale("en"), ar: loadLocale("ar") };
// E2E coverage for Task #207 — custom subheadings inside the attendees
// cell. Subheadings are user-defined section labels that live alongside
// person rows in a meeting's attendee list, but are NOT counted as
// attendees and do NOT advance the running "1-, 2-, 3-…" person index.
//
// What we verify:
// 1. Mixed group: a meeting with 2 internal persons + 1 subheading
// between them + 1 more person renders all four rows. The
// subheading carries `data-testid="em-attendee-subheading-…"`,
// and the three persons get the indices 1-, 2-, 3-.
// 2. Zero subheading: a meeting with only persons keeps the legacy
// numbering exactly as before (no extra "subheading" testids,
// no off-by-one).
// Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the
// same.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings subheading UI tests",
);
}
// Parse to silence the "unused" lint until we use the bundle below.
void localeBundles;
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
// Pick a far-future, randomised base so we never collide with real
// scheduled meetings while running side by side with other specs that
// also seed in the future.
const RANDOM_DATE_BASE_DAYS =
900 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
return d.toISOString().slice(0, 10);
}
async function insertMeeting({
meetingDate,
dailyNumber,
titleEn,
titleAr,
startTime,
endTime,
}) {
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`,
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function insertAttendee({
meetingId,
name,
attendanceType,
sortOrder,
kind,
}) {
await pool.query(
`INSERT INTO executive_meeting_attendees
(meeting_id, name, attendance_type, sort_order, kind)
VALUES ($1, $2, $3, $4, $5)`,
[meetingId, name, attendanceType, sortOrder, kind ?? "person"],
);
}
async function loginViaUi(page) {
await page.goto("/login");
await page.locator("#username").fill("admin");
await page.locator("#password").fill("admin123");
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
let originalAdminPreferredLanguage = null;
async function setAdminPreferredLanguage(lang) {
const { rows } = await pool.query(
`UPDATE users SET preferred_language = $1
WHERE username = 'admin'
RETURNING preferred_language`,
[lang],
);
return rows[0]?.preferred_language;
}
test.beforeAll(async () => {
const { rows } = await pool.query(
`SELECT preferred_language FROM users WHERE username = 'admin'`,
);
originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar";
});
test.afterAll(async () => {
if (originalAdminPreferredLanguage) {
await pool
.query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [
originalAdminPreferredLanguage,
])
.catch(() => {
/* best-effort */
});
}
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_attendees 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_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
const langOffsets = { en: 1, ar: 2 };
for (const lang of ["en", "ar"]) {
test(`Schedule [${lang}]: subheading rows render and do not advance the person index`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Subheading Mixed ${lang} ${Date.now().toString(36)}`,
titleAr: `عنوان فرعي مختلط ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Internal group with a subheading wedged between the second and
// third person. The subheading starts a fresh numbered section, so
// persons render as 1-, 2- (before the subheading) and 1- (after),
// PLUS the subheading row itself.
await insertAttendee({
meetingId,
name: "Person Alpha",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Person Beta",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
// Distinctive label so the assertion below cannot trip on a
// person name that incidentally contains the same word.
name: "GuestsLabel-X9",
attendanceType: "internal",
sortOrder: 2,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "Person Gamma",
attendanceType: "internal",
sortOrder: 3,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
// Exactly one subheading row in this meeting.
const subheadings = row.locator(
'[data-testid^="em-attendee-subheading-"]',
);
await expect(subheadings).toHaveCount(1, { timeout: 10_000 });
await expect(subheadings.first()).toContainText("GuestsLabel-X9");
// Three person rows rendered (subheading is excluded by the
// person-only `em-attendee-row-…` testid).
const personRows = row.locator('[data-testid^="em-attendee-row-"]');
await expect(personRows).toHaveCount(3);
// Numbering restarts after the subheading: section 1 has Alpha
// and Beta numbered "1-" and "2-"; section 2 has Gamma alone,
// numbered "1-" (the cell has a subheading so we always show the
// index, even for a solo person inside a section).
const indices = row.locator('[data-testid^="em-attendee-index-"]');
await expect(indices).toHaveCount(3);
const indexTexts = await indices.allInnerTexts();
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, ""));
expect(trimmed).toEqual(["1-", "2-", "1-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Schedule [${lang}]: subheading-only group survives deletion of all its persons (hasSplit preservation)`, async ({
page,
}) => {
// Regression guard for the hasSplit edge case the architect flagged:
// if a group still has a subheading after the user deletes every
// person in it, the group must keep its own AttendeeGroup wrapper
// (and its label) — its subheading should NOT silently fold into
// a sibling group's row. We seed virtual = [subheading] +
// internal = [persons] directly via the DB to skip the editing
// path and assert only the rendering invariant.
const date = uniqueFutureDate(langOffsets[lang] + 10);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Sub Solo Group ${lang} ${Date.now().toString(36)}`,
titleAr: `قسم بعنوان فرعي ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Virtual group: ONE subheading row, zero persons. This is the
// post-delete state described in the architect comment.
await insertAttendee({
meetingId,
name: "Virtual Block Label",
attendanceType: "virtual",
sortOrder: 0,
kind: "subheading",
});
// Internal group: two real persons.
await insertAttendee({
meetingId,
name: "Internal Person 1",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Internal Person 2",
attendanceType: "internal",
sortOrder: 2,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
// The subheading row must still be in the DOM, with its label
// text intact.
const subheadings = row.locator(
'[data-testid^="em-attendee-subheading-"]',
);
await expect(subheadings).toHaveCount(1, { timeout: 10_000 });
await expect(subheadings.first()).toContainText("Virtual Block Label");
// The two internal persons must still be numbered 1- and 2- (the
// virtual subheading does NOT get a number, and crucially does
// NOT push the internal counter to start at 2).
const indices = row.locator('[data-testid^="em-attendee-index-"]');
await expect(indices).toHaveCount(2);
const indexTexts = (await indices.allInnerTexts()).map((s) =>
s.trim().replace(/\s+/g, ""),
);
expect(indexTexts).toEqual(["1-", "2-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort */
},
);
}
}
});
test(`Schedule [${lang}]: meeting with no subheadings still numbers persons 1..N with no stray subheading rows`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang] + 5);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `No Subheadings ${lang} ${Date.now().toString(36)}`,
titleAr: `بدون عناوين فرعية ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await insertAttendee({
meetingId,
name: "Solo Person A",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Solo Person B",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
// Zero subheadings — make sure the new code path didn't sneak
// one in for legacy "person"-only meetings.
const subheadings = row.locator(
'[data-testid^="em-attendee-subheading-"]',
);
await expect(subheadings).toHaveCount(0);
const indices = row.locator('[data-testid^="em-attendee-index-"]');
await expect(indices).toHaveCount(2);
const trimmed = (await indices.allInnerTexts()).map((s) =>
s.trim().replace(/\s+/g, ""),
);
expect(trimmed).toEqual(["1-", "2-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort */
},
);
}
}
});
}