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

385 lines
13 KiB
TypeScript

import { Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { Ware } from '../types';
/**
* Bargaining Panel Page Object
* Handles all interactions for the bargaining/shop feature.
*
* Real DOM structure (MapPage + MapCharacterPanel):
* .map-character-panel — outer wrapper (visible after "Start Bargain")
* motion.aside (renders as <aside>) — inner component
* [data-testid="dialogue-panel"] — scrollable chat messages area
* div.text-left — each NPC/seller message bubble
* div.text-right — each user message bubble
* textarea — message draft input
* button "Send" — send button
*
* Catalog message (textContent, markdown rendered as plain text):
* "Available wares to bargain for:
* Item Name — ask NNN Gold (id: #ID)
* ..."
*/
export class BargainingPanel extends BasePage {
// Outer wrapper div for the MapCharacterPanel (only present when seller panel is open)
private readonly panelWrapper = () => this.page.locator('.map-character-panel').first();
// Scrollable chat messages area
private readonly dialoguePanel = () => this.page.locator('[data-testid="dialogue-panel"]').first();
// Seller/NPC messages — rendered with text-left alignment
private readonly sellerMessages = () => this.dialoguePanel().locator('> .text-left');
// Message input (textarea — no data-testid in real component)
private readonly messageInput = () => this.panelWrapper().locator('textarea').first();
// Send button (text "Send" — no data-testid in real component)
private readonly sendButton = () =>
this.panelWrapper().getByRole('button', { name: /^Send$/i }).first();
constructor(page: Page, baseUrl?: string) {
super(page, baseUrl);
}
/**
* Check if bargaining panel is visible
*/
async isVisible(): Promise<boolean> {
return this.panelWrapper().isVisible({ timeout: 3000 }).catch(() => false);
}
/**
* Wait for the dialogue panel to become visible (after "Start Bargain" is clicked)
*/
async waitForPanel(timeout = 10000): Promise<void> {
await this.dialoguePanel().waitFor({ state: 'visible', timeout });
}
/**
* Check if catalog is displayed as the first seller message.
* The catalog contains item names with "(id: #N)" entries.
*/
async isCatalogFirstMessage(): Promise<boolean> {
try {
await this.waitForPanel(10000);
await this.pollUntil(
async () => (await this.sellerMessages().count()) > 0,
10000,
500,
);
const text = await this.sellerMessages().first().textContent();
return !!(text?.match(/available wares|wares to bargain|id:\s*#\d+/i));
} catch {
return false;
}
}
/**
* Get catalog wares by parsing the catalog assistant message text.
* Catalog line format (after markdown rendering):
* "Item Name — ask NNN Gold (id: #ID)"
*/
async getCatalogWares(): Promise<Ware[]> {
try {
await this.waitForPanel(10000);
await this.pollUntil(
async () => {
const count = await this.sellerMessages().count();
if (count === 0) return false;
for (let i = 0; i < count; i++) {
const t = await this.sellerMessages().nth(i).textContent();
if (t?.match(/id:\s*#\d+/i)) return true;
}
return false;
},
10000,
500,
);
} catch {
return [];
}
const count = await this.sellerMessages().count();
for (let i = 0; i < count; i++) {
const textRaw = await this.sellerMessages().nth(i).textContent();
if (textRaw?.match(/id:\s*#\d+/i)) {
// Use li elements for parsing since textContent collapses newlines
const liElements = this.sellerMessages().nth(i).locator('li');
const liCount = await liElements.count();
if (liCount > 0) {
const wares: Ware[] = [];
for (let j = 0; j < liCount; j++) {
const liText = await liElements.nth(j).textContent();
if (liText) {
const parsed = this.parseWaresFromText(liText);
wares.push(...parsed);
}
}
if (wares.length > 0) return wares;
}
// Fallback: use innerText which preserves newlines from block elements
const innerText = await this.sellerMessages().nth(i).evaluate(
(el: Element) => (el as HTMLElement).innerText || el.textContent || ''
);
return this.parseWaresFromText(innerText);
}
}
return [];
}
/**
* Parse wares from catalog message textContent.
* Expected line: "Name — ask NNN Gold (id: #ID)"
* Note: markdown bold (**) is stripped by the renderer, so plain text is used.
*/
private parseWaresFromText(text: string): Ware[] {
const wares: Ware[] = [];
for (const line of text.split('\n')) {
const match = line.match(/^\s*([^—–\n]+?)\s*[—–]\s*ask\s*([\d,]+)\s*Gold\s*\(id:\s*#(\d+)\)/i);
if (match) {
const name = match[1].trim();
const price = parseInt(match[2].replace(/,/g, ''), 10);
const id = match[3];
if (name && id) wares.push({ id, name, description: '', price });
}
}
return wares;
}
/**
* Select a ware by sending its name in the bargaining chat
*/
async selectWareByName(wareName: string): Promise<void> {
await this.sendMessage(`I want the ${wareName}`);
}
/**
* Select a ware by ID (sends "#ID" in chat)
*/
async selectWareById(wareId: string): Promise<void> {
await this.sendMessage(`#${wareId}`);
}
/**
* Select a ware by partial name (fuzzy — types partial name in chat)
*/
async selectWareByPartialName(partialName: string): Promise<void> {
await this.sendMessage(`I want ${partialName}`);
}
/**
* Send a message and wait for the seller to respond (new message appears).
* Uses smart polling instead of a fixed wait — faster when the API is quick.
*/
async sendMessage(message: string): Promise<void> {
const countBefore = await this.sellerMessages().count();
await this.messageInput().fill(message);
await this.sendButton().click();
// Wait for a new seller message to appear (up to 15 s)
await this.pollUntil(
async () => (await this.sellerMessages().count()) > countBefore,
15000,
300,
).catch(() => {
// Tolerate timeout — test assertions will catch real failures
});
}
/**
* Make a price offer
*/
async makeOffer(amount: number): Promise<void> {
await this.sendMessage(`${amount}`);
}
/**
* Accept the deal by typing "deal"
*/
async acceptDeal(): Promise<void> {
await this.sendMessage('deal');
}
/**
* Get the text of the last seller message
*/
async getLastSellerMessage(): Promise<string> {
try {
await this.pollUntil(
async () => (await this.sellerMessages().count()) > 0,
10000,
500,
);
} catch {
return '';
}
const count = await this.sellerMessages().count();
if (count === 0) return '';
const text = await this.sellerMessages().nth(count - 1).textContent();
return text?.trim() || '';
}
/**
* Check if seller is asking for clarification (ambiguous selection)
*/
async isAskingForClarification(): Promise<boolean> {
const msg = await this.getLastSellerMessage();
return /which|clarif|confirm|did you mean/i.test(msg);
}
/**
* Get gold balance from the nav GoldCounter ("Gold: NNN")
*/
async getGoldBalance(): Promise<number> {
try {
// GoldCounter renders: <div ...>Gold: {gold}</div>
const el = this.page.locator('text=/Gold: \\d+/').first();
const text = await el.textContent({ timeout: 5000 });
const match = text?.match(/(\d+)/);
return parseInt(match?.[1] || '0', 10);
} catch {
return 0;
}
}
/**
* Check if stats section is visible on the inventory page.
* Stats grid uses data-tour="inventory-stats".
* Uses waitFor because InventoryPage has a loading state before stats render.
*/
async isStatsVisible(): Promise<boolean> {
try {
await this.page
.locator('[data-tour="inventory-stats"]')
.waitFor({ state: 'visible', timeout: 15000 });
return true;
} catch {
return false;
}
}
/**
* Get best bargain percentage from inventory stats grid
*/
async getBestBargainPercent(): Promise<number> {
const panel = this.page.locator('[data-tour="inventory-stats"]');
const text = await panel.textContent();
const matches = [...(text?.matchAll(/([\d.]+)%/g) || [])];
return parseFloat(matches[0]?.[1] || '0');
}
/**
* Get average savings percentage from inventory stats grid
*/
async getAverageSavingsPercent(): Promise<number> {
const panel = this.page.locator('[data-tour="inventory-stats"]');
const text = await panel.textContent();
const matches = [...(text?.matchAll(/([\d.]+)%/g) || [])];
return parseFloat(matches[matches.length - 1]?.[1] || '0');
}
/**
* Navigate to inventory page using SPA navigation (click nav link).
* A full page.goto('/inventory') resets React auth state causing a redirect to /login.
* Clicking the nav link preserves the currentUser state from login.
*/
async goToInventory(): Promise<void> {
// Try SPA-safe navigation via nav link first
const navLink = this.page.getByRole('link', { name: /^Inventory$/i }).first();
const isNavVisible = await navLink.isVisible({ timeout: 2000 }).catch(() => false);
if (isNavVisible) {
await navLink.click();
await this.page.waitForURL('**/inventory', { timeout: 10000 });
} else {
// Fallback: full page load — wait for React auth check to complete before checking content
await this.page.goto(`${this.baseUrl}/inventory`);
// Give the SPA time to check auth (/api/auth/me) and re-render the protected route
await this.page.waitForTimeout(3000);
// If auth succeeded, we may be on /inventory; if not re-navigate once auth check is done
if (!this.page.url().includes('/inventory')) {
const inventoryLink = this.page.getByRole('link', { name: /^Inventory$/i }).first();
const visible = await inventoryLink.isVisible({ timeout: 3000 }).catch(() => false);
if (visible) {
await inventoryLink.click();
await this.page.waitForURL('**/inventory', { timeout: 10000 });
}
}
}
await this.page.waitForTimeout(500);
}
/**
* Get inventory items from the table rows.
* InventoryPage renders items in <table><tbody><tr> rows (no data-testid).
*/
async getInventoryItems(): Promise<string[]> {
await this.page
.locator('table tbody tr')
.first()
.waitFor({ state: 'visible', timeout: 10000 })
.catch(() => null);
const rows = this.page.locator('table tbody tr');
const count = await rows.count();
const results: string[] = [];
for (let i = 0; i < count; i++) {
const text = await rows.nth(i).textContent();
if (text) results.push(text.trim());
}
return results;
}
/**
* Expect a purchase confirmation in the last seller message
*/
async expectPurchaseConfirmation(): Promise<void> {
await this.page.waitForTimeout(2000);
const msg = await this.getLastSellerMessage();
expect(msg).toMatch(/purchase|success|bought|congratulations|thank|sold|deal/i);
}
/**
* Check if panel has a generic NPC opener message
*/
async hasGenericOpener(): Promise<boolean> {
try {
const messages = await this.getMessages();
return messages.some(m => /^hello|^hi|^greetings|welcome/i.test(m.trim()));
} catch {
return false;
}
}
/**
* Get all messages from the bargaining panel
*/
async getMessages(): Promise<string[]> {
try {
await this.waitForPanel(10000);
const allMessages = this.dialoguePanel().locator('> div');
const count = await allMessages.count();
const messages: string[] = [];
for (let i = 0; i < count; i++) {
const text = await allMessages.nth(i).textContent();
if (text) messages.push(text.trim());
}
return messages;
} catch {
return [];
}
}
/**
* Check if the bargaining panel is loaded and ready for interaction
*/
async isLoaded(): Promise<boolean> {
try {
await this.waitForPanel(10000);
const inputVisible = await this.messageInput().isVisible({ timeout: 3000 });
const buttonVisible = await this.sendButton().isVisible({ timeout: 3000 });
return inputVisible && buttonVisible;
} catch {
return false;
}
}
}