lotr-sut/tests/e2e/pages/QuestsPage.ts
Fellowship Scholar f6a5823439 init commit
2026-03-29 20:07:56 +00:00

453 lines
16 KiB
TypeScript

import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { Quest } from '../types';
/**
* Quests Page Object
* Handles all interactions on the /quests route
*
* Real DOM structure (QuestsPage.tsx + QuestForm.tsx + QuestList.tsx):
* Button "🗂️ Propose a Quest" — opens QuestForm overlay
* QuestForm overlay:
* #title input
* #description textarea
* #quest_type select
* #priority select
* #status select
* #location select (required)
* button "Propose Quest" / "Revise Quest"
* QuestList:
* [data-testid="quest-item"][data-quest-title="…"]
* button "✓ Complete Quest"
* button "✏️ Revise"
* button "✕ Abandon"
* Filter buttons (plain <button> elements):
* Status: "All Quests", "In Progress", "Completed", "Blocked"
* Type: "All Types", "The Journey", "The Battle", "The Fellowship", "The Ring"
* Location: per-location buttons
* QuestCompletionModal:
* h2 "⚔️ Quest Challenge"
*/
export class QuestsPage extends BasePage {
/** Title of the quest targeted by the last clickQuest() call */
private selectedQuestTitle: string | null = null;
// --- Form (inside quest-form-overlay) ---
private readonly formOverlay = () => this.page.locator('.quest-form-overlay').first();
private readonly questTitleInput = () => this.formOverlay().locator('#title').first();
private readonly questDescriptionInput = () => this.formOverlay().locator('#description').first();
private readonly questTypeSelect = () => this.formOverlay().locator('#quest_type').first();
private readonly questPrioritySelect = () => this.formOverlay().locator('#priority').first();
private readonly questStatusSelect = () => this.formOverlay().locator('#status').first();
private readonly questLocationSelect = () => this.formOverlay().locator('#location').first();
private readonly submitQuestButton = () =>
this.formOverlay().locator('button[type="submit"]').first();
// --- Quest list items ---
private readonly questItems = () => this.page.locator('[data-testid="quest-item"]');
// Returns the card element for a given quest title
private questCard(title: string) {
return this.page.locator(`[data-testid="quest-item"][data-quest-title="${title}"]`).first();
}
// --- Completion mini-game modal ---
private readonly miniGameModal = () =>
this.page.locator('h2:has-text("Quest Challenge")').locator('../../..').first();
async goto(): Promise<void> {
// Don't use page.goto() as it remounts the React app and loses session state
// Instead, use internal React Router navigation via page.click() on the nav link
const currentUrl = this.page.url();
if (currentUrl.includes('/quests')) {
// Already on quests page - navigate away and back to force component remount and data refresh
const dashboardLink = this.page.locator('a[href="/dashboard"]').first();
if (await dashboardLink.count().then(c => c > 0)) {
await dashboardLink.click();
await this.page.waitForURL('**/dashboard', { timeout: 5000 });
await this.page.waitForTimeout(500);
}
}
const questsLink = this.page.locator('a[href="/quests"]').first();
const linkExists = await questsLink.count().then(c => c > 0);
if (linkExists) {
await questsLink.click();
// Wait for navigation to complete
await this.page.waitForURL('**/quests', { timeout: 10000 });
await this.page.waitForTimeout(1000); // Wait for quest data to load
} else {
// Fallback to page.goto() if no link found
await super.goto('/quests');
}
}
/**
* Wait for the quests page to be fully loaded
*/
async isLoaded(): Promise<boolean> {
try {
// Wait for either the "Propose a Quest" button or at least one quest item
await Promise.race([
this.page
.getByRole('button', { name: /propose a quest/i })
.waitFor({ state: 'visible', timeout: 5000 }),
this.page
.locator('[data-testid="quest-item"]')
.first()
.waitFor({ state: 'visible', timeout: 5000 }),
]);
return true;
} catch (e) {
return false;
}
}
/**
* Open the quest creation form
*/
private async openCreateForm(): Promise<void> {
await this.page.getByRole('button', { name: /propose a quest/i }).first().click();
await this.formOverlay().waitFor({ state: 'visible', timeout: 10000 });
}
/**
* Create a new quest.
* Automatically picks the first available location and priority when not provided.
*/
async createQuest(quest: Omit<Quest, 'id'>): Promise<void> {
await this.openCreateForm();
await this.questTitleInput().fill(quest.title);
await this.questDescriptionInput().fill(quest.description);
if (quest.type) {
await this.questTypeSelect().selectOption({ label: quest.type });
}
// priority — map test values to form values, or pick first non-empty option
if (quest.priority) {
const priorityMap: Record<string, string> = {
Low: 'Standard',
Medium: 'Important',
High: 'Critical',
Critical: 'Critical',
Important: 'Important',
Standard: 'Standard',
};
const formPriority = priorityMap[quest.priority] || quest.priority;
await this.questPrioritySelect().selectOption({ label: formPriority });
} else {
// Pick first non-empty priority option
await this.questPrioritySelect().selectOption({ index: 1 });
}
// Location is required — pick first available if not specified
if (quest.location) {
await this.questLocationSelect().selectOption({ label: new RegExp(quest.location, 'i') });
} else {
await this.questLocationSelect().selectOption({ index: 1 });
}
// Dark magic checkbox
if (quest.darkMagic) {
const checkbox = this.formOverlay().locator('#is_dark_magic');
if (!(await checkbox.isChecked())) {
await checkbox.click();
}
}
await this.submitQuestButton().click();
// Wait for form to close
await this.formOverlay().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}
/**
* Get all quest titles currently visible in the list
*/
async getQuestTitles(): Promise<string[]> {
try {
await this.pollUntil(
async () => (await this.questItems().count()) > 0,
10000,
500,
);
} catch {
return [];
}
const items = this.questItems();
const count = await items.count();
const titles: string[] = [];
for (let i = 0; i < count; i++) {
const attr = await items.nth(i).getAttribute('data-quest-title');
if (attr) {
titles.push(attr.trim());
} else {
const text = await items.nth(i).locator('h3').first().textContent();
if (text) titles.push(text.trim().replace(/^[^\w]*/, '').trim());
}
}
return titles;
}
/**
* Select a quest to operate on.
* Stores the title for subsequent editQuest() / deleteQuest() calls.
*/
async clickQuest(questTitle: string): Promise<void> {
this.selectedQuestTitle = questTitle;
// Scroll the card into view
try {
await this.questCard(questTitle).scrollIntoViewIfNeeded();
} catch {
// Card may not have data-quest-title yet — fall back
}
}
/**
* Filter quests by status using the status-filter buttons.
* Accepts both display values ("It Is Done") and API values ("it_is_done").
*/
async filterByStatus(status: string): Promise<void> {
const labelMap: Record<string, RegExp> = {
'It Is Done': /^Completed/i,
'it_is_done': /^Completed/i,
'The Road Goes Ever On...': /^In Progress/i,
'the_road_goes_ever_on': /^In Progress/i,
'The Shadow Falls': /^Blocked/i,
'the_shadow_falls': /^Blocked/i,
'Not Yet Begun': /^All Quests/i,
'not_yet_begun': /^All Quests/i,
};
const pattern = labelMap[status] || new RegExp(status, 'i');
// Status filters are expanded by default, so just click
await this.page.getByRole('button', { name: pattern }).first().click();
await this.page.waitForTimeout(300);
}
/**
* Filter quests by type using the type-filter buttons.
* First expands the Quest Type category if it's collapsed.
*/
async filterByType(type: string): Promise<void> {
// Expand Type filter category if not already expanded
try {
const typeHeader = this.page.getByRole('button', { name: /^Quest Type$/i }).first();
// Scroll into view first
await typeHeader.scrollIntoViewIfNeeded();
const isExpanded = await typeHeader.getAttribute('aria-expanded');
if (isExpanded === 'false') {
await typeHeader.click();
await this.page.waitForTimeout(1500); // Give time for filters to appear and expand
}
} catch (e) {
console.warn('Could not find/expand Quest Type header:', e);
}
// Wait a bit to ensure DOM is updated
await this.page.waitForTimeout(500);
// Click the type filter button
try {
// Use getByText which is more reliable than has-text
const button = this.page.getByText(type, { exact: false }).filter({ role: 'button' }).first();
await button.scrollIntoViewIfNeeded();
await button.click({ timeout: 15000 });
await this.page.waitForTimeout(300);
} catch (e) {
throw new Error(`Could not click filter button for type "${type}". Error: ${e}`);
}
}
/**
* Filter quests by location using the location-filter buttons.
* First expands the Location category if it's collapsed.
*/
async filterByLocation(locationName: string): Promise<void> {
// Expand Location filter category if not already expanded
try {
const locationHeader = this.page.getByRole('button', { name: /^Location$/i }).first();
// Scroll into view first
await locationHeader.scrollIntoViewIfNeeded();
const isExpanded = await locationHeader.getAttribute('aria-expanded');
if (isExpanded === 'false') {
await locationHeader.click();
await this.page.waitForTimeout(1500); // Give time for filters to appear and expand
}
} catch (e) {
console.warn('Could not find/expand Location header:', e);
}
// Wait a bit to ensure DOM is updated
await this.page.waitForTimeout(500);
// Click the location filter button
try {
// Use getByText which is more reliable
const button = this.page.getByText(locationName, { exact: false }).filter({ role: 'button' }).first();
await button.scrollIntoViewIfNeeded();
await button.click({ timeout: 15000 });
await this.page.waitForTimeout(300);
} catch (e) {
throw new Error(`Could not click filter button for location "${locationName}". Error: ${e}`);
}
}
/**
* Open the edit form for the selected quest and apply updates.
* Must be called after clickQuest().
*/
async editQuest(updates: Partial<Quest>): Promise<void> {
const title = this.selectedQuestTitle;
if (!title) throw new Error('No quest selected. Call clickQuest() first.');
// Click the "✏️ Revise" button on the quest card
await this.questCard(title).getByRole('button', { name: /revise/i }).click();
await this.formOverlay().waitFor({ state: 'visible', timeout: 10000 });
if (updates.title) {
await this.questTitleInput().clear();
await this.questTitleInput().fill(updates.title);
}
if (updates.description) {
await this.questDescriptionInput().clear();
await this.questDescriptionInput().fill(updates.description);
}
if (updates.status) {
// Map display status to form option label
const statusMap: Record<string, string> = {
'Not Yet Begun': 'Not Yet Begun',
'not_yet_begun': 'Not Yet Begun',
'The Road Goes Ever On...': 'The Road Goes Ever On...',
'the_road_goes_ever_on': 'The Road Goes Ever On...',
'It Is Done': 'It Is Done',
'it_is_done': 'It Is Done',
'The Shadow Falls': 'The Shadow Falls',
'the_shadow_falls': 'The Shadow Falls',
};
const label = statusMap[updates.status] || updates.status;
await this.questStatusSelect().selectOption({ label });
}
await this.submitQuestButton().click();
await this.formOverlay().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}
/**
* Delete the currently selected quest.
* Must be called after clickQuest().
* Handles the native window.confirm dialog automatically.
*/
async deleteQuest(): Promise<void> {
const title = this.selectedQuestTitle;
if (!title) throw new Error('No quest selected. Call clickQuest() first.');
// Accept the confirm dialog before the click triggers it
this.page.once('dialog', (dialog) => dialog.accept());
await this.questCard(title).getByRole('button', { name: /abandon/i }).click();
// Wait for the card to disappear
await this.questCard(title).waitFor({ state: 'detached', timeout: 10000 }).catch(() => {});
this.selectedQuestTitle = null;
}
/**
* Trigger quest completion (opens mini-game modal).
* Must be called after clickQuest().
*/
async completeQuest(): Promise<void> {
const title = this.selectedQuestTitle;
if (!title) throw new Error('No quest selected. Call clickQuest() first.');
await this.questCard(title).getByRole('button', { name: /complete quest/i }).click();
await this.page
.locator('h2:has-text("Quest Challenge")')
.waitFor({ state: 'visible', timeout: 10000 });
}
/**
* Check if the mini-game modal is visible
*/
async isMiniGameModalVisible(): Promise<boolean> {
return this.page
.locator('h2:has-text("Quest Challenge")')
.isVisible({ timeout: 5000 })
.catch(() => false);
}
/**
* Wait for the mini-game modal to close (hidden or detached)
*/
async waitForMiniGameModalToClose(): Promise<void> {
await this.page
.locator('h2:has-text("Quest Challenge")')
.waitFor({ state: 'hidden', timeout: 10000 })
.catch(() => {});
}
/**
* Get the mini-game type from the visible mini-game modal
*/
async getMiniGameType(): Promise<string> {
// Each mini-game renders a recognisable heading
const candidates = ['Trivia', 'Memory', 'Reaction', 'Find the Ring'];
// First try: look for game name as heading text within the modal
for (const game of candidates) {
try {
const heading = this.page.locator(`h2:has-text("${game}"), h3:has-text("${game}")`);
const count = await heading.count();
if (count > 0) return game;
} catch {
// Continue to next candidate
}
}
// Second try: general text search
for (const game of candidates) {
try {
const element = this.page.locator(`text/${game}/i`);
const visible = await element.isVisible({ timeout: 2000 }).catch(() => false);
if (visible) return game;
} catch {
// Continue to next candidate
}
}
return '';
}
/**
* Fail the mini-game by clicking the hidden force-fail trigger button.
* Uses page.evaluate() to bypass viewport restrictions for the off-screen button.
*/
async failMiniGame(): Promise<void> {
// Use page.evaluate to click the hidden test button (bypasses viewport check)
await this.page.evaluate(() => {
const btn = document.querySelector('[data-testid="force-game-fail"]') as HTMLElement | null;
if (btn) btn.click();
});
// Wait for the "Try Again" button to appear (fail state shows retry button)
await this.page.locator('button:has-text("Try Again")').waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
}
/**
* Win the mini-game by clicking the hidden force-win trigger button.
* Then click "Claim Reward" to close the modal and mark quest complete.
*/
async winMiniGame(): Promise<void> {
// Use page.evaluate to click the hidden test button (bypasses viewport check)
await this.page.evaluate(() => {
const btn = document.querySelector('[data-testid="force-game-win"]') as HTMLElement | null;
if (btn) btn.click();
});
// Wait for the Claim Reward button to appear (win state)
const claimButton = this.page.getByRole('button', { name: /Claim Reward/i }).first();
await claimButton.waitFor({ state: 'visible', timeout: 10000 });
await claimButton.click();
// Wait for quest completion API call and modal to close
await this.page.locator('h2:has-text("Quest Challenge")').waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {});
}
}