Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3974fcfb91 | |||
| 410e7e23b7 | |||
| 6655cdeee7 | |||
| 1f7a0819fc | |||
| 73f29cea08 |
+13
-12
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
||||
@@ -21,26 +21,27 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blazor-Analytics" Version="3.11.0"/>
|
||||
<PackageReference Include="Markdig" Version="0.30.3"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.14"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.14"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.14"/>
|
||||
<PackageReference Include="MudBlazor" Version="8.5.1"/>
|
||||
<PackageReference Include="Blazor-Analytics" Version="4.0.0" />
|
||||
<PackageReference Include="Markdig" Version="1.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="10.0.5" />
|
||||
<PackageReference Include="MudBlazor" Version="9.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js"/>
|
||||
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Components\Components.csproj"/>
|
||||
<ProjectReference Include="..\Model\Model.csproj"/>
|
||||
<ProjectReference Include="..\Services\Services.csproj"/>
|
||||
<ProjectReference Include="..\Components\Components.csproj" />
|
||||
<ProjectReference Include="..\Model\Model.csproj" />
|
||||
<ProjectReference Include="..\Services\Services.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\generated"/>
|
||||
<Folder Include="Pages\DataTables\Parts\" />
|
||||
<Folder Include="wwwroot\generated" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,8 +11,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components", "..\Components
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Services", "..\Services\Services.csproj", "{621178C8-4E8B-478E-80E5-7478F0E7B67E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestAutomation", "..\TestAutomation\TestAutomation.csproj", "{8B49D038-D013-460D-9C4F-817CAFFEB06F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -35,10 +33,6 @@ Global
|
||||
{621178C8-4E8B-478E-80E5-7478F0E7B67E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{621178C8-4E8B-478E-80E5-7478F0E7B67E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{621178C8-4E8B-478E-80E5-7478F0E7B67E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8B49D038-D013-460D-9C4F-817CAFFEB06F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8B49D038-D013-460D-9C4F-817CAFFEB06F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8B49D038-D013-460D-9C4F-817CAFFEB06F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8B49D038-D013-460D-9C4F-817CAFFEB06F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -19,25 +19,16 @@
|
||||
<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">
|
||||
<div class="gridItem" style="grid-area: timing;">
|
||||
<PanelComponent>
|
||||
<InfoTooltipComponent InfoText="@Locale["Tooltip Filter Info"]">
|
||||
<FilterComponent></FilterComponent>
|
||||
</InfoTooltipComponent>
|
||||
</PanelComponent>
|
||||
|
||||
<ButtonComponent MyButtonType="MyButtonType.Secondary" OnClick="OnResetClicked">Clear Build Order
|
||||
</ButtonComponent>
|
||||
<PanelComponent>
|
||||
@@ -45,11 +36,6 @@
|
||||
<TimingComponent></TimingComponent>
|
||||
</InfoTooltipComponent>
|
||||
</PanelComponent>
|
||||
<PanelComponent>
|
||||
<InfoTooltipComponent InfoText="@Locale["Tooltip Filter Info"]">
|
||||
<FilterComponent></FilterComponent>
|
||||
</InfoTooltipComponent>
|
||||
</PanelComponent>
|
||||
|
||||
<PanelComponent>
|
||||
<InfoTooltipComponent InfoText="@Locale["Tooltip Options Info"]">
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
@implements IDisposable
|
||||
|
||||
<FormLayoutComponent>
|
||||
|
||||
</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>
|
||||
</FormLayoutComponent>
|
||||
|
||||
@code {
|
||||
*/
|
||||
|
||||
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();
|
||||
@@ -82,5 +66,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,6 +290,7 @@ 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
|
||||
@@ -298,7 +299,6 @@ public class BuildOrderService : IBuildOrderService
|
||||
ordersAtTime.Key + (orders.Production() == null
|
||||
? 0
|
||||
: orders.Production().BuildTime))
|
||||
where orders.Harvest() != null
|
||||
select orders).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation;
|
||||
|
||||
public enum DeploymentType
|
||||
{
|
||||
Dev,
|
||||
Local
|
||||
}
|
||||
|
||||
public class BaseTest
|
||||
{
|
||||
protected static readonly TestReport TestReport = new();
|
||||
|
||||
|
||||
protected static Website WebsiteInstance = default!;
|
||||
protected readonly HttpClient HttpClient = new();
|
||||
|
||||
protected static Website Website
|
||||
{
|
||||
get
|
||||
{
|
||||
if (WebsiteInstance == null)
|
||||
{
|
||||
var options = new FirefoxOptions();
|
||||
|
||||
options.AcceptInsecureCertificates = true;
|
||||
|
||||
if (Website.DeploymentType.Equals(DeploymentType.Dev)) options.AddArgument("--headless");
|
||||
options.AddArgument("--ignore-certificate-errors");
|
||||
options.AddArgument("--start-maximized");
|
||||
options.AddArgument("--test-type");
|
||||
options.AddArgument("--allow-running-insecure-content");
|
||||
|
||||
IWebDriver webDriver = new FirefoxDriver(Environment.CurrentDirectory, options);
|
||||
|
||||
WebsiteInstance = new Website(webDriver, TestReport);
|
||||
}
|
||||
|
||||
return WebsiteInstance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace TestAutomation.Enums;
|
||||
|
||||
public enum ScreenType
|
||||
{
|
||||
Desktop,
|
||||
Tablet,
|
||||
Mobile
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using TestAutomation.Shared;
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation.Pages;
|
||||
|
||||
public abstract class BasePage : BaseElement
|
||||
{
|
||||
protected BasePage(Website website) : base(website)
|
||||
{
|
||||
}
|
||||
|
||||
private IEnumerable<string> Links =>
|
||||
Website.FindAllWithTag(Website.Find("content"), "a")
|
||||
.Select(x => x.GetAttribute("href"));
|
||||
|
||||
public abstract string Url { get; set; }
|
||||
|
||||
public IEnumerable<string> GetLinks()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Links;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"Couldn't get links on page {Url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation.Pages;
|
||||
|
||||
public class DatabasePage : BasePage
|
||||
{
|
||||
public DatabasePage(Website website) : base(website)
|
||||
{
|
||||
}
|
||||
|
||||
private IWebElement FilterNameInput => Website.Find("filterName");
|
||||
|
||||
public override string Url { get; set; } = "database";
|
||||
|
||||
|
||||
private ReadOnlyCollection<IWebElement> EntityNames()
|
||||
{
|
||||
return Website.FindAll("entityName");
|
||||
}
|
||||
|
||||
|
||||
private IWebElement EntityName(string entityType, string entityName)
|
||||
{
|
||||
return Website.Find("entityName",
|
||||
$"{entityType.ToLower()}-{entityName.ToLower()}");
|
||||
}
|
||||
|
||||
public DatabasePage FilterName(string name)
|
||||
{
|
||||
Website.EnterInput(FilterNameInput, name);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabasePage GetEntityName(string entityType, string entityName, out string result)
|
||||
{
|
||||
result = EntityName(entityType, entityName).Text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabasePage GetEntityName(int index, out string result)
|
||||
{
|
||||
result = EntityNames()[index].Text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabasePage Goto()
|
||||
{
|
||||
Website.Goto(Url);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation.Pages;
|
||||
|
||||
public class DatabaseSinglePage : BasePage
|
||||
{
|
||||
public DatabaseSinglePage(Website website) : base(website)
|
||||
{
|
||||
}
|
||||
|
||||
private IWebElement EntityName => Website.Find("entityName");
|
||||
private IWebElement EntityHealth => Website.Find("entityHealth");
|
||||
|
||||
private IWebElement InvalidSearch => Website.Find("invalidSearch");
|
||||
private IWebElement ValidSearch => Website.Find("validSearch");
|
||||
|
||||
public override string Url { get; set; } = "database";
|
||||
|
||||
|
||||
public DatabaseSinglePage GetEntityName(out string result)
|
||||
{
|
||||
result = EntityName.Text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabaseSinglePage GetEntityHealth(out string result)
|
||||
{
|
||||
result = EntityHealth.Text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabaseSinglePage GetInvalidSearch(out string result)
|
||||
{
|
||||
result = InvalidSearch.Text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabaseSinglePage GetValidSearch(out string result)
|
||||
{
|
||||
result = ValidSearch.Text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabaseSinglePage Goto(string searchText)
|
||||
{
|
||||
Website.Goto($"{Url}/{searchText}");
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation.Pages;
|
||||
|
||||
public class HarassCalculatorPage : BasePage
|
||||
{
|
||||
public HarassCalculatorPage(Website website) : base(website)
|
||||
{
|
||||
}
|
||||
|
||||
private IWebElement NumberOfWorkersLostToHarass => Website.Find("numberOfWorkersLostToHarass");
|
||||
private IWebElement NumberOfTownHallsExisting => Website.Find("numberOfTownHallsExisting");
|
||||
private IList<IWebElement> OnTownHallTravelTimes => Website.FindChildren("numberOfTownHallTravelTimes", "input");
|
||||
private int TotalAlloyHarassment => Website.FindInt("totalAlloyHarassment");
|
||||
private int WorkerReplacementCost => Website.FindInt("workerReplacementCost");
|
||||
private int DelayedMiningCost => Website.FindInt("delayedMiningCost");
|
||||
private int AverageTravelTime => Website.FindInt("getAverageTravelTime");
|
||||
|
||||
private int ExampleTotalAlloyLoss => Website.FindInt("exampleTotalAlloyLoss");
|
||||
private int ExampleWorkerCost => Website.FindInt("exampleWorkerCost");
|
||||
private int ExampleMiningTimeCost => Website.FindInt("exampleMiningTimeCost");
|
||||
private int ExampleTotalAlloyLossDifference => Website.FindInt("exampleTotalAlloyLossDifference");
|
||||
private int ExampleTotalAlloyLossAccurate => Website.FindInt("exampleTotalAlloyLossAccurate");
|
||||
private int ExampleTotalAlloyLossAccurateDifference => Website.FindInt("exampleTotalAlloyLossAccurateDifference");
|
||||
|
||||
public override string Url { get; set; } = "harass-calculator";
|
||||
|
||||
public HarassCalculatorPage SetWorkersLostToHarass(int number)
|
||||
{
|
||||
Website.EnterInput(NumberOfWorkersLostToHarass, number);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage SetNumberOfTownHallsExisting(int number)
|
||||
{
|
||||
Website.EnterInput(NumberOfTownHallsExisting, number);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage SetTownHallTravelTime(int forTownHall, int number)
|
||||
{
|
||||
Website.EnterInput(OnTownHallTravelTimes[forTownHall], number);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage GetTotalAlloyHarassment(out int result)
|
||||
{
|
||||
result = TotalAlloyHarassment;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public HarassCalculatorPage GetExampleTotalAlloyLoss(out int result)
|
||||
{
|
||||
result = ExampleTotalAlloyLoss;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage GetExampleWorkerCost(out int result)
|
||||
{
|
||||
result = ExampleWorkerCost;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage GetExampleMiningTimeCost(out int result)
|
||||
{
|
||||
result = ExampleMiningTimeCost;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage GetExampleTotalAlloyLossAccurate(out int result)
|
||||
{
|
||||
result = ExampleTotalAlloyLossAccurate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage GetExampleTotalAlloyLossDifference(out int result)
|
||||
{
|
||||
result = ExampleTotalAlloyLossDifference;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage GetExampleTotalAlloyLossAccurateDifference(out int result)
|
||||
{
|
||||
result = ExampleTotalAlloyLossAccurateDifference;
|
||||
return this;
|
||||
}
|
||||
|
||||
protected HarassCalculatorPage NavigateTo()
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
public HarassCalculatorPage Goto()
|
||||
{
|
||||
Website.Goto(Url);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation.Shared;
|
||||
|
||||
public abstract class BaseElement
|
||||
{
|
||||
protected readonly Website Website;
|
||||
|
||||
protected BaseElement(Website website)
|
||||
{
|
||||
Website = website;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation.Shared;
|
||||
|
||||
public class NavigationBar : BaseElement
|
||||
{
|
||||
public NavigationBar(Website website) : base(website)
|
||||
{
|
||||
}
|
||||
|
||||
private IWebElement HomeLink => Website.FindScreenSpecific("homeLink");
|
||||
private IWebElement SearchButton => Website.FindScreenSpecific("searchButton");
|
||||
|
||||
public NavigationBar ClickHomeLink()
|
||||
{
|
||||
Website.Click(HomeLink);
|
||||
return this;
|
||||
}
|
||||
|
||||
public WebsiteSearchDialog ClickSearchButton()
|
||||
{
|
||||
Website.Click(SearchButton);
|
||||
return Website.WebsiteSearchDialog;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation.Shared;
|
||||
|
||||
public class WebsiteSearchDialog : BaseElement
|
||||
{
|
||||
public WebsiteSearchDialog(Website website) : base(website)
|
||||
{
|
||||
}
|
||||
|
||||
public IWebElement SearchBackground => Website.Find("searchBackground");
|
||||
|
||||
public IWebElement SearchInput => Website.Find("searchInput");
|
||||
|
||||
public NavigationBar CloseDialog()
|
||||
{
|
||||
Website.ClickTopLeft();
|
||||
return Website.NavigationBar;
|
||||
}
|
||||
|
||||
public WebsiteSearchDialog Search(string throne)
|
||||
{
|
||||
Website.EnterInput(SearchInput, throne);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DatabaseSinglePage SelectSearchEntity(string throne)
|
||||
{
|
||||
Website.Click(Website.FindButtonWithLabel(throne));
|
||||
return Website.DatabaseSinglePage;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Discord.Net.Webhook" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.14"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0"/>
|
||||
<PackageReference Include="NUnit" Version="3.13.2"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.0"/>
|
||||
<PackageReference Include="NUnit.Analyzers" Version="3.2.0"/>
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.0"/>
|
||||
<PackageReference Include="Selenium.WebDriver" Version="4.1.0"/>
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="101.0.4951.4100"/>
|
||||
<PackageReference Include="Selenium.WebDriver.GeckoDriver" Version="0.31.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Pages\"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,95 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation;
|
||||
|
||||
[TestFixture]
|
||||
public class TestHarassCalculator : BaseTest
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
TestReport.CreateTest();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
TestReport.ThrowErrors();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculatorInput()
|
||||
{
|
||||
var expectedTotalAlloyHarassment = 240;
|
||||
|
||||
Website.HarassCalculatorPage
|
||||
.Goto()
|
||||
.SetWorkersLostToHarass(3)
|
||||
.SetNumberOfTownHallsExisting(2)
|
||||
.SetTownHallTravelTime(0, 30)
|
||||
.GetTotalAlloyHarassment(out var foundTotalAlloyHarassment);
|
||||
|
||||
TestReport.CheckPassed(expectedTotalAlloyHarassment.Equals(foundTotalAlloyHarassment),
|
||||
TestMessage.CreateFailedMessage($"expectTotalAlloyHarassment of {expectedTotalAlloyHarassment} " +
|
||||
"does not equal " +
|
||||
$"foundTotalAlloyHarassment of {foundTotalAlloyHarassment} "));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CalculatedExampleInformation()
|
||||
{
|
||||
var expectedExampleTotalAlloyLoss = 720;
|
||||
var expectedExampleWorkerCost = 300;
|
||||
var expectedExampleMiningTimeCost = 420;
|
||||
var expectedExampleTotalAlloyLossDifference = 300;
|
||||
var expectedExampleTotalAlloyLossAccurate = 450;
|
||||
var expectedExampleTotalAlloyLossAccurateDifference = 270;
|
||||
|
||||
Website.HarassCalculatorPage
|
||||
.Goto()
|
||||
.GetExampleTotalAlloyLoss(out var foundTotalAlloyLoss)
|
||||
.GetExampleWorkerCost(out var foundExampleWorkerCost)
|
||||
.GetExampleMiningTimeCost(out var foundExampleMiningTimeCost)
|
||||
.GetExampleTotalAlloyLossAccurate(out var foundExampleTotalAlloyLossAccurate)
|
||||
.GetExampleTotalAlloyLossDifference(out var foundGetExampleTotalAlloyLossDifference)
|
||||
.GetExampleTotalAlloyLossAccurateDifference(out var foundExampleTotalAlloyLossAccurateDifference);
|
||||
|
||||
TestReport.CheckPassed(expectedExampleTotalAlloyLoss.Equals(foundTotalAlloyLoss),
|
||||
TestMessage.CreateFailedMessage($"expectedExampleTotalAlloyLoss of {expectedExampleTotalAlloyLoss} " +
|
||||
"does not equal " +
|
||||
$"foundTotalAlloyLoss of {foundTotalAlloyLoss} "));
|
||||
|
||||
TestReport.CheckPassed(expectedExampleWorkerCost.Equals(foundExampleWorkerCost),
|
||||
TestMessage.CreateFailedMessage($"expectedExampleWorkerCost of {expectedExampleWorkerCost} " +
|
||||
"does not equal " +
|
||||
$"foundExampleWorkerCost of {foundExampleWorkerCost} "));
|
||||
|
||||
|
||||
TestReport.CheckPassed(expectedExampleMiningTimeCost.Equals(foundExampleMiningTimeCost),
|
||||
TestMessage.CreateFailedMessage($"expectedExampleMiningTimeCost of {expectedExampleMiningTimeCost} " +
|
||||
"does not equal " +
|
||||
$"foundExampleMiningTimeCost of {foundExampleMiningTimeCost} "));
|
||||
|
||||
|
||||
TestReport.CheckPassed(expectedExampleTotalAlloyLossAccurate.Equals(foundExampleTotalAlloyLossAccurate),
|
||||
TestMessage.CreateFailedMessage(
|
||||
$"expectedExampleTotalAlloyLossAccurate of {expectedExampleTotalAlloyLossAccurate} " +
|
||||
"does not equal " +
|
||||
$"foundExampleTotalAlloyLossAccurate of {foundExampleTotalAlloyLossAccurate} "));
|
||||
|
||||
|
||||
TestReport.CheckPassed(expectedExampleTotalAlloyLossDifference.Equals(foundGetExampleTotalAlloyLossDifference),
|
||||
TestMessage.CreateFailedMessage(
|
||||
$"expectedExampleTotalAlloyLossDifference of {expectedExampleTotalAlloyLossDifference} " +
|
||||
"does not equal " +
|
||||
$"foundGetExampleTotalAlloyLossDifference of {foundGetExampleTotalAlloyLossDifference} "));
|
||||
|
||||
|
||||
TestReport.CheckPassed(
|
||||
expectedExampleTotalAlloyLossAccurateDifference.Equals(foundExampleTotalAlloyLossAccurateDifference),
|
||||
TestMessage.CreateFailedMessage(
|
||||
$"expectedExampleTotalAlloyLossAccurateDifference of {expectedExampleTotalAlloyLossAccurateDifference} " +
|
||||
"does not equal " +
|
||||
$"foundExampleTotalAlloyLossAccurateDifference of {foundExampleTotalAlloyLossAccurateDifference} "));
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace TestAutomation;
|
||||
|
||||
[TestFixture]
|
||||
public class TestLinks : BaseTest
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
TestReport.CreateTest();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
TestReport.ThrowErrors();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VerifyPageLinks()
|
||||
{
|
||||
Website.HarassCalculatorPage.Goto();
|
||||
TestReport.VerifyLinks(Website.HarassCalculatorPage).Wait();
|
||||
|
||||
Website.DatabasePage.Goto();
|
||||
TestReport.VerifyLinks(Website.DatabasePage).Wait();
|
||||
|
||||
Website.DatabaseSinglePage.Goto("throne");
|
||||
TestReport.VerifyLinks(Website.DatabaseSinglePage).Wait();
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using TestAutomation.Utils;
|
||||
|
||||
namespace TestAutomation;
|
||||
|
||||
[TestFixture]
|
||||
public class TestSearchFeatures : BaseTest
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
TestReport.CreateTest();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
TestReport.ThrowErrors();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DesktopOpenCloseSearchDialog()
|
||||
{
|
||||
Website
|
||||
.Goto()
|
||||
.NavigationBar
|
||||
.ClickSearchButton()
|
||||
.CloseDialog()
|
||||
.ClickHomeLink();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DesktopSearchForThrone()
|
||||
{
|
||||
Website
|
||||
.Goto()
|
||||
.NavigationBar.ClickSearchButton()
|
||||
.Search("Throne")
|
||||
.SelectSearchEntity("Throne")
|
||||
.GetEntityName(out var name)
|
||||
.GetEntityHealth(out var health);
|
||||
|
||||
TestReport.CheckPassed(name.Equals("Throne"),
|
||||
new TestMessage { Description = "Couldn't find Throne via search." });
|
||||
TestReport.CheckPassed(!health.Trim().Equals(""),
|
||||
new TestMessage { Description = "Throne has no visible health!" });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DesktopFilterForThrone()
|
||||
{
|
||||
Website.DatabasePage
|
||||
.Goto()
|
||||
.FilterName("Throne")
|
||||
.GetEntityName(0, out var name);
|
||||
|
||||
TestReport.CheckPassed(name.Equals("Throne"),
|
||||
new TestMessage { Description = "Couldn't find Throne via filter." });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SeeThroneByDefault()
|
||||
{
|
||||
Website.DatabasePage
|
||||
.Goto()
|
||||
.GetEntityName("army", "throne", out var name);
|
||||
|
||||
TestReport.CheckPassed(name.Equals("Throne"),
|
||||
new TestMessage { Description = "Couldn't find Throne on the page by default." });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DirectLinkNotThroneFailure()
|
||||
{
|
||||
Website.DatabaseSinglePage
|
||||
.Goto("not throne")
|
||||
.GetInvalidSearch(out var invalidSearch)
|
||||
.GetValidSearch(out var validSearch);
|
||||
|
||||
TestReport.CheckPassed(invalidSearch.Equals("not throne"),
|
||||
new TestMessage { Description = "Couldn't find invalid search text on the page." });
|
||||
TestReport.CheckPassed(validSearch.Equals("Throne"),
|
||||
new TestMessage { Description = "Couldn't find valid search text on the page." });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace TestAutomation.Utils;
|
||||
|
||||
public class Test
|
||||
{
|
||||
public string Name { get; set; } = "Name...";
|
||||
public bool Result { get; set; } = true;
|
||||
public IList<TestMessage> Messages { get; set; } = new List<TestMessage>();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace TestAutomation.Utils;
|
||||
|
||||
public class TestMessage
|
||||
{
|
||||
public string Title { get; set; } = "Name...";
|
||||
public string Description { get; set; } = "";
|
||||
public string Color { get; set; } = "FFFFFF";
|
||||
|
||||
public static TestMessage CreateFailedMessage(string description)
|
||||
{
|
||||
return new TestMessage { Title = "Check Failed", Description = description, Color = "FF0000" };
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace TestAutomation.Utils;
|
||||
|
||||
public class TestReport
|
||||
{
|
||||
private List<Test> Tests { get; } = new();
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public Test CreateTest()
|
||||
{
|
||||
Tests.Add(new Test
|
||||
{
|
||||
Name = TestContext.CurrentContext.Test.Name
|
||||
});
|
||||
return Tests.Last();
|
||||
}
|
||||
|
||||
public void ThrowErrors()
|
||||
{
|
||||
if (!Tests.Last().Result)
|
||||
{
|
||||
var messages = string.Join("\n", Tests.Last().Messages.Select(x => x.Description).ToList());
|
||||
|
||||
throw new Exception(
|
||||
$"{Tests.Last().Name} test failed with {Tests.Last().Messages.Count} messages.\n\n{messages}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task VerifyLinks(BasePage page)
|
||||
{
|
||||
foreach (var link in page.GetLinks())
|
||||
try
|
||||
{
|
||||
if (link.StartsWith("mailto")) continue;
|
||||
|
||||
using var client = new HttpClient();
|
||||
var response = await client.GetAsync(link);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
CheckPassed(false,
|
||||
new TestMessage
|
||||
{
|
||||
Color = "red", Title = "Bad Link",
|
||||
Description = $"{link} failed on page {page.Url} with status code {response.StatusCode}"
|
||||
});
|
||||
Console.WriteLine(response.StatusCode.ToString());
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CheckPassed(false,
|
||||
new TestMessage
|
||||
{
|
||||
Color = "red", Title = "Bad Link",
|
||||
Description = $"{link} failed on page {page.Url} with stacktrace {e.StackTrace}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void CheckPassed(bool passed, TestMessage message)
|
||||
{
|
||||
if (passed) return;
|
||||
Tests.Last().Result = false;
|
||||
Tests.Last().Messages.Add(message);
|
||||
}
|
||||
|
||||
public bool DidTestsPass()
|
||||
{
|
||||
foreach (var test in Tests)
|
||||
{
|
||||
if (test.Result) continue;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<object> GetMessages()
|
||||
{
|
||||
if (DidTestsPass())
|
||||
return new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
title = "Passed",
|
||||
color = int.Parse("00FF00", NumberStyles.HexNumber),
|
||||
description = $"All {Tests.Count} tests passed."
|
||||
}
|
||||
};
|
||||
|
||||
var messageList = new List<object>();
|
||||
foreach (var test in Tests)
|
||||
foreach (var message in test.Messages)
|
||||
messageList.Add(
|
||||
new
|
||||
{
|
||||
title = message.Title,
|
||||
color = int.Parse(message.Color, NumberStyles.HexNumber),
|
||||
description = message.Description
|
||||
});
|
||||
|
||||
|
||||
return messageList;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
global using NUnit.Framework;
|
||||
global using OpenQA.Selenium;
|
||||
global using OpenQA.Selenium.Firefox;
|
||||
global using OpenQA.Selenium.Chrome;
|
||||
global using TestAutomation.Pages;
|
||||
global using OpenQA.Selenium.Support.UI;
|
||||
global using OpenQA.Selenium.Support;
|
||||
@@ -1,216 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using OpenQA.Selenium.Interactions;
|
||||
using TestAutomation.Enums;
|
||||
using TestAutomation.Shared;
|
||||
|
||||
namespace TestAutomation.Utils;
|
||||
|
||||
public class Website
|
||||
{
|
||||
public static readonly DeploymentType DeploymentType =
|
||||
Environment.GetEnvironmentVariable("TEST_HOOK")!.Contains("localhost")
|
||||
? DeploymentType.Local
|
||||
: DeploymentType.Dev;
|
||||
|
||||
public static readonly string Url =
|
||||
DeploymentType.Equals(DeploymentType.Dev)
|
||||
? "https://calm-mud-04916b210.1.azurestaticapps.net"
|
||||
: "https://localhost:7234";
|
||||
|
||||
public readonly ScreenType ScreenType = ScreenType.Desktop;
|
||||
|
||||
public Website(IWebDriver webDriver, TestReport testReport)
|
||||
{
|
||||
WebDriver = webDriver;
|
||||
TestReport = testReport;
|
||||
|
||||
// Pages
|
||||
HarassCalculatorPage = new HarassCalculatorPage(this);
|
||||
DatabasePage = new DatabasePage(this);
|
||||
DatabaseSinglePage = new DatabaseSinglePage(this);
|
||||
|
||||
// Navigation
|
||||
NavigationBar = new NavigationBar(this);
|
||||
|
||||
// Dialogs
|
||||
WebsiteSearchDialog = new WebsiteSearchDialog(this);
|
||||
}
|
||||
|
||||
public TestReport TestReport { get; set; }
|
||||
|
||||
public IWebDriver WebDriver { get; }
|
||||
|
||||
public HarassCalculatorPage HarassCalculatorPage { get; }
|
||||
public DatabaseSinglePage DatabaseSinglePage { get; }
|
||||
public DatabasePage DatabasePage { get; }
|
||||
public NavigationBar NavigationBar { get; }
|
||||
public WebsiteSearchDialog WebsiteSearchDialog { get; }
|
||||
|
||||
public IWebElement FindScreenSpecific(string byId)
|
||||
{
|
||||
var screenSpecificId = $"{ScreenType.ToString().ToLower()}-{byId}";
|
||||
|
||||
try
|
||||
{
|
||||
return WebDriver.FindElement(By.Id(screenSpecificId));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"Couldn't find {screenSpecificId}. Element does not exist on current page. " +
|
||||
"\n\nPerhaps an Id is missing.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public IWebElement Find(string byId, string withParentId)
|
||||
{
|
||||
IWebElement parent;
|
||||
|
||||
try
|
||||
{
|
||||
parent = WebDriver.FindElement(By.Id(withParentId));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"Couldn't find parent {withParentId}. Element does not exist on current page. " +
|
||||
"\n\nPerhaps an Id is missing.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return parent.FindElement(By.Id(byId));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"Couldn't find {byId}. Element does not exist on current page. " +
|
||||
"\n\nPerhaps an Id is missing.");
|
||||
}
|
||||
}
|
||||
|
||||
public IWebElement Find(string byId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return WebDriver.FindElement(By.Id(byId));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"Couldn't find {byId}. Element does not exist on current page. " +
|
||||
"\n\nPerhaps an Id is missing.");
|
||||
}
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<IWebElement> FindAll(string byId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return WebDriver.FindElements(By.Id(byId));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"Couldn't find {byId}. Element does not exist on current page. " +
|
||||
"\n\nPerhaps an Id is missing.");
|
||||
}
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<IWebElement> FindAllWithTag(string tag)
|
||||
{
|
||||
return WebDriver.FindElements(By.TagName(tag));
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<IWebElement> FindAllWithTag(IWebElement parent, string tag)
|
||||
{
|
||||
return parent.FindElements(By.TagName(tag));
|
||||
}
|
||||
|
||||
|
||||
public IWebElement FindButtonWithLabel(string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
return WebDriver.FindElement(By.XPath($"//button[@label='{label}']"));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"Couldn't find with label: {label}. Element does not exist on current page. ");
|
||||
}
|
||||
}
|
||||
|
||||
//@FindBy(xpath = "//div[@label='First Name']")
|
||||
|
||||
public IList<IWebElement> FindChildren(string ofId, string tagname)
|
||||
{
|
||||
return WebDriver.FindElements(By.CssSelector($"#{ofId} {tagname}"));
|
||||
}
|
||||
|
||||
public string FindText(string byId)
|
||||
{
|
||||
return WebDriver.FindElement(By.Id(byId)).Text;
|
||||
}
|
||||
|
||||
|
||||
public int FindInt(string byId)
|
||||
{
|
||||
return int.Parse(WebDriver.FindElement(By.Id(byId)).Text);
|
||||
}
|
||||
|
||||
|
||||
public void ClickTopLeft()
|
||||
{
|
||||
new Actions(WebDriver)
|
||||
.MoveByOffset(32, 32)
|
||||
.Click()
|
||||
.Perform();
|
||||
}
|
||||
|
||||
public IWebElement Click(IWebElement element)
|
||||
{
|
||||
try
|
||||
{
|
||||
element.Click();
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new Exception($"Couldn't click on {element.GetDomProperty("id")}. ");
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
public IWebElement EnterInput<T>(IWebElement element, T input)
|
||||
{
|
||||
element.Clear();
|
||||
element.SendKeys(input!.ToString());
|
||||
element.SendKeys(Keys.Enter);
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
public IWebElement EnterInput<T>(string byId, T input)
|
||||
{
|
||||
var element = Find(byId);
|
||||
element.Clear();
|
||||
element.SendKeys(input!.ToString());
|
||||
element.SendKeys(Keys.Enter);
|
||||
return element;
|
||||
}
|
||||
|
||||
public string GetLabel(string byId)
|
||||
{
|
||||
return Find(byId).Text;
|
||||
}
|
||||
|
||||
public Website Goto()
|
||||
{
|
||||
WebDriver.Navigate().GoToUrl($"{Url}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public void Goto(string path)
|
||||
{
|
||||
var url = $"{Url}/{path}";
|
||||
|
||||
WebDriver.Navigate().GoToUrl($"{url}");
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace TestAutomation;
|
||||
|
||||
[SetUpFixture]
|
||||
public class Tests : BaseTest
|
||||
{
|
||||
[OneTimeSetUp]
|
||||
public void Setup()
|
||||
{
|
||||
Website.Goto();
|
||||
Website.WebDriver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
Website.WebDriver.Quit();
|
||||
|
||||
var message = new
|
||||
{
|
||||
content = "Test Report " + DateTime.Now.ToString("dd/MM/yyyy"),
|
||||
embeds = TestReport.GetMessages()
|
||||
};
|
||||
|
||||
var content = new StringContent(JsonConvert.SerializeObject(message), Encoding.UTF8, "application/json");
|
||||
|
||||
if (Environment.GetEnvironmentVariable("TEST_HOOK") == null) return;
|
||||
|
||||
HttpClient.PostAsync(Environment.GetEnvironmentVariable("TEST_HOOK"), content).Wait();
|
||||
}
|
||||
}
|
||||
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
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"types": {
|
||||
"aliases": "aliases",
|
||||
"cssclasses": "multitext",
|
||||
"tags": "tags",
|
||||
"test": "checkbox",
|
||||
"category": "multitext"
|
||||
}
|
||||
}
|
||||
Vendored
+366
@@ -0,0 +1,366 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "74c83563dd55f321",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "8bfa66b8743b2800",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "deb0b2b5f5388749",
|
||||
"type": "leaf",
|
||||
"pinned": true,
|
||||
"state": {
|
||||
"type": "bases",
|
||||
"state": {
|
||||
"file": "_Tasks Kanban.base",
|
||||
"viewName": "View"
|
||||
},
|
||||
"pinned": true,
|
||||
"icon": "columns",
|
||||
"title": "_Tasks Kanban"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2389ff9bfbc7c06e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Untitled.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Untitled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "516344d92af043d3",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Entity Click View Tests.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Entity Click View Tests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e080f009e5533348",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Hotkey Tests.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Hotkey Tests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8c62a38928399310",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Army Calc UI.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Army Calc UI"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "63052cace03ac2f7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Timeline Tests.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Timeline Tests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b468ed9d43376232",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Army Display Split.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Army Display Split"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2c63d9663088f304",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Highest Alloy and Ether Tests.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Highest Alloy and Ether Tests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "461430f8c29407ab",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Helper Tutorial Info Improvements.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Helper Tutorial Info Improvements"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "094e8bbc34e4a833",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Top Borders in Calculator should change based on Selected Faction and Immortal.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Top Borders in Calculator should change based on Selected Faction and Immortal"
|
||||
}
|
||||
}
|
||||
],
|
||||
"currentTab": 9
|
||||
}
|
||||
],
|
||||
"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,
|
||||
"collapsed": true
|
||||
},
|
||||
"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": "094e8bbc34e4a833",
|
||||
"lastOpenFiles": [
|
||||
"_Tasks Kanban.base",
|
||||
"Helper Tutorial Info Improvements.md",
|
||||
"Make a Plan to Fully Test the Calculator.md",
|
||||
"Highest Alloy and Ether Tests.md",
|
||||
"Army Display Split.md",
|
||||
"Timeline Tests.md",
|
||||
"Army Calc UI.md",
|
||||
"Untitled 1.md",
|
||||
"Hotkey Tests.md",
|
||||
"Add a Timeline Editor.md",
|
||||
"Worker Income UI and Tests.md",
|
||||
"Entity Click View Tests.md",
|
||||
"Untitled.md",
|
||||
"More Wait Tests.md",
|
||||
"Build Clear should clear out more stuff.md",
|
||||
"Changing Factions and Immortal should clear out build.md",
|
||||
"Input building delay should have an effect on when a building is built. Tests against 0, 2, 4, 60.md",
|
||||
"Ensure build order gets greyed out past the attack time. Clicking the cancel button will wipe the entire greyed out timeline..md",
|
||||
"Top Borders in Calculator should change based on Selected Faction and Immortal.md",
|
||||
"Pasted image 20260601093510.png",
|
||||
"Pasted image 20260601093506.png",
|
||||
"Pasted image 20260601083333.png",
|
||||
"Pasted image 20260601083206.png",
|
||||
"Pasted image 20260601083147.png",
|
||||
"Pasted image 20260601083127.png",
|
||||
"Pasted image 20260601083113.png",
|
||||
"Pasted image 20260601083101.png",
|
||||
"Pasted image 20260601083046.png",
|
||||
"Pasted image 20260601083030.png",
|
||||
"Jenkins CI.md",
|
||||
"AI Gen Docs/test-network-resilience.md",
|
||||
"Add some cooldown reference.md",
|
||||
"Add Co-op objective reference.md",
|
||||
"Add an Ability to Favourite Data.md",
|
||||
"AI Gen Docs/test-toast-timing-interactions.md",
|
||||
"AI Gen Docs/test-visual-regression.md",
|
||||
"AI Gen Docs",
|
||||
"AI Gen Tasks"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user