Playwright start
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Pages\DataTables\Parts\" />
|
||||
<Folder Include="wwwroot\generated" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -19,21 +19,6 @@
|
||||
<LayoutLargeContentComponent>
|
||||
<WebsiteTitleComponent>Build Calculator</WebsiteTitleComponent>
|
||||
|
||||
<AlertComponent Type="@SeverityType.Warning">
|
||||
<Title>Work In Progress and Not Fully Tested</Title>
|
||||
<Message>
|
||||
Build Calculator hasn't been thoroughly tested. Bugs and inaccurate results assumed.
|
||||
<br/>
|
||||
Currently not considering running out of alloy and ether to harvest.
|
||||
<br/>
|
||||
<br/>
|
||||
Build Calculator was built based on a much older version of the game and was only quickly modified for the
|
||||
June 2025 Playtest version, so the above disclaimer is only more true.
|
||||
<br/>
|
||||
Expect even more oddities and invalid data then the above warning implies.
|
||||
</Message>
|
||||
</AlertComponent>
|
||||
|
||||
<ContentDividerComponent></ContentDividerComponent>
|
||||
|
||||
<div class="calculatorGrid">
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
@implements IDisposable
|
||||
|
||||
<FormLayoutComponent>
|
||||
<FormTextAreaComponent Label="JSON Data"
|
||||
Rows="14"
|
||||
Value="@buildOrderService.AsJson()">
|
||||
</FormTextAreaComponent>
|
||||
|
||||
</FormLayoutComponent>
|
||||
|
||||
@code {
|
||||
/**
|
||||
// TODO: Make this more elegant, and useful. Also, it currently doesn't clear properly
|
||||
<FormTextAreaComponent Label="JSON Data"
|
||||
Rows="14"
|
||||
Value="@buildOrderService.AsJson()">
|
||||
</FormTextAreaComponent>
|
||||
*/
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject IKeyService KeyService
|
||||
@inject IImmortalSelectionService FilterService
|
||||
@inject IBuildOrderService BuildOrderService
|
||||
@inject IStorageService StorageService
|
||||
@using Services.Website
|
||||
@implements IDisposable
|
||||
@@ -51,21 +50,6 @@
|
||||
_viewType = StorageService.GetValue<bool>(StorageKeys.IsPlainView) ? EntityViewType.Plain : EntityViewType.Detailed;
|
||||
}
|
||||
|
||||
protected override bool ShouldRender()
|
||||
{
|
||||
#if DEBUG
|
||||
JsRuntime.InvokeVoidAsync("console.time", "EntityClickViewComponent");
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
#if DEBUG
|
||||
JsRuntime.InvokeVoidAsync("console.timeEnd", "EntityClickViewComponent");
|
||||
#endif
|
||||
}
|
||||
|
||||
private void HandleClick()
|
||||
{
|
||||
var hotkey = KeyService.GetHotkey();
|
||||
@@ -74,6 +58,8 @@
|
||||
var faction = FilterService.GetFaction();
|
||||
var immortal = FilterService.GetImmortal();
|
||||
|
||||
Console.WriteLine(hotkey);
|
||||
|
||||
var foundEntity = EntityModel.GetFrom(hotkey!, hotkeyGroup, isHoldSpace, faction, immortal);
|
||||
|
||||
if (foundEntity != null && _entity != foundEntity)
|
||||
@@ -82,5 +68,4 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,14 +6,6 @@
|
||||
|
||||
<LayoutLargeContentComponent>
|
||||
<WebsiteTitleComponent>Data Tables</WebsiteTitleComponent>
|
||||
|
||||
<AlertComponent Type="@SeverityType.Warning">
|
||||
<Title>Errors Present</Title>
|
||||
<Message>
|
||||
Incomplete feature for easily comparing unit stats.
|
||||
</Message>
|
||||
</AlertComponent>
|
||||
|
||||
<MudTabs Elevation="2">
|
||||
<MudTabPanel Text="Attacks">
|
||||
<WeaponTable/>
|
||||
@@ -29,7 +21,6 @@
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
|
||||
<ContentDividerComponent></ContentDividerComponent>
|
||||
|
||||
<PaperComponent>
|
||||
@@ -43,10 +34,7 @@
|
||||
attack belongs to.
|
||||
</InfoAnswerComponent>
|
||||
</InfoBodyComponent>
|
||||
|
||||
</PaperComponent>
|
||||
|
||||
|
||||
</LayoutLargeContentComponent>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -9,13 +9,6 @@
|
||||
<LayoutMediumContentComponent>
|
||||
<WebsiteTitleComponent>Harass Calculator</WebsiteTitleComponent>
|
||||
|
||||
<AlertComponent Type="@SeverityType.Warning">
|
||||
<Title>Might be out of date</Title>
|
||||
<Message>
|
||||
This calculation is from several years ago and might not reflect the current state of the game.
|
||||
</Message>
|
||||
</AlertComponent>
|
||||
|
||||
<PaperComponent>
|
||||
Credit to Zard for deriving the formula.
|
||||
</PaperComponent>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
namespace Model.Entity.Parts;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Model.Entity.Parts;
|
||||
|
||||
public class IEntityPartInterface
|
||||
{
|
||||
[JsonIgnore]
|
||||
public EntityModel Parent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,38 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://calm-mud-04916b210.1.azurestaticapps.net/build-calculator', { timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
await page.locator('select').nth(0).selectOption("Q'Rath");
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('select').nth(1).selectOption('Orzum');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const keys = await page.locator('.keyContainer > div > div').all();
|
||||
for (const key of keys) {
|
||||
const text = await key.textContent();
|
||||
if (text && text.trim().startsWith('TAB')) {
|
||||
console.log('TAB key textContent:', text.substring(0, 500));
|
||||
const innerHtml = await key.evaluate(el => el.innerHTML);
|
||||
console.log('TAB key innerHTML (first 2000):', innerHtml.substring(0, 2000));
|
||||
const childDivs = await key.locator('> div').all();
|
||||
console.log('Entity div count:', childDivs.length);
|
||||
for (const div of childDivs) {
|
||||
const t = await div.textContent();
|
||||
const s = await div.getAttribute('style');
|
||||
console.log(' Entity:', (t || '').trim(), 'Style:', (s || 'none').substring(0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.locator('.keyContainer > div > div').filter({ hasText: 'TAB' }).first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
const entityViewName = await page.locator('.entityClickView #entityName').textContent();
|
||||
console.log('Entity view shows:', entityViewName);
|
||||
const entityViewHtml = await page.locator('.entityClickView').evaluate(el => el.innerHTML.substring(0, 1000));
|
||||
console.log('Entity view HTML:', entityViewHtml);
|
||||
|
||||
await browser.close();
|
||||
})().catch(e => { console.error(e.message); process.exit(1); });
|
||||
@@ -0,0 +1,51 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error' || msg.type() === 'warning')
|
||||
console.log(msg.type().toUpperCase() + ':', msg.text().substring(0, 300));
|
||||
});
|
||||
|
||||
page.on('pageerror', err => console.log('PAGE_ERR:', err.message));
|
||||
|
||||
await page.goto('http://localhost:5111/build-calculator', { timeout: 30000, waitUntil: 'load' });
|
||||
console.log('Page load event fired');
|
||||
|
||||
// Read button text immediately
|
||||
let buttons = page.locator('.keyContainer > div > div');
|
||||
let count = await buttons.count();
|
||||
console.log('Button count:', count);
|
||||
let qBtn = buttons.filter({ hasText: /^Q/ }).first();
|
||||
console.log('Immediate Q button text:', JSON.stringify(await qBtn.textContent()));
|
||||
|
||||
// Wait for Blazor to finish initializing - look for .blazor-error-boundary or just wait
|
||||
// Actually, let's poll for the button text changing from what it is now
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.waitForTimeout(1000);
|
||||
const text = (await buttons.nth(0).textContent() || '').trim();
|
||||
// Check all 19 buttons for Q and F keys
|
||||
const allTexts = [];
|
||||
for (let j = 0; j < count; j++) {
|
||||
const t = (await buttons.nth(j).textContent() || '').trim();
|
||||
if (t.toUpperCase().startsWith('Q') || t.toUpperCase().startsWith('F') || t.toUpperCase().startsWith('W') || t.toUpperCase().startsWith('E')) {
|
||||
allTexts.push(` B${j}: "${t.substring(0,30)}"`);
|
||||
}
|
||||
}
|
||||
console.log(`After ${i+1}s:`);
|
||||
allTexts.forEach(t => console.log(t));
|
||||
}
|
||||
|
||||
// Now check what the filter is currently showing
|
||||
const selects = page.locator('select');
|
||||
console.log('\nSelect values:');
|
||||
for (let s = 0; s < await selects.count(); s++) {
|
||||
const val = await selects.nth(s).inputValue();
|
||||
const opts = await selects.nth(s).locator('option').allTextContents();
|
||||
const selectedIdx = await selects.nth(s).evaluate(el => el.selectedIndex);
|
||||
console.log(` Select ${s}: value=${val}, selectedIndex=${selectedIdx}, options=${JSON.stringify(opts.map(o=>o.trim()))}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', err => console.log('PAGE_ERR:', err.message));
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') console.log('CONSOLE_ERR:', msg.text().substring(0,200));
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:5111/build-calculator', { timeout: 30000, waitUntil: 'load' });
|
||||
console.log('Page loaded');
|
||||
} catch (e) {
|
||||
console.log('Page load error:', e.message);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(10000);
|
||||
console.log('Waited 10s');
|
||||
|
||||
const selCount = await page.locator('select').count();
|
||||
console.log('Select elements count:', selCount);
|
||||
|
||||
if (selCount > 0) {
|
||||
for (let s = 0; s < selCount; s++) {
|
||||
const opts = await page.locator('select').nth(s).locator('option').allTextContents();
|
||||
console.log('Select', s, 'options:', JSON.stringify(opts.map(o => o.trim())));
|
||||
}
|
||||
|
||||
await page.locator('select').nth(0).selectOption("Q'Rath");
|
||||
console.log('Selected Q\' Rath');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.locator('select').nth(1).selectOption('Orzum');
|
||||
console.log('Selected Orzum');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Log all key buttons
|
||||
const buttons = page.locator('.keyContainer > div > div');
|
||||
const count = await buttons.count();
|
||||
console.log('=== All key buttons (' + count + ') ===');
|
||||
for (let i = 0; i < count; i++) {
|
||||
const txt = (await buttons.nth(i).textContent() || '').trim();
|
||||
console.log('Button', i, ':', JSON.stringify(txt.substring(0,60)));
|
||||
}
|
||||
|
||||
// Find Q button
|
||||
for (let i = 0; i < count; i++) {
|
||||
const txt = (await buttons.nth(i).textContent() || '');
|
||||
if (txt.trim().toUpperCase().startsWith('Q')) {
|
||||
console.log('\nFound Q button at index', i, ':', JSON.stringify(txt.trim().substring(0,60)));
|
||||
console.log('Clicking it...');
|
||||
await buttons.nth(i).click({ force: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check entity view
|
||||
const evCount = await page.locator('.entityClickView #entityName').count();
|
||||
console.log('entityName count:', evCount);
|
||||
if (evCount > 0) {
|
||||
const name = (await page.locator('.entityClickView #entityName').textContent() || '').trim();
|
||||
console.log('entityName text:', JSON.stringify(name));
|
||||
}
|
||||
} else {
|
||||
console.log('No select elements found!');
|
||||
console.log('URL:', page.url());
|
||||
const body = await page.evaluate(() => document.body.innerText.substring(0, 500));
|
||||
console.log('Body text:', JSON.stringify(body));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,59 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const page = await context.newPage();
|
||||
console.log('Navigating...');
|
||||
await page.goto('https://calm-mud-04916b210.1.azurestaticapps.net/build-calculator', { timeout: 30000, waitUntil: 'domcontentloaded' });
|
||||
console.log('Page loaded, waiting for Blazor...');
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
console.log('Selecting Q\'Rath...');
|
||||
await page.locator('select').nth(0).selectOption("Q'Rath");
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('select').nth(1).selectOption('Orzum');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('Looking for TAB key...');
|
||||
const allKeys = await page.locator('.keyContainer > div > div').all();
|
||||
console.log('Total key divs found:', allKeys.length);
|
||||
for (let i = 0; i < allKeys.length; i++) {
|
||||
const text = await allKeys[i].textContent();
|
||||
const preview = (text || '').trim().substring(0, 100);
|
||||
console.log('Key ' + i + ' starts with:', preview.replace(/\n/g, ' '));
|
||||
if (text && text.trim().startsWith('TAB')) {
|
||||
console.log('FOUND TAB KEY at index ' + i);
|
||||
const entityDivs = await allKeys[i].locator('> div').all();
|
||||
console.log('Entity divs:', entityDivs.length);
|
||||
for (const d of entityDivs) {
|
||||
const t = await d.textContent();
|
||||
console.log(' Entity:', (t || '').trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Clicking TAB key...');
|
||||
try {
|
||||
const tabKey = page.locator('.keyContainer > div > div').filter({ hasText: 'TAB' }).first();
|
||||
await tabKey.click({ timeout: 5000 });
|
||||
console.log('Click succeeded');
|
||||
} catch (e) {
|
||||
console.log('Click failed:', e.message.substring(0, 200));
|
||||
}
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
const entityViewCount = await page.locator('.entityClickView').count();
|
||||
console.log('entityClickView count:', entityViewCount);
|
||||
if (entityViewCount > 0) {
|
||||
const ev = await page.locator('.entityClickView #entityName').textContent();
|
||||
console.log('Entity view name:', ev);
|
||||
const id = await page.locator('.entityClickView .entitiesContainer').getAttribute('id');
|
||||
console.log('Entity container id:', id);
|
||||
} else {
|
||||
console.log('Entity click view NOT found - checking for errors...');
|
||||
const errorEls = await page.locator('[class*=\"error\"], [class*=\"Error\"]').count();
|
||||
console.log('Error elements:', errorEls);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})().catch(e => { console.error(e.message); process.exit(1); });
|
||||
@@ -0,0 +1,69 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:5111/build-calculator', { timeout: 30000, waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
const selects = page.locator('select');
|
||||
await selects.nth(0).selectOption("Q'Rath");
|
||||
await page.waitForTimeout(500);
|
||||
await selects.nth(1).selectOption('Orzum');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check entity view before adding
|
||||
let evCount = await page.locator('.entityClickView #entityName').count();
|
||||
console.log('entityName count before add:', evCount);
|
||||
if (evCount > 0) console.log('entityName text:', (await page.locator('.entityClickView #entityName').textContent() || '').trim());
|
||||
|
||||
// Check timeline intervals before
|
||||
let intervals = page.locator('[class*="interval"], .timelineInterval');
|
||||
console.log('interval count before add:', await intervals.count());
|
||||
if ((await intervals.count()) > 0) {
|
||||
console.log('first interval text:', ((await intervals.first().textContent()) || '').trim().substring(0,100));
|
||||
}
|
||||
|
||||
// Click Q
|
||||
const buttons = page.locator('.keyContainer > div > div');
|
||||
const count = await buttons.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const txt = (await buttons.nth(i).textContent()) || '';
|
||||
if (txt.trim().toUpperCase().startsWith('Q')) {
|
||||
await buttons.nth(i).click({ force: true });
|
||||
console.log('Clicked Q button at index', i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check entity view after adding
|
||||
evCount = await page.locator('.entityClickView #entityName').count();
|
||||
console.log('entityName count after add:', evCount);
|
||||
if (evCount > 0) console.log('entityName text:', ((await page.locator('.entityClickView #entityName').textContent()) || '').trim());
|
||||
|
||||
// Check timeline intervals after
|
||||
intervals = page.locator('[class*="interval"], .timelineInterval');
|
||||
console.log('interval count after add:', await intervals.count());
|
||||
if ((await intervals.count()) > 0) {
|
||||
const ic = await intervals.count();
|
||||
for (let i = 0; i < Math.min(ic, 3); i++) {
|
||||
console.log('interval', i, 'text:', ((await intervals.nth(i).textContent()) || '').trim().substring(0,120));
|
||||
}
|
||||
}
|
||||
|
||||
// Click Clear Build Order
|
||||
await page.locator('button').filter({ hasText: 'Clear Build Order' }).click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check entity view after clear
|
||||
evCount = await page.locator('.entityClickView #entityName').count();
|
||||
console.log('entityName count after clear:', evCount);
|
||||
if (evCount > 0) console.log('entityName text:', ((await page.locator('.entityClickView #entityName').textContent()) || '').trim());
|
||||
|
||||
// Check timeline intervals after clear
|
||||
intervals = page.locator('[class*="interval"], .timelineInterval');
|
||||
console.log('interval count after clear:', await intervals.count());
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,39 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:5111/build-calculator', { timeout: 30000, waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
const selects = page.locator('select');
|
||||
await selects.nth(0).selectOption("Q'Rath");
|
||||
await page.waitForTimeout(500);
|
||||
await selects.nth(1).selectOption('Orzum');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const grid = page.locator('.calculatorGrid > div');
|
||||
const gCount = await grid.count();
|
||||
console.log('calculatorGrid child divs:', gCount);
|
||||
for (let i = 0; i < gCount; i++) {
|
||||
const cls = await grid.nth(i).getAttribute('class');
|
||||
const text = (await grid.nth(i).textContent() || '').trim().substring(0,80);
|
||||
console.log(' child', i, 'class:', JSON.stringify(cls), 'text:', JSON.stringify(text));
|
||||
}
|
||||
|
||||
// Check for interval-related elements
|
||||
for (const sel of ['[class*="interval"]', '[class*="Interval"]', '[class*="timeline"]', '[class*="Timeline"]']) {
|
||||
console.log(sel, 'count:', await page.locator(sel).count());
|
||||
}
|
||||
|
||||
// Also check displayContainer children
|
||||
const dc = page.locator('.displayContainer');
|
||||
const dcCount = await dc.count();
|
||||
console.log('displayContainer count:', dcCount);
|
||||
for (let i = 0; i < dcCount; i++) {
|
||||
const cls = await dc.nth(i).getAttribute('class');
|
||||
const text = (await dc.nth(i).textContent() || '').trim().substring(0,150);
|
||||
console.log(' dc', i, 'class:', JSON.stringify(cls), 'text:', JSON.stringify(text));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,90 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:5111/build-calculator', { timeout: 30000, waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
const selects = page.locator('select');
|
||||
await selects.nth(0).selectOption("Q'Rath");
|
||||
await page.waitForTimeout(500);
|
||||
await selects.nth(1).selectOption('Orzum');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const gridItems = page.locator('.calculatorGrid > div');
|
||||
const gCount = await gridItems.count();
|
||||
|
||||
// Find the timeline section (has "Shows economy" text)
|
||||
let timelineIdx = -1;
|
||||
for (let i = 0; i < gCount; i++) {
|
||||
const txt = (await gridItems.nth(i).textContent() || '');
|
||||
if (txt.includes('economy')) {
|
||||
timelineIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log('Timeline grid item index:', timelineIdx);
|
||||
|
||||
// Get full timeline text before adding
|
||||
if (timelineIdx >= 0) {
|
||||
const text = (await gridItems.nth(timelineIdx).textContent() || '').trim();
|
||||
console.log('Timeline text before add (first 500 chars):');
|
||||
console.log(text.substring(0, 500));
|
||||
console.log('...');
|
||||
console.log('Contains Acropolis:', text.includes('Acropolis'));
|
||||
console.log('Contains Requested:', text.includes('Requested'));
|
||||
}
|
||||
|
||||
// Click Q to add Acropolis
|
||||
const buttons = page.locator('.keyContainer > div > div');
|
||||
const count = await buttons.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const txt = (await buttons.nth(i).textContent()) || '';
|
||||
if (txt.trim().toUpperCase().startsWith('Q')) {
|
||||
await buttons.nth(i).click({ force: true });
|
||||
console.log('Clicked Q at index', i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check entity view
|
||||
const evName = (await page.locator('.entityClickView #entityName').textContent() || '').trim();
|
||||
console.log('EntityView name after add:', evName);
|
||||
|
||||
// Get timeline text after adding
|
||||
if (timelineIdx >= 0) {
|
||||
const text = (await gridItems.nth(timelineIdx).textContent() || '').trim();
|
||||
console.log('Timeline text after add (first 500 chars):');
|
||||
console.log(text.substring(0, 500));
|
||||
console.log('Contains Acropolis:', text.includes('Acropolis'));
|
||||
console.log('Contains Requested:', text.includes('Requested'));
|
||||
console.log('Contains New:', text.includes('New'));
|
||||
}
|
||||
|
||||
// Count Virtualize items in timeline
|
||||
const virtualItems = page.locator('[style*="grid-template-columns: 1fr 1fr"]');
|
||||
console.log('Virtualize items (grid items):', await virtualItems.count());
|
||||
|
||||
// Click Clear Build Order
|
||||
await page.locator('button').filter({ hasText: 'Clear Build Order' }).click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check entity view after clear
|
||||
const evAfterClear = await page.locator('.entityClickView #entityName').count();
|
||||
console.log('EntityView #entityName count after clear:', evAfterClear);
|
||||
if (evAfterClear > 0) {
|
||||
console.log('EntityView name after clear:', (await page.locator('.entityClickView #entityName').textContent() || '').trim());
|
||||
}
|
||||
|
||||
// Check timeline after clear
|
||||
if (timelineIdx >= 0) {
|
||||
const text = (await gridItems.nth(timelineIdx).textContent() || '').trim();
|
||||
console.log('Timeline text after clear (first 300 chars):');
|
||||
console.log(text.substring(0, 300));
|
||||
console.log('Contains Acropolis:', text.includes('Acropolis'));
|
||||
console.log('Virtualize items:', await virtualItems.count());
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,64 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:5111/build-calculator', { timeout: 30000, waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
const selects = page.locator('select');
|
||||
await selects.nth(0).selectOption("Q'Rath");
|
||||
await page.waitForTimeout(500);
|
||||
await selects.nth(1).selectOption('Orzum');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const gridItems = page.locator('.calculatorGrid > div');
|
||||
const gCount = await gridItems.count();
|
||||
|
||||
for (let i = 0; i < gCount; i++) {
|
||||
const txt = (await gridItems.nth(i).textContent() || '').trim();
|
||||
const cls = await gridItems.nth(i).getAttribute('class');
|
||||
console.log('=== Grid Item', i, 'class:', cls, '===');
|
||||
console.log(txt.substring(0, 200));
|
||||
console.log('---');
|
||||
}
|
||||
|
||||
console.log('\n=== Clicking Q ===');
|
||||
const buttons = page.locator('.keyContainer > div > div');
|
||||
const count = await buttons.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const txt = (await buttons.nth(i).textContent()) || '';
|
||||
if (txt.trim().toUpperCase().startsWith('Q')) {
|
||||
await buttons.nth(i).click({ force: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('\n=== After Q click ===');
|
||||
for (let i = 0; i < gCount; i++) {
|
||||
const txt = (await gridItems.nth(i).textContent() || '').trim();
|
||||
console.log('Grid', i, '- contains Acropolis:', txt.includes('Acropolis'));
|
||||
if (txt.includes('Acropolis')) {
|
||||
console.log(' Text around Acropolis:');
|
||||
const idx = txt.indexOf('Acropolis');
|
||||
console.log(' ', txt.substring(Math.max(0, idx - 40), idx + 40));
|
||||
}
|
||||
}
|
||||
|
||||
// Entity view
|
||||
console.log('\nEntityView name:', (await page.locator('.entityClickView #entityName').textContent() || '').trim());
|
||||
|
||||
// Click Clear
|
||||
await page.locator('button').filter({ hasText: 'Clear Build Order' }).click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('\n=== After Clear ===');
|
||||
for (let i = 0; i < gCount; i++) {
|
||||
const txt = (await gridItems.nth(i).textContent() || '').trim();
|
||||
console.log('Grid', i, '- contains Acropolis:', txt.includes('Acropolis'));
|
||||
}
|
||||
|
||||
console.log('\nEntityView after clear count:', await page.locator('.entityClickView #entityName').count());
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,101 @@
|
||||
const ScreenType = Object.freeze({ Desktop: 'desktop', Tablet: 'tablet', Mobile: 'mobile' });
|
||||
|
||||
class Website {
|
||||
constructor(page, options = {}) {
|
||||
this.page = page;
|
||||
this.screenType = ScreenType.Desktop;
|
||||
this.runAgainstProduction = options.production || process.env.RUN_AGAINST_PRODUCTION === 'true';
|
||||
|
||||
if (this.runAgainstProduction) {
|
||||
this.baseUrl = 'https://igpfanreference.ca';
|
||||
} else {
|
||||
const hook = process.env.TEST_HOOK || '';
|
||||
this.deploymentType = hook.includes('localhost') ? 'Local' : 'Dev';
|
||||
this.baseUrl = this.deploymentType === 'Dev'
|
||||
? 'https://calm-mud-04916b210.1.azurestaticapps.net'
|
||||
: 'https://localhost:7234';
|
||||
}
|
||||
|
||||
const BuildCalculatorPage = require('../pages/buildCalculatorPage');
|
||||
const HarassCalculatorPage = require('../pages/harassCalculator.page');
|
||||
const DatabasePage = require('../pages/database.page');
|
||||
const DatabaseSinglePage = require('../pages/databaseSingle.page');
|
||||
const NavigationBar = require('../shared/navigationBar');
|
||||
const WebsiteSearchDialog = require('../shared/websiteSearchDialog');
|
||||
|
||||
this.buildCalculatorPage = new BuildCalculatorPage(this);
|
||||
this.harassCalculatorPage = new HarassCalculatorPage(this);
|
||||
this.databasePage = new DatabasePage(this);
|
||||
this.databaseSinglePage = new DatabaseSinglePage(this);
|
||||
this.navigationBar = new NavigationBar(this);
|
||||
this.websiteSearchDialog = new WebsiteSearchDialog(this);
|
||||
}
|
||||
|
||||
locator(selector) {
|
||||
return this.page.locator(selector);
|
||||
}
|
||||
|
||||
find(byId) {
|
||||
return this.page.locator(`#${byId}`);
|
||||
}
|
||||
|
||||
findWithParent(byId, withParentId) {
|
||||
return this.page.locator(`#${withParentId} #${byId}`);
|
||||
}
|
||||
|
||||
findScreenSpecific(byId) {
|
||||
return this.page.locator(`#${this.screenType}-${byId}`);
|
||||
}
|
||||
|
||||
findAll(byId) {
|
||||
return this.page.locator(`#${byId}`);
|
||||
}
|
||||
|
||||
findAllWithTag(tag) {
|
||||
return this.page.locator(tag);
|
||||
}
|
||||
|
||||
findAllWithTagFromElement(element, tag) {
|
||||
return element.locator(tag);
|
||||
}
|
||||
|
||||
findButtonWithLabel(label) {
|
||||
return this.page.locator(`button[label="${label}"]`);
|
||||
}
|
||||
|
||||
findChildren(ofId, tagname) {
|
||||
return this.page.locator(`#${ofId} ${tagname}`);
|
||||
}
|
||||
|
||||
async findText(byId) {
|
||||
return (await this.page.locator(`#${byId}`).textContent()) || '';
|
||||
}
|
||||
|
||||
async findInt(byId) {
|
||||
const text = await this.findText(byId);
|
||||
return parseInt(text, 10);
|
||||
}
|
||||
|
||||
async clickSearchBackground() {
|
||||
await this.page.locator('#searchBackground').click();
|
||||
}
|
||||
|
||||
async clickElement(element) {
|
||||
await element.click();
|
||||
}
|
||||
|
||||
async enterInput(element, value) {
|
||||
await element.fill(String(value));
|
||||
await element.press('Enter');
|
||||
}
|
||||
|
||||
async goto(path) {
|
||||
if (path) {
|
||||
await this.page.goto(`${this.baseUrl}/${path}`);
|
||||
} else {
|
||||
await this.page.goto(this.baseUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Website, ScreenType };
|
||||
Generated
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "playwright",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "playwright",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "playwright",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "npx playwright test",
|
||||
"test:headed": "npx playwright test --headed",
|
||||
"report": "npx playwright show-report"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
class BasePage {
|
||||
constructor(website) {
|
||||
this.website = website;
|
||||
}
|
||||
|
||||
get url() {
|
||||
throw new Error('Subclasses must implement url');
|
||||
}
|
||||
|
||||
async getLinks() {
|
||||
const content = this.website.find('content');
|
||||
const links = content.locator('a');
|
||||
return await links.evaluateAll(els => els.map(el => el.getAttribute('href')).filter(Boolean));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BasePage;
|
||||
@@ -0,0 +1,52 @@
|
||||
class ArmyComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
armyView() {
|
||||
return this.page.locator('.armyView');
|
||||
}
|
||||
|
||||
displayValue(label) {
|
||||
return this.page.locator('.displayContainer').filter({ hasText: label }).locator('.displayContent');
|
||||
}
|
||||
|
||||
armyCards() {
|
||||
return this.armyView().locator('.armyCard');
|
||||
}
|
||||
|
||||
async getArmyCompletedAt() {
|
||||
return await this.displayValue('Army Completed At').textContent();
|
||||
}
|
||||
|
||||
async getArmyAttackingAt() {
|
||||
return await this.displayValue('Army Attacking At').textContent();
|
||||
}
|
||||
|
||||
async getArmyUnitNames() {
|
||||
const cards = await this.armyCards().all();
|
||||
const names = [];
|
||||
for (const card of cards) {
|
||||
const text = await card.innerText();
|
||||
const match = text.match(/\d+x\s*(.+)/);
|
||||
names.push(match ? match[1].trim() : text.trim());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
async getArmyUnitCounts() {
|
||||
const cards = await this.armyCards().all();
|
||||
const counts = [];
|
||||
for (const card of cards) {
|
||||
const countEl = card.locator('.armyCount');
|
||||
const nameEl = card.locator('div').last();
|
||||
const count = await countEl.textContent();
|
||||
const name = await nameEl.textContent();
|
||||
const num = count ? parseInt(count.replace('x', ''), 10) : 0;
|
||||
counts.push({ name: (name || '').trim(), count: num });
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArmyComponent;
|
||||
@@ -0,0 +1,47 @@
|
||||
class BankComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
bankContainer() {
|
||||
return this.page.locator('.bankContainer');
|
||||
}
|
||||
|
||||
displayValue(label) {
|
||||
return this.bankContainer().locator('.displayContainer').filter({ hasText: label }).locator('.displayContent');
|
||||
}
|
||||
|
||||
async getTime() {
|
||||
return await this.displayValue('Time').textContent();
|
||||
}
|
||||
|
||||
async getAlloy() {
|
||||
return await this.displayValue('Alloy').textContent();
|
||||
}
|
||||
|
||||
async getEther() {
|
||||
return await this.displayValue('Ether').textContent();
|
||||
}
|
||||
|
||||
async getPyre() {
|
||||
return await this.displayValue('Pyre').textContent();
|
||||
}
|
||||
|
||||
async getSupply() {
|
||||
return await this.displayValue('Supply').textContent();
|
||||
}
|
||||
|
||||
async getWorkerCount() {
|
||||
return await this.bankContainer().locator('.workerText').locator('.displayContent').nth(0).textContent();
|
||||
}
|
||||
|
||||
async getBusyWorkerCount() {
|
||||
return await this.bankContainer().locator('.workerText').locator('.displayContent').nth(1).textContent();
|
||||
}
|
||||
|
||||
async getCreatingWorkerCount() {
|
||||
return await this.bankContainer().locator('.workerText').locator('.displayContent').nth(2).textContent();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BankComponent;
|
||||
@@ -0,0 +1,35 @@
|
||||
class BuildChartComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
chartsContainer() {
|
||||
return this.page.locator('.chartsContainer');
|
||||
}
|
||||
|
||||
displayValue(label) {
|
||||
return this.page.locator('.displayContainer').filter({ hasText: label }).locator('.displayContent');
|
||||
}
|
||||
|
||||
async getHighestAlloy() {
|
||||
return await this.displayValue('Highest Alloy').textContent();
|
||||
}
|
||||
|
||||
async getHighestEther() {
|
||||
return await this.displayValue('Highest Ether').textContent();
|
||||
}
|
||||
|
||||
async getHighestPyre() {
|
||||
return await this.displayValue('Highest Pyre').textContent();
|
||||
}
|
||||
|
||||
async getHighestArmy() {
|
||||
return await this.displayValue('Highest Army').textContent();
|
||||
}
|
||||
|
||||
async getChartCount() {
|
||||
return await this.chartsContainer().locator('> div').count();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BuildChartComponent;
|
||||
@@ -0,0 +1,15 @@
|
||||
class BuildOrderComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
jsonTextarea() {
|
||||
return this.page.locator('textarea');
|
||||
}
|
||||
|
||||
async getJsonData() {
|
||||
return await this.jsonTextarea().inputValue();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BuildOrderComponent;
|
||||
@@ -0,0 +1,33 @@
|
||||
class EntityClickViewComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
entityClickView() {
|
||||
return this.page.locator('.entityClickView');
|
||||
}
|
||||
|
||||
async getEntityName() {
|
||||
const el = this.entityClickView().locator('#entityName');
|
||||
if ((await el.count()) === 0) return null;
|
||||
return (await el.textContent()) || '';
|
||||
}
|
||||
|
||||
async getEntityHealth() {
|
||||
const healthText = this.entityClickView().locator('div').filter({ hasText: /Health/i }).first();
|
||||
if ((await healthText.count()) === 0) return null;
|
||||
const text = (await healthText.textContent()) || '';
|
||||
const match = text.match(/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async clickDetailedView() {
|
||||
await this.entityClickView().locator('button').filter({ hasText: 'Detailed' }).click();
|
||||
}
|
||||
|
||||
async clickPlainView() {
|
||||
await this.entityClickView().locator('button').filter({ hasText: 'Plain' }).click();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EntityClickViewComponent;
|
||||
@@ -0,0 +1,35 @@
|
||||
class FilterComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
factionSelect() {
|
||||
return this.page.locator('select').filter({ has: this.page.locator('option:has-text("Aru"), option:has-text("Q\'Rath")') });
|
||||
}
|
||||
|
||||
immortalSelect() {
|
||||
return this.page.locator('select').filter({ has: this.page.locator('option:has-text("Orzum"), option:has-text("Ajari"), option:has-text("Atzlan"), option:has-text("Mala"), option:has-text("Xol")') });
|
||||
}
|
||||
|
||||
async selectFaction(faction) {
|
||||
await this.factionSelect().selectOption(faction);
|
||||
}
|
||||
|
||||
async selectImmortal(immortal) {
|
||||
await this.immortalSelect().selectOption(immortal);
|
||||
}
|
||||
|
||||
async getSelectedFaction() {
|
||||
return await this.factionSelect().inputValue();
|
||||
}
|
||||
|
||||
async getSelectedImmortal() {
|
||||
return await this.immortalSelect().inputValue();
|
||||
}
|
||||
|
||||
async getAvailableImmortals() {
|
||||
return await this.immortalSelect().locator('option').allTextContents();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FilterComponent;
|
||||
@@ -0,0 +1,39 @@
|
||||
class HighlightsComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
highlightsContainer() {
|
||||
return this.page.locator('.highlightsContainer');
|
||||
}
|
||||
|
||||
requestedColumn() {
|
||||
return this.highlightsContainer().locator('div').filter({ hasText: 'Requested' }).locator('+ div');
|
||||
}
|
||||
|
||||
finishedColumn() {
|
||||
return this.highlightsContainer().locator('div').filter({ hasText: 'Finished' }).locator('+ div');
|
||||
}
|
||||
|
||||
async getRequestedItems() {
|
||||
const items = await this.highlightsContainer().locator('div').filter({ hasText: /^\d+\s*\|/ }).all();
|
||||
const result = [];
|
||||
for (const item of items) {
|
||||
const text = (await item.textContent()) || '';
|
||||
result.push(text.trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getFinishedItems() {
|
||||
const items = await this.highlightsContainer().locator('div').filter({ hasText: /^\d+\s*\|/ }).all();
|
||||
const result = [];
|
||||
for (const item of items) {
|
||||
const text = (await item.textContent()) || '';
|
||||
result.push(text.trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HighlightsComponent;
|
||||
@@ -0,0 +1,50 @@
|
||||
class HotkeyViewerComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
keyContainer() {
|
||||
return this.page.locator('.keyContainer');
|
||||
}
|
||||
|
||||
async _findKeyButton(keyLabel) {
|
||||
const upper = keyLabel.toUpperCase();
|
||||
const buttons = this.keyContainer().locator('> div > div');
|
||||
const count = await buttons.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = buttons.nth(i);
|
||||
const text = (await btn.textContent()) || '';
|
||||
if (text.trim().toUpperCase().startsWith(upper)) return btn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async clickKey(keyText) {
|
||||
const btn = await this._findKeyButton(keyText);
|
||||
if (!btn) throw new Error(`Key "${keyText}" not found`);
|
||||
await btn.click({ force: true });
|
||||
}
|
||||
|
||||
async getFirstEntityName(keyText) {
|
||||
const btn = await this._findKeyButton(keyText);
|
||||
if (!btn) return null;
|
||||
const entities = btn.locator('> div');
|
||||
if ((await entities.count()) === 0) return null;
|
||||
return (await entities.first().textContent()) || '';
|
||||
}
|
||||
|
||||
async getEntityNamesOnKey(keyText) {
|
||||
const btn = await this._findKeyButton(keyText);
|
||||
if (!btn) return [];
|
||||
const entities = btn.locator('> div');
|
||||
const count = await entities.count();
|
||||
const names = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = (await entities.nth(i).textContent()) || '';
|
||||
names.push(text.trim());
|
||||
}
|
||||
return names.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HotkeyViewerComponent;
|
||||
@@ -0,0 +1,68 @@
|
||||
class OptionsComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
buildingInputDelayInput() {
|
||||
return this.formNumberInput('Building Input Delay');
|
||||
}
|
||||
|
||||
waitTimeInput() {
|
||||
return this.formNumberInput('Wait Time');
|
||||
}
|
||||
|
||||
waitToInput() {
|
||||
return this.formNumberInput('Wait To');
|
||||
}
|
||||
|
||||
addWaitButton() {
|
||||
return this.buttonWithLabel('Add Wait').first();
|
||||
}
|
||||
|
||||
addWaitToButton() {
|
||||
return this.buttonWithLabel('Add Wait').last();
|
||||
}
|
||||
|
||||
formNumberInput(label) {
|
||||
return this.page.locator(`.formNumberContainer`).filter({ hasText: label }).locator('input[type="number"]');
|
||||
}
|
||||
|
||||
buttonWithLabel(label) {
|
||||
return this.page.locator('button').filter({ hasText: label });
|
||||
}
|
||||
|
||||
async setBuildingInputDelay(value) {
|
||||
await this.buildingInputDelayInput().fill(String(value));
|
||||
await this.buildingInputDelayInput().press('Enter');
|
||||
}
|
||||
|
||||
async setWaitTime(value) {
|
||||
await this.waitTimeInput().fill(String(value));
|
||||
}
|
||||
|
||||
async setWaitTo(value) {
|
||||
await this.waitToInput().fill(String(value));
|
||||
}
|
||||
|
||||
async clickAddWait() {
|
||||
await this.addWaitButton().click();
|
||||
}
|
||||
|
||||
async clickAddWaitTo() {
|
||||
await this.addWaitToButton().click();
|
||||
}
|
||||
|
||||
async getBuildingInputDelay() {
|
||||
return await this.buildingInputDelayInput().inputValue();
|
||||
}
|
||||
|
||||
async getWaitTime() {
|
||||
return await this.waitTimeInput().inputValue();
|
||||
}
|
||||
|
||||
async getWaitTo() {
|
||||
return await this.waitToInput().inputValue();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OptionsComponent;
|
||||
@@ -0,0 +1,16 @@
|
||||
class TimelineComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
container() {
|
||||
return this.page.locator('.calculatorGrid > div').filter({ hasText: 'Timeline highlights' });
|
||||
}
|
||||
|
||||
async containsEntity(name) {
|
||||
const text = (await this.container().textContent()) || '';
|
||||
return text.includes(name);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimelineComponent;
|
||||
@@ -0,0 +1,37 @@
|
||||
class TimingComponent {
|
||||
constructor(website) {
|
||||
this.website = website;
|
||||
}
|
||||
|
||||
attackTimeInput() {
|
||||
return this.formNumberInput('Attack Time');
|
||||
}
|
||||
|
||||
travelTimeInput() {
|
||||
return this.formNumberInput('Travel Time');
|
||||
}
|
||||
|
||||
formNumberInput(label) {
|
||||
return this.website.locator(`.formNumberContainer`).filter({ hasText: label }).locator('input[type="number"]');
|
||||
}
|
||||
|
||||
async setAttackTime(value) {
|
||||
await this.attackTimeInput().fill(String(value));
|
||||
await this.attackTimeInput().press('Enter');
|
||||
}
|
||||
|
||||
async setTravelTime(value) {
|
||||
await this.travelTimeInput().fill(String(value));
|
||||
await this.travelTimeInput().press('Enter');
|
||||
}
|
||||
|
||||
async getAttackTime() {
|
||||
return await this.attackTimeInput().inputValue();
|
||||
}
|
||||
|
||||
async getTravelTime() {
|
||||
return await this.travelTimeInput().inputValue();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimingComponent;
|
||||
@@ -0,0 +1,56 @@
|
||||
const TimingComponent = require('./buildCalculator/timingComponent');
|
||||
const FilterComponent = require('./buildCalculator/filterComponent');
|
||||
const OptionsComponent = require('./buildCalculator/optionsComponent');
|
||||
const BankComponent = require('./buildCalculator/bankComponent');
|
||||
const ArmyComponent = require('./buildCalculator/armyComponent');
|
||||
const HighlightsComponent = require('./buildCalculator/highlightsComponent');
|
||||
const BuildOrderComponent = require('./buildCalculator/buildOrderComponent');
|
||||
const TimelineComponent = require('./buildCalculator/timelineComponent');
|
||||
const HotkeyViewerComponent = require('./buildCalculator/hotkeyViewerComponent');
|
||||
const EntityClickViewComponent = require('./buildCalculator/entityClickViewComponent');
|
||||
const BuildChartComponent = require('./buildCalculator/buildChartComponent');
|
||||
const ToastComponent = require('../shared/toastComponent');
|
||||
|
||||
const BasePage = require('./base.page');
|
||||
|
||||
|
||||
class BuildCalculatorPage extends BasePage {
|
||||
constructor(website) {
|
||||
super(website);
|
||||
this.timing = new TimingComponent(website);
|
||||
this.filter = new FilterComponent(website);
|
||||
this.options = new OptionsComponent(website);
|
||||
this.bank = new BankComponent(website);
|
||||
this.army = new ArmyComponent(website);
|
||||
this.highlights = new HighlightsComponent(website);
|
||||
this.buildOrder = new BuildOrderComponent(website);
|
||||
this.timeline = new TimelineComponent(website);
|
||||
this.hotkeys = new HotkeyViewerComponent(website);
|
||||
this.entityView = new EntityClickViewComponent(website);
|
||||
this.chart = new BuildChartComponent(website);
|
||||
this.toast = new ToastComponent(website);
|
||||
}
|
||||
|
||||
get url() {
|
||||
return 'build-calculator';
|
||||
}
|
||||
|
||||
calculatorGrid() {
|
||||
return this.website.locator('.calculatorGrid');
|
||||
}
|
||||
|
||||
clearBuildOrderButton() {
|
||||
return this.website.locator('button').filter({ hasText: 'Clear Build Order' });
|
||||
}
|
||||
|
||||
async clickClearBuildOrder() {
|
||||
await this.clearBuildOrderButton().click();
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.website.goto(this.url);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BuildCalculatorPage;
|
||||
@@ -0,0 +1,27 @@
|
||||
const BasePage = require('./base.page');
|
||||
|
||||
class DatabasePage extends BasePage {
|
||||
get url() { return 'database'; }
|
||||
|
||||
async filterName(name) {
|
||||
await this.website.enterInput(this.website.findAll('filterName').first(), name);
|
||||
return this;
|
||||
}
|
||||
|
||||
async getEntityName(entityType, entityName) {
|
||||
return await this.website
|
||||
.findWithParent('entityName', `${entityType.toLowerCase()}-${entityName.toLowerCase()}`)
|
||||
.innerText();
|
||||
}
|
||||
|
||||
async getEntityNameByIndex(index) {
|
||||
return await this.website.findAll('entityName').nth(index).innerText();
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.website.goto(this.url);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DatabasePage;
|
||||
@@ -0,0 +1,28 @@
|
||||
const BasePage = require('./base.page');
|
||||
|
||||
class DatabaseSinglePage extends BasePage {
|
||||
get url() { return 'database'; }
|
||||
|
||||
async getEntityName() {
|
||||
return await this.website.find('entityName').innerText();
|
||||
}
|
||||
|
||||
async getEntityHealth() {
|
||||
return await this.website.find('entityHealth').innerText();
|
||||
}
|
||||
|
||||
async getInvalidSearch() {
|
||||
return await this.website.find('invalidSearch').innerText();
|
||||
}
|
||||
|
||||
async getValidSearch() {
|
||||
return await this.website.find('validSearch').innerText();
|
||||
}
|
||||
|
||||
async goto(searchText) {
|
||||
await this.website.goto(`${this.url}/${searchText}`);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DatabaseSinglePage;
|
||||
@@ -0,0 +1,68 @@
|
||||
const BasePage = require('./base.page');
|
||||
|
||||
class HarassCalculatorPage extends BasePage {
|
||||
get url() { return 'harass-calculator'; }
|
||||
|
||||
async setWorkersLostToHarass(number) {
|
||||
await this.website.enterInput(this.website.find('numberOfWorkersLostToHarass'), number);
|
||||
return this;
|
||||
}
|
||||
|
||||
async setNumberOfTownHallsExisting(number) {
|
||||
await this.website.enterInput(this.website.find('numberOfTownHallsExisting'), number);
|
||||
return this;
|
||||
}
|
||||
|
||||
async setTownHallTravelTime(forTownHall, number) {
|
||||
const inputs = this.website.findChildren('numberOfTownHallTravelTimes', 'input');
|
||||
await this.website.enterInput(inputs.nth(forTownHall), number);
|
||||
return this;
|
||||
}
|
||||
|
||||
async getTotalAlloyHarassment() {
|
||||
return await this.website.findInt('totalAlloyHarassment');
|
||||
}
|
||||
|
||||
async getWorkerReplacementCost() {
|
||||
return await this.website.findInt('workerReplacementCost');
|
||||
}
|
||||
|
||||
async getDelayedMiningCost() {
|
||||
return await this.website.findInt('delayedMiningCost');
|
||||
}
|
||||
|
||||
async getAverageTravelTime() {
|
||||
return await this.website.findInt('getAverageTravelTime');
|
||||
}
|
||||
|
||||
async getExampleTotalAlloyLoss() {
|
||||
return await this.website.findInt('exampleTotalAlloyLoss');
|
||||
}
|
||||
|
||||
async getExampleWorkerCost() {
|
||||
return await this.website.findInt('exampleWorkerCost');
|
||||
}
|
||||
|
||||
async getExampleMiningTimeCost() {
|
||||
return await this.website.findInt('exampleMiningTimeCost');
|
||||
}
|
||||
|
||||
async getExampleTotalAlloyLossAccurate() {
|
||||
return await this.website.findInt('exampleTotalAlloyLossAccurate');
|
||||
}
|
||||
|
||||
async getExampleTotalAlloyLossDifference() {
|
||||
return await this.website.findInt('exampleTotalAlloyLossDifference');
|
||||
}
|
||||
|
||||
async getExampleTotalAlloyLossAccurateDifference() {
|
||||
return await this.website.findInt('exampleTotalAlloyLossAccurateDifference');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.website.goto(this.url);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HarassCalculatorPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
retries: 1,
|
||||
timeout: 30000,
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
class NavigationBar {
|
||||
constructor(website) {
|
||||
this.website = website;
|
||||
}
|
||||
|
||||
get searchButton() { return this.website.findScreenSpecific('searchButton'); }
|
||||
|
||||
async clickHomeLink() {
|
||||
await this.website.clickElement(this.website.locator('a:has-text("IGP Fan Reference")'));
|
||||
return this;
|
||||
}
|
||||
|
||||
async clickSearchButton() {
|
||||
await this.website.clickElement(this.searchButton);
|
||||
return this.website.websiteSearchDialog;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NavigationBar;
|
||||
@@ -0,0 +1,40 @@
|
||||
class ToastComponent {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
container() {
|
||||
return this.page.locator('.toastsContainer');
|
||||
}
|
||||
|
||||
toasts() {
|
||||
return this.page.locator('.toastsContainer .toastContainer');
|
||||
}
|
||||
|
||||
async getToastTitles() {
|
||||
const titles = await this.page.locator('.toastsContainer .toastTitle').allTextContents();
|
||||
return titles.map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
_page() {
|
||||
return this.page.page || this.page;
|
||||
}
|
||||
|
||||
async hasToastContaining(text) {
|
||||
try {
|
||||
await this._page().waitForFunction(
|
||||
(expected) => {
|
||||
const titles = document.querySelectorAll('.toastsContainer .toastTitle');
|
||||
return Array.from(titles).some(t => t.textContent.trim().includes(expected));
|
||||
},
|
||||
text,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ToastComponent;
|
||||
@@ -0,0 +1,25 @@
|
||||
class WebsiteSearchDialog {
|
||||
constructor(website) {
|
||||
this.website = website;
|
||||
}
|
||||
|
||||
get searchBackground() { return this.website.find('searchBackground'); }
|
||||
get searchInput() { return this.website.find('searchInput'); }
|
||||
|
||||
async closeDialog() {
|
||||
await this.website.clickSearchBackground();
|
||||
return this.website.navigationBar;
|
||||
}
|
||||
|
||||
async search(text) {
|
||||
await this.website.enterInput(this.searchInput, text);
|
||||
return this;
|
||||
}
|
||||
|
||||
async selectSearchEntity(label) {
|
||||
await this.website.clickElement(this.website.findButtonWithLabel(label));
|
||||
return this.website.databaseSinglePage;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WebsiteSearchDialog;
|
||||
@@ -0,0 +1,125 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const BuildCalculatorPage = require('../pages/buildCalculatorPage');
|
||||
const { Website } = require('../helpers/website');
|
||||
|
||||
|
||||
|
||||
test.describe('Build Calculator', () => {
|
||||
|
||||
let website;
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
website = new Website(page);
|
||||
});
|
||||
|
||||
test('Add entities via keyboard Q, W, E with Q\'Rath/Orzum', async ({ page }) => {
|
||||
const calc = website.buildCalculatorPage;
|
||||
await calc.goto();
|
||||
|
||||
await calc.filter.selectFaction("Q'Rath");
|
||||
await calc.filter.selectImmortal('Orzum');
|
||||
|
||||
await calc.hotkeys.clickKey('TAB');
|
||||
|
||||
const keyNames = { Q: 'q', W: 'w', E: 'e', TAB: 'Tab' };
|
||||
|
||||
for (const key of ['Q', 'W', 'E', 'TAB']) {
|
||||
const entityNames = await calc.hotkeys.getEntityNamesOnKey(key);
|
||||
if (entityNames.length === 0) continue;
|
||||
|
||||
await page.keyboard.press(keyNames[key]);
|
||||
|
||||
const viewName = await calc.entityView.getEntityName();
|
||||
expect(viewName).toBeTruthy();
|
||||
expect(entityNames).toContain(viewName);
|
||||
}
|
||||
});
|
||||
|
||||
test('Add entities via hotkeys TAB, Q, W, E with Q\'Rath/Orzum', async ({ page }) => {
|
||||
const calc = website.buildCalculatorPage;
|
||||
await calc.goto();
|
||||
|
||||
await calc.filter.selectFaction("Q'Rath");
|
||||
await calc.filter.selectImmortal('Orzum');
|
||||
|
||||
for (const key of ['TAB', 'Q', 'W', 'E']) {
|
||||
const entityNames = await calc.hotkeys.getEntityNamesOnKey(key);
|
||||
if (entityNames.length === 0) continue;
|
||||
|
||||
await calc.hotkeys.clickKey(key);
|
||||
|
||||
const viewName = await calc.entityView.getEntityName();
|
||||
expect(viewName).toBeTruthy();
|
||||
expect(entityNames).toContain(viewName);
|
||||
}
|
||||
});
|
||||
|
||||
test('Add Acropolis via Q, verify entity view and timeline, then clear', async ({ page }) => {
|
||||
const calc = website.buildCalculatorPage;
|
||||
await calc.goto();
|
||||
|
||||
const buttons = page.locator('.keyContainer > div > div');
|
||||
console.log('Initial Q button text:', await buttons.filter({ hasText: /^Q/ }).first().textContent());
|
||||
|
||||
// Wait for Blazor re-render to complete by waiting for the button text to stabilize
|
||||
// (it goes from QAcropolis → empty during re-render → back to QAcropolis)
|
||||
let tries = 0;
|
||||
let text = '';
|
||||
while (tries < 20) {
|
||||
await page.waitForTimeout(500);
|
||||
try {
|
||||
text = (await buttons.filter({ hasText: /^Q/ }).first().textContent() || '').trim();
|
||||
if (text && text.length > 1) break;
|
||||
} catch { }
|
||||
tries++;
|
||||
}
|
||||
console.log(`After Blazor render (${(tries+1)*0.5}s): Q button text: ${JSON.stringify(text)}`);
|
||||
|
||||
await calc.filter.selectFaction("Q'Rath");
|
||||
await calc.filter.selectImmortal('Orzum');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
console.log('After filter Q button text:', await buttons.filter({ hasText: /^Q/ }).first().textContent());
|
||||
|
||||
expect(await calc.timeline.containsEntity('Acropolis')).toBe(false);
|
||||
|
||||
await calc.hotkeys.clickKey('Q');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(await calc.entityView.getEntityName()).toBe('Acropolis');
|
||||
expect(await calc.timeline.containsEntity('Acropolis')).toBe(true);
|
||||
|
||||
await calc.clickClearBuildOrder();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(await calc.timeline.containsEntity('Acropolis')).toBe(false);
|
||||
expect(await calc.entityView.getEntityName()).toBeNull();
|
||||
});
|
||||
|
||||
test('Missing Requirements toast when building Soul Foundry without Legion Hall', async ({ page }) => {
|
||||
const calc = website.buildCalculatorPage;
|
||||
await calc.goto();
|
||||
|
||||
await calc.filter.selectFaction("Q'Rath");
|
||||
await calc.filter.selectImmortal('Orzum');
|
||||
|
||||
await calc.hotkeys.clickKey('E');
|
||||
const hasToast = await calc.toast.hasToastContaining('Missing Requirements');
|
||||
expect(hasToast).toBe(true);
|
||||
});
|
||||
|
||||
test('Not Enough Ether toast when building Soul Foundry after Legion Hall', async ({ page }) => {
|
||||
const calc = website.buildCalculatorPage;
|
||||
|
||||
await calc.goto();
|
||||
|
||||
await calc.filter.selectFaction("Q'Rath");
|
||||
await calc.filter.selectImmortal('Orzum');
|
||||
|
||||
await calc.hotkeys.clickKey('W');
|
||||
|
||||
await calc.hotkeys.clickKey('E');
|
||||
const hasToast = await calc.toast.hasToastContaining('Not Enough Ether');
|
||||
expect(hasToast).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { Website } = require('../helpers/website');
|
||||
|
||||
test.describe('Harass Calculator', () => {
|
||||
let website;
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
website = new Website(page);
|
||||
});
|
||||
|
||||
test('CalculatorInput', async () => {
|
||||
const page = website.harassCalculatorPage;
|
||||
await page.goto();
|
||||
await page.setWorkersLostToHarass(3);
|
||||
await page.setNumberOfTownHallsExisting(2);
|
||||
await page.setTownHallTravelTime(0, 30);
|
||||
const result = await page.getTotalAlloyHarassment();
|
||||
expect(result).toBe(240);
|
||||
});
|
||||
|
||||
test('CalculatedExampleInformation', async () => {
|
||||
const page = website.harassCalculatorPage;
|
||||
await page.goto();
|
||||
|
||||
expect(await page.getExampleTotalAlloyLoss()).toBe(720);
|
||||
expect(await page.getExampleWorkerCost()).toBe(300);
|
||||
expect(await page.getExampleMiningTimeCost()).toBe(420);
|
||||
expect(await page.getExampleTotalAlloyLossAccurate()).toBe(450);
|
||||
expect(await page.getExampleTotalAlloyLossDifference()).toBe(300);
|
||||
expect(await page.getExampleTotalAlloyLossAccurateDifference()).toBe(270);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const { test } = require('@playwright/test');
|
||||
const { Website } = require('../helpers/website');
|
||||
const TestReport = require('../utils/testReport');
|
||||
|
||||
test.describe('Link Verification', () => {
|
||||
let website;
|
||||
let testReport;
|
||||
|
||||
test.beforeEach(() => {
|
||||
testReport = new TestReport();
|
||||
});
|
||||
|
||||
test('VerifyPageLinks', async ({ page }) => {
|
||||
website = new Website(page);
|
||||
testReport.createTest(test.info().title);
|
||||
|
||||
await website.harassCalculatorPage.goto();
|
||||
await testReport.verifyLinks(website.harassCalculatorPage);
|
||||
|
||||
await website.databasePage.goto();
|
||||
await testReport.verifyLinks(website.databasePage);
|
||||
|
||||
await website.databaseSinglePage.goto('throne');
|
||||
await testReport.verifyLinks(website.databaseSinglePage);
|
||||
|
||||
testReport.throwErrors();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { Website } = require('../helpers/website');
|
||||
|
||||
test.describe('Search Features', () => {
|
||||
let website;
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
website = new Website(page);
|
||||
});
|
||||
|
||||
test('DesktopOpenCloseSearchDialog', async () => {
|
||||
await website.goto();
|
||||
await website.navigationBar.clickSearchButton();
|
||||
await website.websiteSearchDialog.closeDialog();
|
||||
await website.navigationBar.clickHomeLink();
|
||||
});
|
||||
|
||||
test('DesktopSearchForThrone', async () => {
|
||||
await website.goto();
|
||||
await website.navigationBar.clickSearchButton();
|
||||
await website.websiteSearchDialog.search('Throne');
|
||||
const page = await website.websiteSearchDialog.selectSearchEntity('Throne');
|
||||
|
||||
const name = await page.getEntityName();
|
||||
const health = await page.getEntityHealth();
|
||||
|
||||
expect(name).toBe('Throne');
|
||||
expect(health.trim()).not.toBe('');
|
||||
});
|
||||
|
||||
test('DesktopFilterForThrone', async () => {
|
||||
const page = website.databasePage;
|
||||
await page.goto();
|
||||
await page.filterName('Throne');
|
||||
const name = await page.getEntityNameByIndex(0);
|
||||
expect(name).toBe('Throne');
|
||||
});
|
||||
|
||||
test('SeeThroneByDefault', async () => {
|
||||
const page = website.databasePage;
|
||||
await page.goto();
|
||||
const name = await page.getEntityName('army', 'throne');
|
||||
expect(name).toBe('Throne');
|
||||
});
|
||||
|
||||
test('DirectLinkNotThroneFailure', async () => {
|
||||
const page = website.databaseSinglePage;
|
||||
await page.goto('not throne');
|
||||
const invalidSearch = await page.getInvalidSearch();
|
||||
const validSearch = await page.getValidSearch();
|
||||
expect(invalidSearch).toBe('not throne');
|
||||
expect(validSearch).toBe('Throne');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
class TestReport {
|
||||
constructor() {
|
||||
this.tests = [];
|
||||
}
|
||||
|
||||
createTest(name) {
|
||||
const test = { name, result: true, messages: [] };
|
||||
this.tests.push(test);
|
||||
return test;
|
||||
}
|
||||
|
||||
throwErrors() {
|
||||
const latest = this.tests[this.tests.length - 1];
|
||||
if (!latest.result) {
|
||||
const msgs = latest.messages.map(m => m.description).join('\n');
|
||||
throw new Error(`${latest.name} test failed with ${latest.messages.length} messages.\n\n${msgs}`);
|
||||
}
|
||||
}
|
||||
|
||||
checkPassed(passed, message) {
|
||||
if (!passed) {
|
||||
const latest = this.tests[this.tests.length - 1];
|
||||
latest.result = false;
|
||||
latest.messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyLinks(page) {
|
||||
const links = await page.getLinks();
|
||||
for (const link of links) {
|
||||
if (link.startsWith('mailto')) continue;
|
||||
try {
|
||||
const response = await fetch(link);
|
||||
if (!response.ok) {
|
||||
this.checkPassed(false, {
|
||||
color: 'red',
|
||||
title: 'Bad Link',
|
||||
description: `${link} failed on page ${page.url} with status code ${response.status}`
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.checkPassed(false, {
|
||||
color: 'red',
|
||||
title: 'Bad Link',
|
||||
description: `${link} failed on page ${page.url} with error ${e.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
didTestsPass() {
|
||||
return this.tests.every(t => t.result);
|
||||
}
|
||||
|
||||
getMessages() {
|
||||
if (this.didTestsPass()) {
|
||||
return [{
|
||||
title: 'Passed',
|
||||
color: 0x00FF00,
|
||||
description: `All ${this.tests.length} tests passed.`
|
||||
}];
|
||||
}
|
||||
const messages = [];
|
||||
for (const test of this.tests) {
|
||||
for (const msg of test.messages) {
|
||||
messages.push({
|
||||
title: msg.title,
|
||||
color: parseInt(msg.color, 16),
|
||||
description: msg.description
|
||||
});
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TestReport;
|
||||
@@ -1 +1,26 @@
|
||||
|
||||
|
||||
# IGP Fan Reference
|
||||
|
||||
A fan-made reference site for *IMMORTAL: Gates of Pyre*.
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation for developers is available in the [`/docs`](./docs) folder:
|
||||
|
||||
- [**Overview**](./docs/overview.md): High-level project description and tech stack.
|
||||
- [**Architecture**](./docs/architecture.md): Solution structure and design patterns.
|
||||
- [**Services**](./docs/services.md): Detailed explanation of core application services.
|
||||
- [**Components**](./docs/components.md): UI component library and layout structure.
|
||||
- [**Development Guide**](./docs/development.md): Build, test, and deployment instructions.
|
||||
- [**Recommendations**](./docs/recommendations.md): Suggestions for code and design improvements.
|
||||
|
||||
## Quick Start
|
||||
|
||||
To run the project locally:
|
||||
|
||||
1. Navigate to the `IGP` directory.
|
||||
2. Run `dotnet watch run`.
|
||||
3. Open `https://localhost:5001`.
|
||||
|
||||
For more details, see the [Development Guide](./docs/development.md).
|
||||
|
||||
@@ -290,15 +290,15 @@ public class BuildOrderService : IBuildOrderService
|
||||
{
|
||||
return (from ordersAtTime in _buildOrder.StartedOrders
|
||||
from orders in ordersAtTime.Value
|
||||
where orders.Harvest() != null
|
||||
where ordersAtTime.Key + (orders.Production() == null
|
||||
? 0
|
||||
: orders.Production().BuildTime) <= interval
|
||||
&& !orders.Harvest().IsDepleted(
|
||||
interval,
|
||||
ordersAtTime.Key + (orders.Production() == null
|
||||
? 0
|
||||
: orders.Production().BuildTime))
|
||||
where orders.Harvest() != null
|
||||
&& !orders.Harvest().IsDepleted(
|
||||
interval,
|
||||
ordersAtTime.Key + (orders.Production() == null
|
||||
? 0
|
||||
: orders.Production().BuildTime))
|
||||
select orders).ToList();
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"theme": "obsidian"
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
[
|
||||
"kanban-bases-view",
|
||||
"code-styler",
|
||||
"calendar",
|
||||
"custom-font-loader",
|
||||
"obsidian-note-autocreator"
|
||||
]
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"footnotes": false,
|
||||
"properties": true,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": true,
|
||||
"bases": true,
|
||||
"webviewer": false
|
||||
}
|
||||
BIN
Binary file not shown.
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"shouldConfirmBeforeCreate": true,
|
||||
"weekStart": "locale",
|
||||
"wordsPerDot": 250,
|
||||
"showWeeklyNote": false,
|
||||
"weeklyNoteFormat": "",
|
||||
"weeklyNoteTemplate": "",
|
||||
"weeklyNoteFolder": "",
|
||||
"localeOverride": "system-default"
|
||||
}
|
||||
+4459
File diff suppressed because it is too large
Load Diff
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"description": "Calendar view of your daily notes",
|
||||
"version": "1.5.10",
|
||||
"author": "Liam Cain",
|
||||
"authorUrl": "https://github.com/liamcain/",
|
||||
"isDesktopOnly": false,
|
||||
"minAppVersion": "0.9.11"
|
||||
}
|
||||
+20047
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "code-styler",
|
||||
"name": "Code Styler",
|
||||
"version": "1.1.7",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Style and customize codeblocks and inline code in both editing mode and reading mode.",
|
||||
"author": "Mayuran Visakan",
|
||||
"authorUrl": "https://github.com/mayurankv",
|
||||
"fundingUrl": "https://www.buymeacoffee.com/mayurankv2",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+1348
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"font_folder": ".obsidian/fonts/",
|
||||
"font": "JetBrainsMono-Regular.ttf",
|
||||
"force_mode": false,
|
||||
"custom_css_mode": false,
|
||||
"custom_css": ""
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+357
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// main.ts
|
||||
var main_exports = {};
|
||||
__export(main_exports, {
|
||||
default: () => FontPlugin
|
||||
});
|
||||
module.exports = __toCommonJS(main_exports);
|
||||
var import_obsidian = require("obsidian");
|
||||
var DEFAULT_SETTINGS = {
|
||||
font_folder: "",
|
||||
font: "None",
|
||||
force_mode: false,
|
||||
custom_css_mode: false,
|
||||
custom_css: ""
|
||||
};
|
||||
function get_default_css(font_family_name, css_class = ":root *") {
|
||||
return `${css_class} {
|
||||
--font-default: '${font_family_name}';
|
||||
--default-font: '${font_family_name}';
|
||||
--font-family-editor: '${font_family_name}';
|
||||
--font-monospace-default: '${font_family_name}';
|
||||
--font-interface-override: '${font_family_name}';
|
||||
--font-text-override: '${font_family_name}';
|
||||
--font-monospace-override: '${font_family_name}';
|
||||
}
|
||||
`;
|
||||
}
|
||||
function get_custom_css(font_family_name, css_class = ":root *") {
|
||||
return `${css_class} * {
|
||||
font-family: '${font_family_name}' !important;
|
||||
}`;
|
||||
}
|
||||
function arrayBufferToBase64(buffer) {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
function applyCss(css, css_id, appendMode = false) {
|
||||
const existingStyle = document.getElementById(css_id);
|
||||
if (existingStyle && appendMode) {
|
||||
existingStyle.innerHTML += css;
|
||||
} else {
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = css;
|
||||
document.head.appendChild(style);
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
style.id = css_id;
|
||||
}
|
||||
}
|
||||
var FontPlugin = class extends import_obsidian.Plugin {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.config_dir = this.app.vault.configDir;
|
||||
this.plugin_folder_path = `${this.config_dir}/plugins/custom-font-loader`;
|
||||
}
|
||||
async load_plugin() {
|
||||
await this.loadSettings();
|
||||
try {
|
||||
const font_file_name = this.settings.font;
|
||||
if (font_file_name && font_file_name.toLowerCase() != "none") {
|
||||
if (font_file_name != "all") {
|
||||
await this.process_and_load_font(font_file_name, false);
|
||||
} else {
|
||||
applyCss("", "custom_font_base64");
|
||||
const files = await this.app.vault.adapter.list(
|
||||
this.settings.font_folder
|
||||
);
|
||||
for (const file of files.files) {
|
||||
const file_name = file.replace(
|
||||
this.settings.font_folder,
|
||||
""
|
||||
);
|
||||
await this.process_and_load_font(file_name, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
applyCss("", "custom_font_base64");
|
||||
applyCss("", "custom_font_general");
|
||||
}
|
||||
} catch (error) {
|
||||
new import_obsidian.Notice(error);
|
||||
}
|
||||
}
|
||||
async process_and_load_font(font_file_name, load_all_fonts) {
|
||||
console.log("loading %s", font_file_name);
|
||||
const css_font_path = `${this.plugin_folder_path}/${font_file_name.toLowerCase().replace(".", "_")}.css`;
|
||||
if (!await this.app.vault.adapter.exists(css_font_path)) {
|
||||
await this.convert_font_to_css(font_file_name, css_font_path);
|
||||
} else {
|
||||
await this.load_font(css_font_path, load_all_fonts);
|
||||
await this.load_css(font_file_name);
|
||||
}
|
||||
}
|
||||
async load_font(css_font_path, appendMode) {
|
||||
const content = await this.app.vault.adapter.read(css_font_path);
|
||||
applyCss(content, "custom_font_base64", appendMode);
|
||||
}
|
||||
async load_css(font_file_name) {
|
||||
let css_string = "";
|
||||
const font_family_name = font_file_name.split(".")[0].toLowerCase();
|
||||
if (this.settings.custom_css_mode) {
|
||||
css_string = this.settings.custom_css;
|
||||
} else {
|
||||
css_string = get_default_css(font_family_name);
|
||||
}
|
||||
if (this.settings.force_mode)
|
||||
css_string += `
|
||||
* {
|
||||
font-family: '${font_family_name}' !important;
|
||||
}
|
||||
`;
|
||||
applyCss(css_string, "custom_font_general");
|
||||
}
|
||||
async convert_font_to_css(font_file_name, css_font_path) {
|
||||
new import_obsidian.Notice("Processing Font files");
|
||||
const file = `${this.settings.font_folder}/${font_file_name}`;
|
||||
const arrayBuffer = await this.app.vault.adapter.readBinary(file);
|
||||
const base64 = arrayBufferToBase64(arrayBuffer);
|
||||
const font_family_name = font_file_name.split(".")[0].toLowerCase();
|
||||
const font_extension_name = font_file_name.split(".")[1].toLowerCase();
|
||||
let css_type = "";
|
||||
switch (font_extension_name) {
|
||||
case "woff":
|
||||
css_type = "font/woff";
|
||||
break;
|
||||
case "ttf":
|
||||
css_type = "font/truetype";
|
||||
break;
|
||||
case "woff2":
|
||||
css_type = "font/woff2";
|
||||
break;
|
||||
case "otf":
|
||||
css_type = "font/opentype";
|
||||
break;
|
||||
default:
|
||||
css_type = "font";
|
||||
}
|
||||
const base64_css = `@font-face{
|
||||
font-family: '${font_family_name}';
|
||||
src: url(data:${css_type};base64,${base64});
|
||||
}`;
|
||||
this.app.vault.adapter.write(css_font_path, base64_css);
|
||||
console.log("saved font %s into %s", font_family_name, css_font_path);
|
||||
console.log("Font CSS Saved into %s", css_font_path);
|
||||
await this.load_plugin();
|
||||
}
|
||||
async onload() {
|
||||
this.load_plugin();
|
||||
this.addSettingTab(new FontSettingTab(this.app, this));
|
||||
}
|
||||
async onunload() {
|
||||
applyCss("", "custom_font_base64");
|
||||
applyCss("", "custom_font_general");
|
||||
}
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
await this.loadData()
|
||||
);
|
||||
}
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
};
|
||||
var FontSettingTab = class extends import_obsidian.PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
async display() {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
const infoContainer = containerEl.createDiv();
|
||||
infoContainer.setText(
|
||||
"In Order to set the font, copy your font into fonts directory that you set"
|
||||
);
|
||||
new import_obsidian.Setting(containerEl).setName("Fonts Folder").setDesc("Folder to look for your custom fonts").addText((text) => {
|
||||
text.onChange(async (value) => {
|
||||
this.plugin.settings.font_folder = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.loadSettings();
|
||||
});
|
||||
if (this.plugin.settings.font_folder.trim() == "") {
|
||||
this.plugin.settings.font_folder = `${this.app.vault.configDir}/fonts`;
|
||||
}
|
||||
if (!this.plugin.settings.font_folder.endsWith("/"))
|
||||
this.plugin.settings.font_folder = this.plugin.settings.font_folder + "/";
|
||||
text.setValue(this.plugin.settings.font_folder);
|
||||
});
|
||||
const font_folder_path = this.plugin.settings.font_folder;
|
||||
const options = [{ name: "none", value: "None" }];
|
||||
try {
|
||||
if (!await this.app.vault.adapter.exists(font_folder_path)) {
|
||||
await this.app.vault.adapter.mkdir(font_folder_path);
|
||||
}
|
||||
if (await this.app.vault.adapter.exists(font_folder_path)) {
|
||||
const files = await this.app.vault.adapter.list(
|
||||
font_folder_path
|
||||
);
|
||||
for (const file of files.files) {
|
||||
const file_name = file.replace(font_folder_path, "");
|
||||
if (file_name.startsWith("."))
|
||||
continue;
|
||||
options.push({ name: file_name, value: file_name });
|
||||
}
|
||||
}
|
||||
options.push({ name: "all", value: "Multiple fonts" });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
new import_obsidian.Setting(containerEl).setName("Reload fonts from folder").setDesc(
|
||||
"This button reloades from the folder you specified (it also creates the folder for you)"
|
||||
).addButton((button) => {
|
||||
button.setButtonText("Reload");
|
||||
button.onClick((callback) => {
|
||||
this.plugin.saveSettings();
|
||||
this.plugin.load_plugin();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
this.containerEl.createDiv();
|
||||
new import_obsidian.Setting(containerEl).setName("Font").setDesc(
|
||||
`Choose font (If you can't see your fonts, make sure your fonts are in the folder you specified and hit reload.
|
||||
Also if you choose multiple fonts option, we will load and process all fonts in the folder for you. In that Case, enable Custom CSS Mode)`
|
||||
).addDropdown((dropdown) => {
|
||||
for (const opt of options) {
|
||||
dropdown.addOption(opt.name, opt.value);
|
||||
}
|
||||
dropdown.setValue(this.plugin.settings.font).onChange(async (value) => {
|
||||
this.plugin.settings.font = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.load_plugin();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
if (this.plugin.settings.font.toLowerCase() != "none") {
|
||||
new import_obsidian.Setting(containerEl).setName("Force Style").setDesc(
|
||||
"This option should only be used if you have installed a community theme and normal mode doesn't work"
|
||||
).addToggle((toggle) => {
|
||||
toggle.setValue(this.plugin.settings.force_mode);
|
||||
toggle.onChange(async (value) => {
|
||||
this.plugin.settings.force_mode = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.load_plugin();
|
||||
});
|
||||
});
|
||||
new import_obsidian.Setting(containerEl).setName("Custom CSS Mode").setDesc(
|
||||
"If you want to apply a custom css style rather than default style, choose this."
|
||||
).addToggle((toggle) => {
|
||||
toggle.setValue(this.plugin.settings.custom_css_mode);
|
||||
toggle.onChange(async (value) => {
|
||||
if (this.plugin.settings.custom_css_mode == false) {
|
||||
this.plugin.settings.custom_css = "";
|
||||
}
|
||||
this.plugin.settings.custom_css_mode = value;
|
||||
this.plugin.saveSettings();
|
||||
this.plugin.load_plugin();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
if (this.plugin.settings.custom_css_mode) {
|
||||
new import_obsidian.Setting(containerEl).setName("Custom CSS Style").setDesc("Input your custom css style. Use the font filename without extension (in lowercase) as the font-family name. For example, if your font file is 'MyFont.ttf', use 'myfont' in your CSS.").addTextArea(async (text) => {
|
||||
text.onChange(async (new_value) => {
|
||||
this.plugin.settings.custom_css = new_value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.load_plugin();
|
||||
});
|
||||
text.setDisabled(!this.plugin.settings.custom_css_mode);
|
||||
if (this.plugin.settings.custom_css == "") {
|
||||
let font_family_name = "";
|
||||
try {
|
||||
font_family_name = this.plugin.settings.font.split(".")[0].toLowerCase();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
if (font_family_name == "all") {
|
||||
if (await this.app.vault.adapter.exists(
|
||||
font_folder_path
|
||||
)) {
|
||||
const files = await this.app.vault.adapter.list(
|
||||
font_folder_path
|
||||
);
|
||||
let final_str = "";
|
||||
for (const file of files.files) {
|
||||
const file_name = file.split("/")[2];
|
||||
const font_family = file_name.split(".")[0].toLowerCase();
|
||||
final_str += "\n" + get_custom_css(
|
||||
font_family,
|
||||
"." + font_family
|
||||
);
|
||||
}
|
||||
text.setValue(final_str);
|
||||
}
|
||||
} else {
|
||||
const template = `/* Example CSS for your font: ${font_family_name} */
|
||||
|
||||
/* Apply to all text */
|
||||
:root * {
|
||||
--font-default: '${font_family_name}';
|
||||
--default-font: '${font_family_name}';
|
||||
--font-family-editor: '${font_family_name}';
|
||||
--font-interface-override: '${font_family_name}';
|
||||
--font-text-override: '${font_family_name}';
|
||||
}
|
||||
|
||||
/* Example: Apply to custom CSS class */
|
||||
.custom-font * {
|
||||
font-family: '${font_family_name}' !important;
|
||||
}
|
||||
|
||||
/* Example: Apply to specific elements only */
|
||||
.custom-font h1, .custom-font h2, .custom-font h3 {
|
||||
font-family: '${font_family_name}' !important;
|
||||
}`;
|
||||
text.setValue(template);
|
||||
}
|
||||
} else {
|
||||
text.setValue(this.plugin.settings.custom_css);
|
||||
}
|
||||
text.onChanged();
|
||||
text.inputEl.style.width = "100%";
|
||||
text.inputEl.style.height = "100px";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* nosourcemap */
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "custom-font-loader",
|
||||
"name": "Custom Font Loader",
|
||||
"version": "1.8.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Customize your Obsidian vault with any font you want (+ Support for Android and IOS)",
|
||||
"author": "Amir Pourmand",
|
||||
"authorUrl": "https://amirpourmand.ir",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
+4140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "kanban-bases-view",
|
||||
"name": "Kanban Bases View",
|
||||
"version": "0.10.1",
|
||||
"minAppVersion": "1.10.2",
|
||||
"description": "A kanban-style drag-and-drop custom view for Bases.",
|
||||
"author": "I. Welch Canavan",
|
||||
"authorUrl": "https://welchcanavan.com",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
+617
@@ -0,0 +1,617 @@
|
||||
/* Kanban View Container */
|
||||
.obk-view-container {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Property Selector */
|
||||
.obk-property-selector {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.obk-property-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.obk-property-select {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.obk-property-select:hover {
|
||||
border-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.obk-property-select:focus {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.obk-empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Kanban Board */
|
||||
.obk-board {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.obk-board::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.obk-board::-webkit-scrollbar-track {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.obk-board::-webkit-scrollbar-thumb {
|
||||
background: var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.obk-board::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
/* Swimlane mode: stack lanes vertically. The lane body becomes the
|
||||
horizontal column flex (replacing what .obk-board does in flat mode). */
|
||||
.obk-board--with-swimlanes {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.obk-swimlane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-secondary-alt);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.obk-swimlane-header {
|
||||
padding: 8px 14px;
|
||||
background: var(--background-primary-alt);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-normal);
|
||||
text-transform: capitalize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.obk-swimlane-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--background-modifier-border);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.obk-swimlane-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
padding: 12px;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
/* In swimlane mode, each lane grows tall enough to fit the fullest column,
|
||||
and shorter column bodies stretch to that height so their Sortable drop
|
||||
target spans the whole lane row. */
|
||||
.obk-board--with-swimlanes .obk-column {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.obk-board--with-swimlanes .obk-column-body {
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The outer container caps height in flat mode; release it in swimlane mode
|
||||
so the board grows to fit all lanes and the parent scroll-area scrolls. */
|
||||
.obk-view-container--with-swimlanes {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Collapsed lane: cap the column body at about 30% less than the original
|
||||
420px height and scroll within the
|
||||
column. The lane and column themselves stay flexible — only the card
|
||||
container is capped. */
|
||||
.obk-swimlane--collapsed .obk-column-body {
|
||||
max-height: 294px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.obk-swimlane-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: var(--background-modifier-border);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
--icon-size: var(--icon-xs);
|
||||
transition:
|
||||
background 0.1s ease,
|
||||
color 0.1s ease;
|
||||
}
|
||||
|
||||
.obk-swimlane-toggle:hover {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.obk-swimlane-toggle:focus-visible {
|
||||
outline: 2px solid var(--background-modifier-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.obk-swimlane-drag-handle {
|
||||
cursor: grab;
|
||||
padding: 2px 4px;
|
||||
opacity: 0.5;
|
||||
user-select: none;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.obk-swimlane-drag-handle:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.obk-swimlane-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.obk-swimlane-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.obk-swimlane-ghost {
|
||||
opacity: 0.3;
|
||||
background: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.obk-swimlane-body::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
.obk-swimlane-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.obk-swimlane-body::-webkit-scrollbar-thumb {
|
||||
background: var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.obk-swimlane-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
/* Kanban Column */
|
||||
.obk-column {
|
||||
--obk-column-accent-color: transparent;
|
||||
flex: 0 0 clamp(200px, 60cqw, 280px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
min-height: 200px;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.obk-column-header {
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--obk-column-accent-color, transparent) 15%, var(--background-primary-alt));
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Column color picker button */
|
||||
.obk-column-color-btn {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
background: var(--obk-column-accent-color, transparent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.obk-column-color-btn:hover {
|
||||
border-color: var(--text-muted);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Color picker popover */
|
||||
.obk-column-color-popover {
|
||||
position: fixed;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
width: 164px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.obk-column-color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.obk-column-color-swatch:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.obk-column-color-swatch--active {
|
||||
border-color: var(--text-normal);
|
||||
}
|
||||
|
||||
.obk-column-color-none {
|
||||
background: var(--background-modifier-border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.obk-column-color-none::before,
|
||||
.obk-column-color-none::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 10px;
|
||||
height: 1.5px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 1px;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.obk-column-color-none::before {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.obk-column-color-none::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.obk-column-drag-handle {
|
||||
cursor: grab;
|
||||
padding: 4px;
|
||||
opacity: 0.5;
|
||||
user-select: none;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.obk-column-drag-handle:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.obk-column-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.obk-column-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.obk-column-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--obk-column-accent-color, transparent) 15%, var(--background-modifier-border));
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.obk-column-add-btn,
|
||||
.obk-column-remove-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.1s ease,
|
||||
color 0.1s ease,
|
||||
opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.obk-column-add-btn {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.obk-column:hover .obk-column-add-btn,
|
||||
.obk-column-add-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.obk-column-add-btn:hover,
|
||||
.obk-column-remove-btn:hover {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.obk-column-add-btn .svg-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.obk-column-remove-btn {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.obk-column-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.obk-column-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.obk-column-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.obk-column-body::-webkit-scrollbar-thumb {
|
||||
background: var(--background-modifier-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.obk-column-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
/* Kanban Card */
|
||||
.obk-card {
|
||||
background: var(--background-primary);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--obk-column-accent-color, transparent) 15%, var(--background-modifier-border));
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* targets touch-first devices (tablets, phones) to make the kanban genuinely usable on
|
||||
touch screens — any-pointer: coarse alone would also match hybrid devices (e.g.
|
||||
touchscreen laptops) where the primary pointer is still a mouse */
|
||||
@media (any-pointer: coarse) and (hover: none) {
|
||||
.obk-card {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.obk-card--hover {
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.obk-card--active {
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--interactive-accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.obk-card-cover {
|
||||
display: block;
|
||||
/* Bleed the cover to the card's inner border edge. Card has padding: 12px,
|
||||
so we expand the width by 24px and pull the box out with negative margins.
|
||||
width: 100% alone only fills the content box and leaves a 12px gap on each side. */
|
||||
width: calc(100% + 24px);
|
||||
margin: -12px -12px 8px -12px;
|
||||
/* aspect-ratio is set inline from the imageAspectRatio config */
|
||||
overflow: hidden;
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
}
|
||||
|
||||
.obk-card-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.obk-card-cover--fit-cover img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.obk-card-cover--fit-contain img {
|
||||
object-fit: contain;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.obk-card-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.obk-card-preview {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin-top: 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.obk-card-property {
|
||||
font-size: var(--font-ui-smaller);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.obk-card-property-wrap {
|
||||
white-space: normal;
|
||||
text-overflow: initial;
|
||||
}
|
||||
|
||||
.obk-card-property-wrap .obk-card-property-value {
|
||||
white-space: normal;
|
||||
text-overflow: initial;
|
||||
}
|
||||
|
||||
.obk-card-property-label {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.obk-card-property-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.obk-card-property-value p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.obk-quick-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.obk-quick-add-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.obk-quick-add-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Drag and Drop States */
|
||||
.obk-card-dragging {
|
||||
opacity: 0.5;
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.obk-card-ghost {
|
||||
opacity: 0.3;
|
||||
background: var(--interactive-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.obk-card-chosen {
|
||||
cursor: grabbing;
|
||||
transform: rotate(2deg);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Column Drag and Drop States */
|
||||
.obk-column-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.obk-column-ghost {
|
||||
opacity: 0.3;
|
||||
background: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Sortable placeholder */
|
||||
.obk-sortable-ghost {
|
||||
opacity: 0.4;
|
||||
background: var(--interactive-accent);
|
||||
border: 2px dashed var(--interactive-accent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "obsidian-note-autocreator",
|
||||
"name": "Note Auto Creator",
|
||||
"version": "1.6.0",
|
||||
"minAppVersion": "0.14.2",
|
||||
"description": "Automatically create notes when links are created to them.",
|
||||
"author": "Simon T. Clement",
|
||||
"authorUrl": "https://github.com/SimonTC",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.setting-item-control.setting-warning input {
|
||||
border-color: yellow;
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"types": {
|
||||
"aliases": "aliases",
|
||||
"cssclasses": "multitext",
|
||||
"tags": "tags",
|
||||
"test": "checkbox"
|
||||
}
|
||||
}
|
||||
Vendored
+285
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "74c83563dd55f321",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "0e5588b193ba3ee1",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "57c7753ac9b7e484",
|
||||
"type": "leaf",
|
||||
"pinned": true,
|
||||
"state": {
|
||||
"type": "bases",
|
||||
"state": {
|
||||
"file": "_Tasks Kanban.base",
|
||||
"viewName": "View"
|
||||
},
|
||||
"pinned": true,
|
||||
"icon": "columns",
|
||||
"title": "_Tasks Kanban"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "35f91346f6b29f75",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "WebAssembly back to Azure.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "WebAssembly back to Azure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bc1064252754f309",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Fully Test the Build Calculator.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Fully Test the Build Calculator"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "91e064bf6d50d3fb",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Nice looking map refrence.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Nice looking map refrence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "19394d1656ec83b7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "AI Gen Docs/test-blazor-hydration-timing.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "test-blazor-hydration-timing"
|
||||
}
|
||||
}
|
||||
],
|
||||
"currentTab": 4
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "de5329d9cda83306",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "812749044d0e88b4",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "e1f279c107051222",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "861082434268040e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "70d3dbf9b35be2f6",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Bookmarks"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 200
|
||||
},
|
||||
"right": {
|
||||
"id": "dd7c1dc4bd54d927",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "95fd9fcedb0a127d",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "4822f1c518db08a5",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "_Tasks Kanban.base",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Backlinks for _Tasks Kanban"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0647707c5f4e6cc0",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "_Tasks Kanban.base",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Outgoing links from _Tasks Kanban"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "244210bbd68c3d20",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Tags"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e34266da7cd941b7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "all-properties",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-archive",
|
||||
"title": "All properties"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8bb961a0412cb3dd",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "_Tasks Kanban.base",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline of _Tasks Kanban"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "522176d80c764e53",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "calendar",
|
||||
"state": {},
|
||||
"icon": "calendar-with-checkmark",
|
||||
"title": "Calendar"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Open quick switcher": false,
|
||||
"graph:Open graph view": false,
|
||||
"canvas:Create new canvas": false,
|
||||
"daily-notes:Open today's daily note": false,
|
||||
"templates:Insert template": false,
|
||||
"command-palette:Open command palette": false,
|
||||
"bases:Create new base": false
|
||||
}
|
||||
},
|
||||
"active": "19394d1656ec83b7",
|
||||
"lastOpenFiles": [
|
||||
"_Tasks Kanban.base",
|
||||
"AI Gen Docs/test-blazor-hydration-timing.md",
|
||||
"AI Gen Docs/test-toast-timing-interactions.md",
|
||||
"AI Gen Docs/test-accessibility-keyboard-nav.md",
|
||||
"AI Gen Docs/test-multi-context-entity-comparison.md",
|
||||
"AI Gen Docs/test-mobile-responsive.md",
|
||||
"AI Gen Docs/test-network-resilience.md",
|
||||
"Get AI to Add easy Test Tasks.md",
|
||||
"AI Gen Docs/test-visual-regression.md",
|
||||
"AI Gen Docs/test-storage-persistence.md",
|
||||
"AI Gen Tasks",
|
||||
"Nice looking map refrence.md",
|
||||
"Add some cooldown reference.md",
|
||||
"Language Support.md",
|
||||
"Improve your SEO.md",
|
||||
"Add Co-op objective reference.md",
|
||||
"Auto Build consideration in Calculator.md",
|
||||
"Fully Test the Build Calculator.md",
|
||||
"WebAssembly back to Azure.md",
|
||||
"Change Ctrl + K Hotkey to something that doesn't conflict with Edge or other browsers.md",
|
||||
"Make Tests for the Build Calculator.md",
|
||||
"Make Examples be based on Database Information.md",
|
||||
"Fix Entity Recursion Error - Parent.md",
|
||||
"Create Automated Tests.md",
|
||||
"Basic Build Order Sheet.md",
|
||||
"Add an Ability to Favourite Data.md",
|
||||
"AI Gen Docs/architecture.md",
|
||||
"AI Gen Docs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Project Architecture
|
||||
|
||||
The IGP Fan Reference is structured as a modular .NET solution, separating concerns between data models, business logic, and UI components.
|
||||
|
||||
## Solution Structure
|
||||
|
||||
- **IGP**: The main Blazor WebAssembly project. Contains pages, portals, and application-specific logic.
|
||||
- **Model**: Domain models and data structures. It is a shared library used by the Services and the main app.
|
||||
- `Model.BuildOrders`: Logic for build order sequences.
|
||||
- `Model.Entity`: Unit and building definitions.
|
||||
- `Model.Website`: Models for UI state and navigation.
|
||||
- **Services**: Contains the core business logic.
|
||||
- `Services.Immortal`: Game-specific logic (Economy, Timings, Unit stats).
|
||||
- `Services.Website`: Infrastructure services (Navigation, Storage, Search, Dialogs).
|
||||
- **Components**: A shared library for reusable Razor components used across different pages.
|
||||
- **TestAutomation**: E2E tests using Playwright to ensure feature stability.
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Services are registered in `Program.cs` and injected into components using `@inject`. Most services are registered as `Scoped`, which in Blazor WASM behaves like a singleton for the duration of the user session.
|
||||
|
||||
## State Management
|
||||
|
||||
- **Blazored.LocalStorage**: Used to persist user settings and data (e.g., custom build orders, preferences) across sessions.
|
||||
- **In-Memory Services**: Services like `StorageService` and `ImmortalSelectionService` maintain the active state during the application's runtime.
|
||||
|
||||
## UI & Layout
|
||||
|
||||
The app uses **MudBlazor** for its component library, providing a consistent Material Design look and feel.
|
||||
- `App.razor`: The entry point for the Blazor app, handles routing and global portals.
|
||||
- `PageLayout.razor`: Defines the main layout, including the AppBar, Drawer (for mobile), and main content area.
|
||||
- **Portals**: Components like `EntityDialogPortal` are placed at the root level to allow global access to common dialogs via services.
|
||||
@@ -0,0 +1,27 @@
|
||||
# UI Components and Layout
|
||||
|
||||
The application uses a modular component architecture, leveraging the **MudBlazor** library for UI elements.
|
||||
|
||||
## Reusable Components (`Components` project)
|
||||
|
||||
Shared components are located in the `Components` project, categorized by their function:
|
||||
|
||||
- **Display**: Components for visualizing data (e.g., `EntityIcon`, `StatDisplay`).
|
||||
- **Inputs**: Specialized input fields for game data.
|
||||
- **Layout**: Reusable layout sections like `FooterComponent`.
|
||||
- **Navigation**: Components like `SearchButtonComponent` and `NavigationTracker`.
|
||||
- **Feedback**: UI elements for user interactions like `ConfirmationDialog`.
|
||||
|
||||
## Key Layout Files
|
||||
|
||||
- **App.razor**: Configures the Blazor router and hosts global "Portals". Portals are components that stay active across all pages (e.g., `EntityDialogPortal`, `ToastPortal`).
|
||||
- **PageLayout.razor**: The standard layout for all pages. It includes:
|
||||
- `MudAppBar`: The top navigation bar.
|
||||
- `MudDrawer`: A side navigation menu (primarily for mobile/small screens).
|
||||
- `MudMainContent`: The area where page-specific content is rendered.
|
||||
|
||||
## Design Patterns
|
||||
|
||||
- **Portals**: Used for dialogs and notifications to ensure they are rendered correctly in the DOM hierarchy regardless of which page is active. They listen to events from their corresponding services (e.g., `EntityDialogService`).
|
||||
- **Data-Driven UI**: Navigation links and unit lists are driven by static data classes in the `Model` project (e.g., `WebsiteData.GetPages()`), making it easy to add new sections.
|
||||
- **Dark Mode**: The site is designed primarily for dark mode, managed via `MudThemeProvider`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Development Guide
|
||||
|
||||
This guide provides instructions for building, running, and testing the IGP Fan Reference project.
|
||||
|
||||
## Prerequisites
|
||||
- [.NET 8.0 or 10.0 SDK](https://dotnet.microsoft.com/download)
|
||||
- [PowerShell](https://github.com/PowerShell/PowerShell) (for running scripts)
|
||||
- [Node.js](https://nodejs.org/) (required for Playwright tests)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd IGP-Fan-Reference
|
||||
```
|
||||
|
||||
2. **Restore dependencies**:
|
||||
```bash
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
3. **Run the application**:
|
||||
Navigate to the `IGP` project folder and run:
|
||||
```bash
|
||||
cd IGP
|
||||
dotnet watch run
|
||||
```
|
||||
The application will be available at `https://localhost:5001` (or the port specified in your output).
|
||||
|
||||
## Running Tests
|
||||
|
||||
The project uses **Playwright** for E2E testing, located in the `TestAutomation` project.
|
||||
|
||||
1. **Install Playwright browsers**:
|
||||
```bash
|
||||
dotnet build TestAutomation
|
||||
pwsh TestAutomation/bin/Debug/net8.0/playwright.ps1 install
|
||||
```
|
||||
|
||||
2. **Execute tests**:
|
||||
```bash
|
||||
dotnet test TestAutomation
|
||||
```
|
||||
|
||||
## Project Structure and Maintenance
|
||||
|
||||
- **Adding a new Unit/Building**: Update the `Entity` models and data providers in the `Model` project.
|
||||
- **Modifying UI**: Most common UI elements are in the `Components` library or the `IGP/Pages` directory.
|
||||
- **Business Logic**: Changes to game mechanics or calculations should be made in `Services.Immortal`.
|
||||
|
||||
## Deployment
|
||||
|
||||
The site is configured as a Static Web App. Deployment settings can be found in `staticwebapp.config.json` and the `.github/workflows` directory for automated CI/CD.
|
||||
@@ -0,0 +1,22 @@
|
||||
# IGP Fan Reference - Overview
|
||||
|
||||
The **IGP Fan Reference** is a community-driven fan site for the game *IMMORTAL: Gates of Pyre*. It provides various tools and data references to help players understand game mechanics, optimize build orders, and analyze unit statistics.
|
||||
|
||||
## Project Goals
|
||||
- Provide an accurate and up-to-date database of game entities (units, structures, etc.).
|
||||
- Offer interactive tools like a Build Calculator and Harass Calculator.
|
||||
- Serve as a central hub for community resources and data analysis.
|
||||
|
||||
## Core Tech Stack
|
||||
- **Frontend**: [Blazor WebAssembly](https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor) (.NET 8.0/10.0)
|
||||
- **UI Framework**: [MudBlazor](https://mudblazor.com/)
|
||||
- **State Management**: [Blazored.LocalStorage](https://github.com/Blazored/LocalStorage)
|
||||
- **Testing**: [Playwright](https://playwright.dev/dotnet/) for E2E testing.
|
||||
- **Analytics**: Google Analytics.
|
||||
|
||||
## Key Features
|
||||
- **Database**: Comprehensive list of game entities with detailed stats.
|
||||
- **Build Calculator**: Tool for planning and optimizing build orders.
|
||||
- **Harass Calculator**: Calculate the effectiveness of unit harassment.
|
||||
- **Data Tables**: Tabular views of game data for easy comparison.
|
||||
- **Memory Tester**: A tool for practicing game-related knowledge.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Design Improvement Recommendations
|
||||
|
||||
This document outlines suggested improvements for the IGP Fan Reference project to enhance maintainability, performance, and code quality.
|
||||
|
||||
## 1. Code Quality and Maintenance
|
||||
|
||||
### Centralize and Refactor Configuration
|
||||
- **Hardcoded Strings and IDs**: Many strings (like storage keys, event names, and navigation paths) are hardcoded across the application. These should be moved to a central `Constants` or `Configuration` class.
|
||||
- **Data Initialization**: In `WebsiteData.cs`, the page list is hardcoded with magic numbers for IDs. Consider moving this to a JSON configuration file that can be loaded at runtime.
|
||||
- **Localization**: While the project has a `Localizations.resx` file, some UI strings (like descriptions in `WebsiteData.cs`) are still hardcoded. All user-facing text should be moved to resource files.
|
||||
|
||||
### Standardize Event Handling
|
||||
- The project uses a manual `Action` subscription pattern in many services (e.g., `StorageService`, `TimingService`, `BuildOrderService`).
|
||||
- **Recommendation**: Consider using standard C# events or a more robust Message Bus pattern (like `IMessenger` from CommunityToolkit.Mvvm) to reduce boilerplate and avoid memory leaks from missing unsubscriptions.
|
||||
|
||||
### Fix "TODO"s and Commented-Out Code
|
||||
- Several files (e.g., `PageLayout.razor`, `TimingService.cs`) contain `TODO` comments or large blocks of commented-out code.
|
||||
- **Recommendation**: Clean up these areas. If a feature (like Light Mode) is planned, track it in a backlog instead of leaving dead code in the UI.
|
||||
|
||||
## 2. Architecture and Design
|
||||
|
||||
### Dependency Injection Cleanup
|
||||
- **Duplicated Service Registration**: In `Program.cs`, `HttpClient` is registered twice.
|
||||
- **Service Lifetimes**: Most services are registered as `Scoped`. In Blazor WASM, `Scoped` and `Singleton` behave similarly, but using `Singleton` for stateless services (like `KeyService`) or services that truly span the entire session (like `StorageService`) makes the intent clearer.
|
||||
|
||||
### Decouple Logic from UI
|
||||
- **Portals and Services**: The "Portal" pattern used for dialogs is good, but the services (like `EntityDialogService`) are directly coupled to the UI state.
|
||||
- **Recommendation**: Ensure that game logic services (in `Services.Immortal`) remain pure and don't depend on UI-specific services unless absolutely necessary.
|
||||
|
||||
### Improve Type Safety
|
||||
- Several methods use `int` or `string` for types that could be enums (e.g., `webPageType` in `NavigationService`, `IsPrivate` string in `WebPageModel`).
|
||||
- **Recommendation**: Replace magic strings and numbers with strongly typed Enums to prevent runtime errors.
|
||||
|
||||
## 3. Performance and UX
|
||||
|
||||
### Optimize State Management
|
||||
- `StorageService` calls `NotifyDataChanged()` frequently. If many components subscribe to this, it could lead to unnecessary re-renders.
|
||||
- **Recommendation**: Implement more granular notifications or use a state management library if the application complexity grows.
|
||||
|
||||
### Move CSS to Isolated Files
|
||||
- `App.razor` contains a large `<style>` block.
|
||||
- **Recommendation**: Move these styles to `App.razor.css` (CSS isolation) or a global `app.css` to keep the Razor file focused on structure and logic.
|
||||
|
||||
## 4. Testing and Validation
|
||||
|
||||
### Expand Test Coverage
|
||||
- **Unit Tests**: While Playwright E2E tests are present, there is a lack of unit tests for the complex calculation logic in `BuildOrderService` and `EconomyService`.
|
||||
- **Recommendation**: Add a unit testing project (xUnit or NUnit) to verify the core algorithms independently of the UI.
|
||||
- **CI/CD**: Ensure that tests are automatically run on every Pull Request to catch regressions early.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Application Services
|
||||
|
||||
The project relies heavily on dependency injection to provide specialized services. These are split into game-logic services (`Immortal`) and infrastructure services (`Website`).
|
||||
|
||||
## Game Logic Services (`Services.Immortal`)
|
||||
|
||||
- **IBuildOrderService (`BuildOrderService`)**: Manages build order creation, editing, and calculations.
|
||||
- **IEconomyService (`EconomyService`)**: Handles calculations related to resource generation and expenditure.
|
||||
- **IEconomyComparisonService (`EconomyComparisonService`)**: Compares different economic scenarios.
|
||||
- **IEntityFilterService (`EntityFilterService`)**: Provides logic for filtering game entities based on various criteria.
|
||||
- **IImmortalSelectionService (`ImmortalSelectionService`)**: Manages the current selection of the "Immortal" (faction/hero).
|
||||
- **ITimingService (`TimingService`)**: Calculates timings for unit production and upgrades.
|
||||
- **IMemoryTesterService (`MemoryTesterService`)**: Supports the memory tester mini-game.
|
||||
|
||||
## Infrastructure Services (`Services.Website`)
|
||||
|
||||
- **IStorageService (`StorageService`)**: Wraps LocalStorage to provide persistent state management for the application.
|
||||
- **INavigationService (`NavigationService`)**: Manages application-level navigation and state.
|
||||
- **ISearchService (`SearchService`)**: Powers the global search functionality.
|
||||
- **IEntityDialogService (`EntityDialogService`)**: Controls the display of unit/building detail dialogs via portals.
|
||||
- **IMyDialogService (`MyDialogService`)**: A wrapper around MudBlazor's dialog service for simplified use.
|
||||
- **IToastService (`ToastService`)**: Provides a unified way to show notifications (toasts).
|
||||
- **IDataCollectionService (`DataCollectionService`)**: Handles anonymous usage analytics.
|
||||
- **IPermissionService (`PermissionService`)**: Manages user permissions or experimental feature toggles.
|
||||
|
||||
## Service Registration
|
||||
|
||||
All services are registered in `Program.cs`. Example:
|
||||
```csharp
|
||||
builder.Services.AddScoped<IBuildOrderService, BuildOrderService>();
|
||||
builder.Services.AddScoped<IStorageService, StorageService>();
|
||||
```
|
||||
In Blazor WebAssembly, `AddScoped` effectively acts as a singleton for the session.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Accessibility — Keyboard Navigation
|
||||
|
||||
## Description
|
||||
|
||||
Verify that the Build Calculator and Database pages are navigable and operable using only the keyboard, with proper ARIA roles, focus indicators, and tab order.
|
||||
|
||||
## Rationale
|
||||
|
||||
The Build Calculator's hotkey viewer is inherently keyboard-centric (Q, W, E, R hotkeys add entities to the build order), but the **filter dropdowns, clear button, timing inputs, and option toggles** must also be reachable and activatable via keyboard (`Tab`, `Enter`, `Space`, arrow keys). A screen-reader user should be able to:
|
||||
- Tab through the filter selects and timing inputs in logical order.
|
||||
- Change a select value using arrow keys.
|
||||
- Activate the "Clear Build Order" button with Enter or Space.
|
||||
- See a visible focus ring on every interactive element.
|
||||
|
||||
No existing test verifies any of these accessibility requirements.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **`page.evaluate()`** and **`page.accessibility.snapshot()`** (the Accessibility Tree API) to inspect ARIA attributes, plus **keyboard-based interaction** (Tab, Enter, arrow keys) to test the entire flow without a mouse.
|
||||
|
||||
### Approach
|
||||
|
||||
#### Tab Order Test
|
||||
|
||||
```js
|
||||
// Starting at the top of the Build Calculator page
|
||||
await page.keyboard.press('Tab'); // First focusable element
|
||||
const focused1 = page.locator(':focus');
|
||||
// Assert focused on the Faction select
|
||||
await expect(focused1).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Tab'); // Move to Immortal select
|
||||
// Assert focus moved correctly
|
||||
```
|
||||
|
||||
#### ARIA Snapshot Test
|
||||
|
||||
```js
|
||||
const snapshot = await page.accessibility.snapshot();
|
||||
// Verify the hotkey viewer container has role="application" or role="group"
|
||||
// Verify selects have role="combobox" or role="listbox"
|
||||
// Verify buttons have accessible names
|
||||
```
|
||||
|
||||
#### Screen-Reader Logic Test
|
||||
|
||||
Verify that a filter select's `aria-label` or associated `<label>` text matches the visible label ("Faction", "Immortal"), using `page.locator('select').getAttribute('aria-label')`.
|
||||
|
||||
### What to Assert
|
||||
|
||||
- Every `<select>` has an associated `<label>` or an `aria-label` attribute.
|
||||
- Every button in the hotkey viewer has an `aria-label` derived from the hotkey + entity name (e.g., "Q — Acropolis").
|
||||
- The timeline's interactive items have a `role="listbox"` or `role="grid"`.
|
||||
- Toast notifications have `role="alert"` or `aria-live="polite"` so screen readers announce them.
|
||||
- No element has a `tabindex` greater than 0 (custom tab order) unless intentional.
|
||||
- The "Clear Build Order" button is keyboard-reachable and activatable.
|
||||
|
||||
### Tools
|
||||
|
||||
Playwright uses the **Chromium Accessibility Tree** via `page.accessibility.snapshot()`, which mirrors what a Chromevox screen reader would expose. This is *not* a substitute for testing with a real screen reader, but it catches the most common ARIA violations at low cost.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Blazor WASM Hydration Timing and State Reconciliation
|
||||
|
||||
## Description
|
||||
|
||||
Verify that the Blazor WASM client correctly reconciles state during hydration — the prerendered HTML should match the client-rendered output, and user interactions initiated **before** Blazor finishes loading should either be queued or gracefully ignored, not cause a crash or a stale render.
|
||||
|
||||
## Rationale
|
||||
|
||||
The app uses Blazor WASM with server-side prerendering. During the hydration gap (between `window.onload` and Blazor attaching its event handlers), the page shows static prerendered HTML. If a user (or test) interacts with a `<select>` or button during this gap, Blazor may:
|
||||
|
||||
- Lose the native-DOM change on re-render (the select value reverts).
|
||||
- Miss the event entirely (no handler is connected yet).
|
||||
- Throw a JS interop exception after hydration completes.
|
||||
|
||||
Our existing tests all navigate with `waitUntil: 'load'` and immediately start clicking. The `debug_initial_state.js` script revealed that Blazor components re-render asynchronously after load, destroying and recreating the DOM elements. This gap is a real source of flakiness.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **`page.waitForResponse()`** to wait for a specific WASM resource (`dotnet.js` or `dotnet.wasm`) to confirm the .NET runtime has loaded, plus **`page.evaluate()` with polling** to detect when Blazor's component tree is fully hydrated.
|
||||
|
||||
### Approach
|
||||
|
||||
```js
|
||||
// Wait until the Blazor runtime signals it's ready
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('dotnet.wasm') && response.status() === 200
|
||||
);
|
||||
|
||||
// OR poll for a Blazor-specific DOM signal
|
||||
await page.waitForFunction(() => {
|
||||
// Blazor sets the `_blazorCircuitId` or similar on the root element
|
||||
// Note: adjust the selector to match the app's actual signal
|
||||
return document.querySelector('select')?.classList.contains('blazor-hydrated')
|
||||
|| document.querySelector('.keyContainer > div') !== null;
|
||||
});
|
||||
```
|
||||
|
||||
### Test Cases
|
||||
|
||||
| Scenario | Interaction Timing | Expected Behavior |
|
||||
|---|---|---|
|
||||
| **Early select** | Select faction BEFORE dotnet.wasm loads | Select reverts on hydration, but WASM should apply the correct default filter |
|
||||
| **Early click** | Click "Clear Build Order" before Blazor ready | Click is ignored or queued; no crash |
|
||||
| **Early keyboard** | Press Q/W/E before key handler attached | Keys are ignored; no crash; no entity added |
|
||||
| **Mid-hydration** | Interact during component re-render (DOM destruction phase) | Interaction should be lost but never crash |
|
||||
| **Late interaction** | Wait for `dotnet.wasm` response, THEN interact | Should work correctly (this is the happy path that already passes) |
|
||||
|
||||
### Detecting Hydration Completion
|
||||
|
||||
Since Blazor WASM with prerendering doesn't emit a built-in "I'm done" event, the most reliable signal is:
|
||||
|
||||
```js
|
||||
await page.waitForFunction(() => {
|
||||
// Wait for at least one Blazor-triggered re-render by checking
|
||||
// the keyContainer has child divs with keyboard buttons
|
||||
const container = document.querySelector('.keyContainer');
|
||||
if (!container) return false;
|
||||
const buttons = container.querySelectorAll(':scope > div');
|
||||
return buttons.length === 19 || buttons.length > 10;
|
||||
});
|
||||
```
|
||||
|
||||
### What This Test Catches
|
||||
|
||||
- Race conditions between `page.goto('waitUntil: load')` and Blazor hydration.
|
||||
- DOM elements that are destroyed and re-created during hydration (stale locator references).
|
||||
- Test flakiness caused by Blazor's async initialization.
|
||||
- Missing error boundaries when JS interop calls fail during hydration.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Mobile Responsive Layout
|
||||
|
||||
## Description
|
||||
|
||||
Verify the Build Calculator and Database pages render correctly on mobile and tablet viewports, with no overlapping elements, truncated text, or unusable controls.
|
||||
|
||||
## Rationale
|
||||
|
||||
The `HotkeyViewerComponent.razor` contains a `@media (max-width: 1025px)` rule that scales the key container with `transform: scale(0.85)` and hides the background color. This responsive behavior is entirely CSS-driven and has zero functional test coverage. A CSS refactor could easily break this breakpoint without any existing test noticing.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **`test.use({ viewport })`** with device-specific viewport sizes. Playwright provides built-in device descriptors via `devices['iPhone 13']`, `devices['Pixel 5']`, and `devices['iPad Pro 11']`.
|
||||
|
||||
### Implementation
|
||||
|
||||
```js
|
||||
// Use the built-in iPhone 13 descriptor
|
||||
const iPhone = devices['iPhone 13'];
|
||||
test.use({ ...iPhone });
|
||||
|
||||
test('Build Calculator is usable on mobile', async ({ page }) => {
|
||||
// Navigate, filter, click Q — same actions as desktop test
|
||||
// but the assertions now validate mobile layout works
|
||||
});
|
||||
```
|
||||
|
||||
### What to Assert (Desktop-equivalent on Mobile)
|
||||
|
||||
- All 19 hotkey buttons are present and clickable.
|
||||
- Font size is legible (no single-character truncation on key labels).
|
||||
- Entity names are readable (not clipped by the `scaler`).
|
||||
- The filter `<select>` elements are fully visible and functional (not hidden behind the keyboard view).
|
||||
- The timeline grid scrolls horizontally without breaking the layout.
|
||||
- No horizontal scrollbar appears on the `<body>` (content fits within viewport).
|
||||
|
||||
### Additional Device Targets
|
||||
|
||||
| Descriptor | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `iPhone 13` | 390×844 | Modern mobile portrait |
|
||||
| `Pixel 5` | 393×851 | Android mobile portrait |
|
||||
| `iPad Pro 11` | 834×1194 | Tablet landscape/portrait breakpoints |
|
||||
| Custom `{ width: 1024, height: 768 }` | 1024×768 | Exact `@media (max-width: 1025px)` boundary test |
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Database Entity Consistency Across Pages
|
||||
|
||||
## Description
|
||||
|
||||
Verify that entity data (name, health, damage, faction, hotkey) displayed on the Database entity-detail page matches the same entity's data shown in the Build Calculator's hotkey viewer and timeline.
|
||||
|
||||
## Rationale
|
||||
|
||||
Entity stats are defined in `Model/Entity/Data/EntityData.*.cs` and consumed by both the Database page (full entity detail) and the Build Calculator (hotkey viewer tooltips, timeline entries). If someone edits the source data and changes a name or stat, it must update consistently across all views. No existing test cross-references entity data between the two pages.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **multiple `page` objects within the same `browserContext`** — one navigating the Database page and another navigating the Build Calculator. Playwright fixtures provide a fresh `page` per test by default, but you can open additional pages manually with `await context.newPage()`.
|
||||
|
||||
### Approach
|
||||
|
||||
1. Open the Database single-entity page for "Acropolis" (e.g. `pageA.goto('/database/acropolis')`).
|
||||
2. Extract displayed values: name, faction, health, armor type, hotkey.
|
||||
3. Open the Build Calculator (`pageB.goto('/build-calculator')`).
|
||||
4. Select Q'Rath/Orzum filter.
|
||||
5. Find the Q hotkey button, read its entity name (Acropolis).
|
||||
6. Click the entity name / button, read the EntityClickView name.
|
||||
7. Click the entity in the timeline, read the same detail fields.
|
||||
8. Assert all values match across the three views.
|
||||
|
||||
### What This Catches
|
||||
|
||||
- A typo in `EntityInfoModel.Name` that differs from the label used in the hotkey viewer.
|
||||
- A missing `EntityFactionModel` part that causes an entity to disappear from one view.
|
||||
- A hotkey assignment that renders differently in the Database vs. the Build Calculator.
|
||||
- Entity requirements or costs that display inconsistently between the tooltip and the entity detail page.
|
||||
|
||||
### Example Assertion Flow
|
||||
|
||||
```
|
||||
pageA (Database) pageB (Build Calculator)
|
||||
───────────────── ────────────────────────
|
||||
Name: "Acropolis" → Q button shows "Acropolis"
|
||||
Faction: "Q'Rath" → Only visible with Q'Rath filter
|
||||
Health: 2300 → EntityClickView shows correct health
|
||||
Hotkey: "Q" → Entity appears on Q button (group C)
|
||||
```
|
||||
|
||||
### Future Enhancement
|
||||
|
||||
Parameterize by entity type — run the same cross-page comparison for army units (`Sipari`, `Xacal`), research (`Greaves of Ahqar`), and immortals (`Orzum`, `Mala`) to validate every available entity.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Network Resilience and WASM Loading
|
||||
|
||||
## Description
|
||||
|
||||
Verify the Blazor WASM app handles network failures gracefully — showing loading states, error boundaries, and retry behavior when `dotnet.js`, `.wasm` files, or `.dll` resources fail to load.
|
||||
|
||||
## Rationale
|
||||
|
||||
The app is a Blazor WASM single-page application that downloads the .NET runtime (`dotnet.js`, `dotnet.wasm`) and assemblies on first load. If any of these requests fail (server down, CDN outage, flaky connection), the entire app breaks. No existing test covers this failure mode; they all assume the dev or production server is reachable.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **`page.route()`** (route interception) to selectively block or delay specific resource types:
|
||||
|
||||
| Test Case | Interception |
|
||||
|---|---|
|
||||
| `dotnet.js` fails to load | `page.route('**/dotnet.js', route => route.abort())` |
|
||||
| `.dll` assembly fails | `page.route('**_framework_**', route => route.abort())` |
|
||||
| Slow network | `page.route('**', route => route.continue({ delay: 5000 }))` |
|
||||
| Timeout during WASM init | Combine slow network with reduced navigation timeout |
|
||||
|
||||
### What to Assert
|
||||
|
||||
- An error boundary element (`.blazor-error-boundary`, `#blazor-error-ui`) becomes visible.
|
||||
- A user-facing message like "An unhandled error has occurred" or "Retry" appears.
|
||||
- The `window.dotnet` global is undefined (runtime never loaded).
|
||||
- The page body is **not** a blank white screen (user sees a styled error, not a crash).
|
||||
- Clicking a "Retry" button triggers a page reload.
|
||||
|
||||
### Variations
|
||||
|
||||
- Block only `dotnet.wasm` but allow `dotnet.js` (tests partial-load scenario).
|
||||
- Block `.pdb` symbol files (tests debug vs. release build quality).
|
||||
- Simulate offline mode with `context.setOffline(true)` for full app navigation testing.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Storage Persistence Across Page Reloads
|
||||
|
||||
## Description
|
||||
|
||||
Verify that the Build Calculator correctly persists and restores user settings (selected faction, immortal, build input delays, attack/travel times) across page reloads using `localStorage`.
|
||||
|
||||
## Rationale
|
||||
|
||||
The app relies on `Blazored.LocalStorage` via `IStorageService` to persist settings. No existing test covers the round-trip: set a value, reload the page, and assert the value survived. This is a common source of regressions when storage keys, serialization, or service initialization changes.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **`page.evaluate()`** to directly inspect `localStorage` and **`page.goto()` with a fresh navigation** to verify persistence across a full Blazor WASM lifecycle (including hydration and service re-initialization).
|
||||
|
||||
Unlike existing tests that set values and immediately assert in-memory Blazor state, this test:
|
||||
|
||||
1. Sets a filter value via the UI (select option).
|
||||
2. Reads the raw `localStorage` key to confirm Blazor wrote it.
|
||||
3. Reloads the page (`page.reload()` or a fresh `page.goto`).
|
||||
4. Asserts the UI reflects the restored value **without** user re-selection.
|
||||
|
||||
### Example Assertions
|
||||
|
||||
- `localStorage.getItem('SelectedFaction')` equals `DataType.FACTION_QRath` GUID after selecting Q'Rath.
|
||||
- After reload, the faction `<select>` element shows Q'Rath selected without any `selectOption` call.
|
||||
- After reload, the hotkey viewer shows Q→Acropolis (proving the filter was restored end-to-end).
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Toast Notification Timing and Dismissal
|
||||
|
||||
## Description
|
||||
|
||||
Verify that toast notifications appear with correct error/success styling, auto-dismiss after the expected duration (~1.3s), and multiple toasts stack properly when triggered in rapid succession.
|
||||
|
||||
## Rationale
|
||||
|
||||
The Build Calculator shows toast notifications for errors ("Missing Requirements", "Not Enough Ether") and potentially for success/addition confirmations. The existing toast test (`tests/buildCalculator.spec.js`) only checks whether a toast **appears** within 3 seconds; it does not validate:
|
||||
- The toast's CSS class (`.error` vs `.success`).
|
||||
- The exact display duration.
|
||||
- Stacking behavior when multiple errors are triggered rapidly.
|
||||
- Whether a toast persists if the condition that triggered it is resolved.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **`page.clock`** (Playwright's clock API) to control time without waiting real seconds, plus **`page.waitForSelector`** with exact timeout values and **`page.evaluate`** to inspect DOM classes.
|
||||
|
||||
### Clock-based Timing Test
|
||||
|
||||
```js
|
||||
// Install fake timers before navigation
|
||||
await page.clock.install();
|
||||
await page.clock.fastForward(5000); // Simulate WASM loading delay
|
||||
await calc.goto();
|
||||
|
||||
// Trigger a toast by clicking E (Soul Foundry without Legion Hall)
|
||||
await calc.hotkeys.clickKey('E');
|
||||
await page.clock.fastForward(500); // small delay for render
|
||||
|
||||
// Assert toast appears with error styling
|
||||
const toast = page.locator('.toastsContainer .toastContainer');
|
||||
await expect(toast).toHaveClass(/error/);
|
||||
|
||||
// Fast-forward to just before auto-dismiss
|
||||
await page.clock.fastForward(1200);
|
||||
await expect(toast).toBeVisible(); // still visible at ~1.2s
|
||||
|
||||
// Fast-forward past dismissal
|
||||
await page.clock.fastForward(200);
|
||||
await expect(toast).not.toBeVisible(); // gone by ~1.4s
|
||||
```
|
||||
|
||||
### Stacking Test
|
||||
|
||||
```js
|
||||
// Trigger two toasts in rapid succession
|
||||
calc.filter.selectFaction("Q'Rath");
|
||||
await calc.hotkeys.clickKey('E'); // Missing Requirements
|
||||
|
||||
// Without waiting, trigger another (e.g., click an entity with zero ether)
|
||||
await calc.hotkeys.clickKey('Q');
|
||||
await calc.hotkeys.clickKey('E'); // Not Enough Ether
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
// Assert TWO toasts are visible, stacked
|
||||
const toasts = page.locator('.toastsContainer .toastContainer');
|
||||
await expect(toasts).toHaveCount(2);
|
||||
// Assert the first toast has error styling and correct title
|
||||
await expect(toasts.nth(0).locator('.toastTitle')).toHaveText('Missing Requirements');
|
||||
await expect(toasts.nth(1).locator('.toastTitle')).toHaveText('Not Enough Ether');
|
||||
```
|
||||
|
||||
### What This Test Catches
|
||||
|
||||
- Auto-dismiss timer drift (toast stays too long or disappears too early).
|
||||
- Success vs. error CSS class mismatch (all toasts get the same styling).
|
||||
- Stacking order (new toasts should appear above or below older ones).
|
||||
- Memory leaks (stale toast DOM nodes that never get removed).
|
||||
- Race conditions between `ToastService.AddToast` and `StateHasChanged`.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
---
|
||||
|
||||
# Test: Visual Regression for Build Calculator Layout
|
||||
|
||||
## Description
|
||||
|
||||
Use Playwright's built-in screenshot comparison to detect unintended layout changes in the Build Calculator and Database pages.
|
||||
|
||||
## Rationale
|
||||
|
||||
The Build Calculator renders a complex keyboard-style hotkey viewer with absolute-positioned buttons, a timeline grid, and resin/bank/army sub-panels. A CSS change or component refactor could shift elements, overlap buttons, or misalign the grid. Existing functional tests only check text content and presence of elements — they would not catch a button that renders in the wrong position or overlaps another control.
|
||||
|
||||
## Playwright Feature
|
||||
|
||||
This test uses **`expect(page).toHaveScreenshot()`** with named snapshots. Playwright compares the current render against a baseline screenshot stored in the repo and reports a diff image on failure.
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Catches visual regressions a functional test would miss (position, size, color, overflow).
|
||||
- Snapshots can be taken at specific viewport sizes (desktop/tablet/mobile) using the `viewport` option.
|
||||
- Animations (toast fade-out) can be suppressed via `page.evaluate` to freeze the UI before capture.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Baselines should be generated on first run (`--update-snapshots`) and committed to the repo.
|
||||
- Use `fullPage: true` to capture the entire scrollable page.
|
||||
- Mask or remove time-dependent elements (timing counters, toast notifications) before capture to avoid false positives.
|
||||
- Run in a CI pipeline where the rendering environment (OS, fonts, GPU) is stable.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Task
|
||||
status: Blocked Backlog
|
||||
category:
|
||||
---
|
||||
The procedure co-op mode is definetly very fun. Need some reference of all the possible objective types. Like gather pyre, hold certain points, kill rae golems, kill bases, etc.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Task
|
||||
status: Backlog
|
||||
category: Feature
|
||||
---
|
||||
Let me heart a entity to save it to a different screen for viewing.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: Task
|
||||
status: Blocked Backlog
|
||||
category:
|
||||
---
|
||||
How many spells can I cast? How long does it take to regen mana?
|
||||
|
||||
Use the same logic for a damage reference. How many hits with attack before killing enemy.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Task
|
||||
status: Backlog
|
||||
category:
|
||||
---
|
||||
Make it a Toggle
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: Task
|
||||
status: Blocked Backlog
|
||||
category:
|
||||
---
|
||||
In the past, I recall respectable pro player vounching for the value of the auto build.
|
||||
|
||||
It's probably a good idea to see how much the auto build could generate on an unefficent build order to represent time spent microing
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Task
|
||||
status: Backlog
|
||||
---
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Task
|
||||
status: Backlog
|
||||
category:
|
||||
---
|
||||
Alt + K?
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Task
|
||||
status: Done
|
||||
category: QA
|
||||
---
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Task
|
||||
status: TODO
|
||||
category:
|
||||
---
|
||||
Doesn't care about keys. Show big buttons.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Task
|
||||
status: Done
|
||||
category:
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Task
|
||||
status: Backlog
|
||||
category: QA
|
||||
---
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Agent Work
|
||||
category:
|
||||
---
|
||||
Consider Playwright features we can use to create obvious tests to our test project in the Playwright folder.
|
||||
|
||||
For these considered tests, create markdown files in the "docs\AI Gen Docs" folder.
|
||||
|
||||
They should have the front matter of:
|
||||
type: Task
|
||||
status: AI Gen TODO
|
||||
category: QA
|
||||
isAgentGenerated: "true"
|
||||
|
||||
Describe what the test is in the contents of the markdown file, and what special feature of playwright it's using, if it's using something special in comparison to what we already have.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: Task
|
||||
status: TODO
|
||||
category:
|
||||
---
|
||||
Maybe the default is already fine, given this website actually pops up if I type in "Immortal Gates of Pyre Database" or "IGP Database" but I should at least consider this as a topic.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Also add back in that www.subdomain stuff because google is looking at that url instead.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Task
|
||||
status: Blocked Backlog
|
||||
category:
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Task
|
||||
status: Backlog
|
||||
---
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
type: Task
|
||||
status: AI Agent Work
|
||||
category:
|
||||
---
|
||||
|
||||
Add an Acropolis with the Q key. Verify it was added to the EntityClickView and the Timeline component.
|
||||
|
||||
Press the Clear Build Order button.
|
||||
|
||||
Observe that the Acropolis is no longer displayed in the EntityClickView and the Timeline component.
|
||||
|
||||
|
||||
---
|
||||
|
||||
Duplicate the 'Add entities via hotkeys TAB, Q, W, E with Q\'Rath/Orzum' test. But instead of clicking the hotkeys, use the matching keyboard inputs. i.e. Send the keyboard TAB key, instead of clicking on the UI component representing the TAB key.
|
||||
|
||||
In order to make these keyboard key presses though, the HotKeyViewer component needs to be the Focused Element. So click it first before sending the key inputs. It only needs to be clicked once.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
Past
|
||||
|
||||
|
||||
---
|
||||
|
||||
Make the filter component have Faction: Q'Rath and Immortal: Orzum selected.
|
||||
|
||||
Add every entity to the build calculator by click on the first four keys. TAB, Q, W, E.
|
||||
|
||||
After each action that would cause an entity to be built, check to see if the name on the key matches the name now display in the EntityClickViewComponent.
|
||||
|
||||
Such as Apostle of Binding on the TAB key should show above.
|
||||
|
||||
---
|
||||
|
||||
|
||||
On the build calculator page, click the grey out HotKeys. Ensure a Toast with the related error appears.
|
||||
|
||||
Clicking on Soul Foundry without a Legion Hall should give the "Missing Requirements" error toast.
|
||||
|
||||
---
|
||||
|
||||
On the build calculator page, click the E hotkey for Soul Foundry. Ensure an error toast with "Not Enough Ether" appears.
|
||||
|
||||
Clicking on Soul Foundry with a Legion Hall should give the "Not Enough Ether" error toast.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user