385 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|