diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
new file mode 100644
index 0000000..b45fca9
--- /dev/null
+++ b/.github/workflows/e2e-tests.yml
@@ -0,0 +1,62 @@
+name: E2E Tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ merge_group:
+
+permissions:
+ contents: read
+
+jobs:
+ changes:
+ runs-on: ubuntu-latest
+ outputs:
+ code: ${{ steps.filter.outputs.code }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Detect relevant file changes
+ uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
+ id: filter
+ with:
+ filters: |
+ code:
+ - '**/*.js'
+ - '**/*.ts'
+ - '**/*.css'
+ - 'tests/**'
+ - 'package*.json'
+
+ test-e2e:
+ needs: changes
+ if: needs.changes.outputs.code == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: ./.github/actions/setup
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps chromium
+
+ - name: Build generated files
+ run: npm run build
+
+ - name: Run E2E tests
+ run: npm run test:e2e
+
+ e2e-test-status:
+ needs: [changes, test-e2e]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Report status
+ run: |
+ if [[ "${{ needs.test-e2e.result }}" == "failure" ]]; then
+ echo "E2E tests failed"
+ exit 1
+ fi
+ echo "E2E tests passed or were skipped (no relevant files changed)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/unit-tests.yml
similarity index 71%
rename from .github/workflows/ci.yml
rename to .github/workflows/unit-tests.yml
index 7f4d83a..71fdb00 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/unit-tests.yml
@@ -1,4 +1,4 @@
-name: CI
+name: Unit Tests
on:
schedule:
@@ -61,23 +61,6 @@ jobs:
verbose: true
fail_ci_if_error: false
- test-e2e:
- needs: changes
- if: needs.changes.outputs.code == 'true'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: ./.github/actions/setup
-
- - name: Install Playwright browsers
- run: npx playwright install --with-deps chromium
-
- - name: Build generated files
- run: npm run build
-
- - name: Run E2E tests
- run: npm run test:e2e
-
unit-test-status:
needs: [changes, test]
if: always()
@@ -90,16 +73,3 @@ jobs:
exit 1
fi
echo "Unit tests passed or were skipped (no relevant files changed)"
-
- e2e-test-status:
- needs: [changes, test-e2e]
- if: always()
- runs-on: ubuntu-latest
- steps:
- - name: Report status
- run: |
- if [[ "${{ needs.test-e2e.result }}" == "failure" ]]; then
- echo "E2E tests failed"
- exit 1
- fi
- echo "E2E tests passed or were skipped (no relevant files changed)"
diff --git a/README.md b/README.md
index f777648..ee39775 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[](https://github.com/nitrocode/token-deathclock/actions/workflows/unit-tests.yml)
[](https://github.com/nitrocode/token-deathclock/actions/workflows/e2e-tests.yml)
[](https://github.com/nitrocode/token-deathclock/actions/workflows/deploy.yml)
-[](https://codecov.io/gh/nitrocode/token-deathclock)
+[](https://codecov.io/gh/nitrocode/token-deathclock)
> 🌐 **Live site:** [nitrocode.github.io/token-deathclock](https://nitrocode.github.io/token-deathclock/)
@@ -66,7 +66,7 @@ Unit-test coverage is tracked by [Codecov](https://codecov.io/gh/nitrocode/token
| Unit test coverage | Coverage breakdown |
|---|---|
-| [](https://codecov.io/gh/nitrocode/token-deathclock) | [](https://codecov.io/gh/nitrocode/token-deathclock) |
+| [](https://codecov.io/gh/nitrocode/token-deathclock) | [](https://codecov.io/gh/nitrocode/token-deathclock) |
E2E tests run in CI via Playwright (Chromium). Their pass/fail status is shown by the **E2E Tests** badge above.
diff --git a/package.json b/package.json
index 1b28d31..cbd785b 100644
--- a/package.json
+++ b/package.json
@@ -36,13 +36,26 @@
"/tests/e2e/"
],
"collectCoverageFrom": [
- "death-clock-core.js"
+ "death-clock-core.js",
+ "script.js"
],
"coverageThreshold": {
"global": {
"lines": 80,
"functions": 80,
"branches": 70
+ },
+ "./death-clock-core.js": {
+ "statements": 80,
+ "functions": 80,
+ "branches": 70,
+ "lines": 80
+ },
+ "./script.js": {
+ "statements": 60,
+ "functions": 60,
+ "branches": 50,
+ "lines": 90
}
}
}
diff --git a/tests/script.test.js b/tests/script.test.js
index 5d3d44b..bbde95a 100644
--- a/tests/script.test.js
+++ b/tests/script.test.js
@@ -3,22 +3,24 @@
*
* Integration / smoke tests for script.js DOM logic.
*
- * script.js is a browser IIFE — it is loaded by evaluating the file source after
- * setting `window.DeathClockCore` and mocking browser-only globals (Chart,
- * requestAnimationFrame). Each test re-renders from a fresh DOM + fresh IIFE run.
+ * script.js is a browser IIFE — it is loaded via require() after setting
+ * `window.DeathClockCore` and mocking browser-only globals (Chart,
+ * requestAnimationFrame). Each test re-renders from a fresh DOM + fresh module
+ * load (via jest.resetModules()) to guarantee isolation.
+ *
+ * Using require() (instead of eval()) lets Istanbul track statement coverage
+ * for script.js, giving Codecov visibility into the DOM layer.
*/
'use strict';
-const path = require('path');
-const fs = require('fs');
-
const coreModule = require('../death-clock-core');
-const scriptCode = fs.readFileSync(path.join(__dirname, '../script.js'), 'utf8');
-// Minimal HTML that mirrors the elements script.js interacts with.
+// Comprehensive HTML that mirrors all elements script.js interacts with,
+// enabling every init*() function to run its full initialization path.
const MIN_HTML = `
+
@@ -37,6 +39,167 @@ const MIN_HTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
function makeChartMock() {
@@ -83,13 +246,11 @@ function loadScript() {
};
global.Chart = makeChartMock();
global.requestAnimationFrame = jest.fn();
- // eval() is intentional here: script.js is a browser IIFE that references `window`
- // and `document` from the current execution context. In the jsdom test environment
- // `eval` runs in the jsdom global scope, making those globals available. Alternatives
- // such as vm.runInThisContext run in a plain V8 context where `window` is undefined.
- // The evaluated code is a local static file (no user input), so there is no XSS risk.
- // eslint-disable-next-line no-eval
- eval(scriptCode);
+ // Use require() so Istanbul can instrument script.js and report coverage to Codecov.
+ // In Jest's jsdom environment global.window is the jsdom window, so window-dependent
+ // code in the IIFE resolves correctly via Node's global object.
+ jest.resetModules();
+ require('../script.js');
// In some jsdom versions, document.readyState is 'loading' when innerHTML is set,
// which causes script.js to defer init() via DOMContentLoaded instead of running it
// synchronously. Fire the event to ensure init() always runs before we inspect state.
@@ -257,8 +418,8 @@ describe('initChart (DOM)', () => {
document.body.innerHTML = MIN_HTML;
delete global.Chart;
global.requestAnimationFrame = jest.fn();
- // eslint-disable-next-line no-eval
- expect(() => eval(scriptCode)).not.toThrow();
+ jest.resetModules();
+ expect(() => require('../script.js')).not.toThrow();
});
test('instantiates Chart when Chart.js is present', () => {
@@ -266,8 +427,8 @@ describe('initChart (DOM)', () => {
global.Chart = ChartMock;
document.body.innerHTML = MIN_HTML;
global.requestAnimationFrame = jest.fn();
- // eslint-disable-next-line no-eval
- eval(scriptCode);
+ jest.resetModules();
+ require('../script.js');
expect(ChartMock).toHaveBeenCalledTimes(1);
});
});
@@ -377,8 +538,8 @@ describe('renderChangelog (DOM)', () => {
}],
};
global.requestAnimationFrame = jest.fn();
- // eslint-disable-next-line no-eval
- eval(scriptCode);
+ jest.resetModules();
+ require('../script.js');
const html = document.getElementById('changelogList').innerHTML;
// Markdown link syntax must not appear verbatim
expect(html).not.toContain('[#99]');
@@ -403,8 +564,8 @@ describe('renderChangelog (DOM)', () => {
}],
};
global.requestAnimationFrame = jest.fn();
- // eslint-disable-next-line no-eval
- eval(scriptCode);
+ jest.resetModules();
+ require('../script.js');
const html = document.getElementById('changelogList').innerHTML;
expect(html).not.toContain('href="javascript:');
// The raw markdown text should be escaped as literal text, not injected as a link
@@ -455,8 +616,8 @@ describe('renderChangelog (DOM)', () => {
}],
};
global.requestAnimationFrame = jest.fn();
- // eslint-disable-next-line no-eval
- eval(scriptCode);
+ jest.resetModules();
+ require('../script.js');
const btn = document.getElementById('changelogShowMore');
expect(btn).toBeNull();
});
@@ -465,8 +626,8 @@ describe('renderChangelog (DOM)', () => {
document.body.innerHTML = MIN_HTML;
delete global.ChangelogData;
global.requestAnimationFrame = jest.fn();
- // eslint-disable-next-line no-eval
- expect(() => eval(scriptCode)).not.toThrow();
+ jest.resetModules();
+ expect(() => require('../script.js')).not.toThrow();
const list = document.getElementById('changelogList');
expect(list.innerHTML).toContain('No changelog entries found');
// Restore for subsequent tests
@@ -476,3 +637,673 @@ describe('renderChangelog (DOM)', () => {
};
});
});
+
+// ============================================================
+// initTicker (DOM)
+// ============================================================
+describe('initTicker (DOM)', () => {
+ test('ai-ticker-text is populated after init', () => {
+ const el = document.getElementById('ai-ticker-text');
+ expect(el).not.toBeNull();
+ expect(el.textContent.length).toBeGreaterThan(0);
+ });
+
+ test('clicking the ticker text expands the ticker', () => {
+ const textEl = document.getElementById('ai-ticker-text');
+ const expanded = document.getElementById('ai-ticker-expanded');
+ expect(expanded).not.toBeNull();
+ textEl.click();
+ expect(expanded.hidden).toBe(false);
+ });
+
+ test('clicking the ticker toggle button expands the ticker', () => {
+ const toggleBtn = document.getElementById('ai-ticker-toggle');
+ const expanded = document.getElementById('ai-ticker-expanded');
+ toggleBtn.click();
+ expect(expanded.hidden).toBe(false);
+ });
+
+ test('clicking the ticker toggle twice collapses the ticker', () => {
+ const toggleBtn = document.getElementById('ai-ticker-toggle');
+ const expanded = document.getElementById('ai-ticker-expanded');
+ toggleBtn.click(); // expand
+ toggleBtn.click(); // collapse
+ expect(expanded.hidden).toBe(true);
+ });
+
+ test('expanded ticker shows math breakdown', () => {
+ const toggleBtn = document.getElementById('ai-ticker-toggle');
+ toggleBtn.click(); // expand
+ const math = document.getElementById('ai-ticker-math');
+ expect(math).not.toBeNull();
+ expect(math.textContent.length).toBeGreaterThan(0);
+ });
+
+ test('resume button collapses the ticker', () => {
+ const toggleBtn = document.getElementById('ai-ticker-toggle');
+ const resumeBtn = document.getElementById('ai-ticker-resume');
+ const expanded = document.getElementById('ai-ticker-expanded');
+ toggleBtn.click(); // expand
+ resumeBtn.click(); // collapse via resume
+ expect(expanded.hidden).toBe(true);
+ });
+
+ test('share button does not throw', () => {
+ // Mock window.open to prevent errors
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const shareBtn = document.getElementById('ai-ticker-share');
+ expect(() => shareBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+});
+
+// ============================================================
+// initEquivalences (DOM)
+// ============================================================
+describe('initEquivalences (DOM)', () => {
+ test('equivIcon and equivText are populated after init', () => {
+ const iconEl = document.getElementById('equivIcon');
+ const textEl = document.getElementById('equivText');
+ expect(iconEl).not.toBeNull();
+ expect(textEl).not.toBeNull();
+ expect(textEl.textContent.length).toBeGreaterThan(0);
+ });
+
+ test('snarkToggle button switches to snarky mode', () => {
+ const toggle = document.getElementById('snarkToggle');
+ toggle.click();
+ expect(toggle.textContent).toContain('Hopeful');
+ });
+
+ test('snarkToggle button switches back to hopeful mode', () => {
+ const toggle = document.getElementById('snarkToggle');
+ toggle.click(); // snarky
+ toggle.click(); // hopeful
+ expect(toggle.textContent).toContain('Snarky');
+ });
+});
+
+// ============================================================
+// initTabs (DOM)
+// ============================================================
+describe('initTabs (DOM)', () => {
+ test('tab buttons are present', () => {
+ const btns = document.querySelectorAll('.tab-btn[data-tab]');
+ expect(btns.length).toBeGreaterThan(0);
+ });
+
+ test('clicking a tab button activates it', () => {
+ const btns = document.querySelectorAll('.tab-btn[data-tab]');
+ const secondTab = btns[1];
+ secondTab.click();
+ expect(secondTab.classList.contains('tab-btn--active')).toBe(true);
+ expect(secondTab.getAttribute('aria-selected')).toBe('true');
+ });
+
+ test('clicking a tab hides the other panel', () => {
+ const btns = document.querySelectorAll('.tab-btn[data-tab]');
+ btns[1].click();
+ const homePanel = document.getElementById('tab-home');
+ expect(homePanel.hidden).toBe(true);
+ });
+});
+
+// ============================================================
+// Receipt modal (DOM)
+// ============================================================
+describe('Receipt modal (DOM)', () => {
+ test('getReceiptBtn opens the receipt modal', () => {
+ const btn = document.getElementById('getReceiptBtn');
+ const modal = document.getElementById('receipt-modal');
+ btn.click();
+ expect(modal.hidden).toBe(false);
+ });
+
+ test('receipt body is populated after opening', () => {
+ const btn = document.getElementById('getReceiptBtn');
+ const body = document.getElementById('receipt-body');
+ btn.click();
+ expect(body.textContent.length).toBeGreaterThan(0);
+ });
+
+ test('close button hides the receipt modal', () => {
+ const openBtn = document.getElementById('getReceiptBtn');
+ const closeBtn = document.getElementById('receiptCloseBtn');
+ const modal = document.getElementById('receipt-modal');
+ openBtn.click();
+ closeBtn.click();
+ expect(modal.hidden).toBe(true);
+ });
+
+ test('share button triggers share logic without throwing', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const openBtn = document.getElementById('getReceiptBtn');
+ const shareBtn = document.getElementById('receiptShareBtn');
+ openBtn.click();
+ expect(() => shareBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+});
+
+// ============================================================
+// Personal Footprint Calculator (DOM)
+// ============================================================
+describe('Calculator (DOM)', () => {
+ test('calcToggleBtn opens the calculator', () => {
+ const toggleBtn = document.getElementById('calcToggleBtn');
+ const content = document.getElementById('calc-content');
+ toggleBtn.click();
+ expect(content.hidden).toBe(false);
+ });
+
+ test('calculator produces results when opened', () => {
+ const toggleBtn = document.getElementById('calcToggleBtn');
+ const results = document.getElementById('calc-results');
+ toggleBtn.click();
+ expect(results.innerHTML.length).toBeGreaterThan(0);
+ });
+
+ test('calculator results contain "WANTED" poster', () => {
+ const toggleBtn = document.getElementById('calcToggleBtn');
+ toggleBtn.click();
+ const results = document.getElementById('calc-results');
+ expect(results.innerHTML).toContain('WANTED');
+ });
+
+ test('calculator input changes update results', () => {
+ const toggleBtn = document.getElementById('calcToggleBtn');
+ toggleBtn.click();
+ const promptsEl = document.getElementById('calcPrompts');
+ promptsEl.value = '50';
+ promptsEl.dispatchEvent(new Event('input'));
+ const results = document.getElementById('calc-results');
+ expect(results.innerHTML).toContain('WANTED');
+ });
+
+ test('calcToggleBtn closes the calculator when clicked again', () => {
+ const toggleBtn = document.getElementById('calcToggleBtn');
+ const content = document.getElementById('calc-content');
+ toggleBtn.click(); // open
+ toggleBtn.click(); // close
+ expect(content.hidden).toBe(true);
+ });
+
+ test('share button does not throw', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const toggleBtn = document.getElementById('calcToggleBtn');
+ const shareBtn = document.getElementById('calcShareBtn');
+ toggleBtn.click();
+ expect(() => shareBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+});
+
+// ============================================================
+// Badges / Achievements (DOM)
+// ============================================================
+describe('Badges (DOM)', () => {
+ test('badges grid is populated on init', () => {
+ const grid = document.getElementById('badges-grid');
+ expect(grid).not.toBeNull();
+ expect(grid.children.length).toBeGreaterThan(0);
+ });
+
+ test('all badge items are initially locked', () => {
+ const grid = document.getElementById('badges-grid');
+ const locked = grid.querySelectorAll('.badge-item.locked');
+ expect(locked.length).toBeGreaterThan(0);
+ });
+});
+
+// ============================================================
+// Share panel (DOM)
+// ============================================================
+describe('Share panel (DOM)', () => {
+ test('share-doom-panel exists in DOM', () => {
+ expect(document.getElementById('share-doom-panel')).not.toBeNull();
+ });
+
+ test('clicking shareDoomBtn toggles the options panel', () => {
+ const mainBtn = document.getElementById('shareDoomBtn');
+ const options = document.getElementById('share-doom-options');
+ mainBtn.click();
+ expect(options.hidden).toBe(false);
+ });
+
+ test('clicking Twitter share does not throw', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const twitterBtn = document.getElementById('shareTwitterBtn');
+ expect(() => twitterBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+
+ test('clicking Reddit share does not throw', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const redditBtn = document.getElementById('shareRedditBtn');
+ expect(() => redditBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+
+ test('clicking LinkedIn share does not throw', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const linkedinBtn = document.getElementById('shareLinkedInBtn');
+ expect(() => linkedinBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+
+ test('clicking WhatsApp share does not throw', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const whatsappBtn = document.getElementById('shareWhatsAppBtn');
+ expect(() => whatsappBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+
+ test('clicking Bluesky share does not throw', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ const blueskyBtn = document.getElementById('shareBlueskyBtn');
+ expect(() => blueskyBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+
+ test('close button hides the options panel', () => {
+ const mainBtn = document.getElementById('shareDoomBtn');
+ const closeBtn = document.getElementById('shareCloseBtn');
+ const options = document.getElementById('share-doom-options');
+ mainBtn.click(); // open
+ closeBtn.click(); // close
+ expect(options.hidden).toBe(true);
+ });
+});
+
+// ============================================================
+// Social Ripple (DOM)
+// ============================================================
+describe('Social Ripple (DOM)', () => {
+ test('presenceCount is populated after init', () => {
+ const el = document.getElementById('presenceCount');
+ expect(el).not.toBeNull();
+ expect(el.textContent.length).toBeGreaterThan(0);
+ });
+});
+
+// ============================================================
+// Witness History / Event Log (DOM)
+// ============================================================
+describe('Witness History (DOM)', () => {
+ test('event-log element exists in DOM', () => {
+ expect(document.getElementById('event-log')).not.toBeNull();
+ });
+});
+
+// ============================================================
+// Footer Stats (DOM)
+// ============================================================
+describe('Footer Stats (DOM)', () => {
+ test('stiCharges is updated after init', () => {
+ const el = document.getElementById('stiCharges');
+ expect(el).not.toBeNull();
+ expect(el.textContent.length).toBeGreaterThan(0);
+ });
+
+ test('stiTrees is updated after init', () => {
+ const el = document.getElementById('stiTrees');
+ expect(el.textContent.length).toBeGreaterThan(0);
+ });
+});
+
+// ============================================================
+// Scary Features: Apology Generator (DOM)
+// ============================================================
+describe('Apology Generator (DOM)', () => {
+ test('apologyQuote is populated after init', () => {
+ const el = document.getElementById('apologyQuote');
+ expect(el).not.toBeNull();
+ expect(el.textContent.length).toBeGreaterThan(0);
+ });
+
+ test('apologyNextBtn updates the quote', () => {
+ const nextBtn = document.getElementById('apologyNextBtn');
+ const quote = document.getElementById('apologyQuote');
+ const original = quote.textContent;
+ nextBtn.click();
+ // The quote text may or may not change (random rotation), but no error
+ expect(quote.textContent.length).toBeGreaterThan(0);
+ });
+});
+
+// ============================================================
+// Scary Features: Prompt Hall of Shame (DOM)
+// ============================================================
+describe('Prompt Hall of Shame (DOM)', () => {
+ test('shame feed is seeded with entries on init', () => {
+ const feed = document.getElementById('shameFeed');
+ expect(feed).not.toBeNull();
+ expect(feed.children.length).toBeGreaterThan(0);
+ });
+
+ test('submitting a prompt adds it to the feed', () => {
+ const input = document.getElementById('shameInput');
+ const submitBtn = document.getElementById('shameSubmitBtn');
+ const feed = document.getElementById('shameFeed');
+ const before = feed.children.length;
+ input.value = 'Test prompt';
+ submitBtn.click();
+ expect(feed.children.length).toBeGreaterThan(0);
+ // Input should be cleared after submission
+ expect(input.value).toBe('');
+ });
+
+ test('pressing Enter in shame input submits the prompt', () => {
+ const input = document.getElementById('shameInput');
+ const feed = document.getElementById('shameFeed');
+ input.value = 'Enter key test';
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(input.value).toBe('');
+ });
+});
+
+// ============================================================
+// Scary Features: Emergency Broadcast (DOM)
+// ============================================================
+describe('Emergency Broadcast (DOM)', () => {
+ test('ebDismissBtn hides the overlay when clicked', () => {
+ const overlay = document.getElementById('emergency-broadcast');
+ const dismissBtn = document.getElementById('ebDismissBtn');
+ overlay.hidden = false;
+ dismissBtn.click();
+ expect(overlay.hidden).toBe(true);
+ });
+});
+
+// ============================================================
+// Life Blocks — drill-down navigation (DOM)
+// ============================================================
+describe('Life Blocks drill-down (DOM)', () => {
+ test('lb-container has blocks after init', () => {
+ const container = document.getElementById('lb-container');
+ expect(container).not.toBeNull();
+ expect(container.innerHTML.length).toBeGreaterThan(0);
+ });
+
+ test('lb-stack-seconds has rendered blocks', () => {
+ const el = document.getElementById('lb-stack-seconds');
+ expect(el).not.toBeNull();
+ expect(el.innerHTML.length).toBeGreaterThan(0);
+ });
+
+ test('clicking a future lb-stack block navigates drill-down without error', () => {
+ const container = document.getElementById('lb-container');
+ const futureBlock = container.querySelector('.lb-block:not(.lb-dead)');
+ if (futureBlock) {
+ expect(() => futureBlock.click()).not.toThrow();
+ }
+ });
+});
+
+// ============================================================
+// Accelerate the Doom (DOM)
+// ============================================================
+describe('Accelerate the Doom (DOM)', () => {
+ test('accelToggleBtn opens the accelerator panel', () => {
+ const toggleBtn = document.getElementById('accelToggleBtn');
+ const content = document.getElementById('accel-content');
+ toggleBtn.click();
+ expect(content.hidden).toBe(false);
+ });
+
+ test('bigRedButton is tappable without error', () => {
+ // Open the panel first
+ document.getElementById('accelToggleBtn').click();
+ const btn = document.getElementById('bigRedButton');
+ expect(() => btn.click()).not.toThrow();
+ });
+
+ test('comboDisplay is updated after tapping the big red button', () => {
+ document.getElementById('accelToggleBtn').click();
+ document.getElementById('bigRedButton').click();
+ const combo = document.getElementById('comboDisplay');
+ expect(combo).not.toBeNull();
+ });
+
+ test('upgrade shop is rendered when accelerator panel opens', () => {
+ document.getElementById('accelToggleBtn').click();
+ const shop = document.getElementById('upgradeShop');
+ expect(shop.innerHTML.length).toBeGreaterThan(0);
+ });
+
+ test('challenge row is rendered', () => {
+ document.getElementById('accelToggleBtn').click();
+ const row = document.getElementById('challengeRow');
+ expect(row).not.toBeNull();
+ });
+});
+
+// ============================================================
+// renderSectionAnchors (DOM)
+// ============================================================
+describe('renderSectionAnchors (DOM)', () => {
+ test('section with id gets an anchor link appended', () => {
+ const section = document.getElementById('test-section');
+ const anchor = section.querySelector('.section-anchor');
+ expect(anchor).not.toBeNull();
+ expect(anchor.textContent).toBe('#');
+ });
+});
+
+// ============================================================
+// hideCompletedMilestones toggle (DOM)
+// ============================================================
+describe('hideCompletedMilestones (DOM)', () => {
+ test('checking the box adds hide-completed class to milestones grid', () => {
+ const cb = document.getElementById('hideCompletedMilestones');
+ const grid = document.getElementById('milestonesGrid');
+ cb.checked = true;
+ cb.dispatchEvent(new Event('change'));
+ expect(grid.classList.contains('hide-completed')).toBe(true);
+ });
+
+ test('unchecking the box removes hide-completed class', () => {
+ const cb = document.getElementById('hideCompletedMilestones');
+ const grid = document.getElementById('milestonesGrid');
+ cb.checked = true;
+ cb.dispatchEvent(new Event('change'));
+ cb.checked = false;
+ cb.dispatchEvent(new Event('change'));
+ expect(grid.classList.contains('hide-completed')).toBe(false);
+ });
+});
+
+// ============================================================
+// Extinction countdown (DOM)
+// ============================================================
+describe('Extinction countdown (DOM)', () => {
+ test('extinction countdown elements are populated', () => {
+ // These are updated by updateExtinctionCountdown() called during init
+ const years = document.getElementById('extYears');
+ const days = document.getElementById('extDays');
+ expect(years).not.toBeNull();
+ expect(days).not.toBeNull();
+ });
+});
+
+// ============================================================
+// Life Blocks drill-down navigation (DOM)
+// ============================================================
+describe('Life Blocks drill-down navigation (DOM)', () => {
+ test('clicking a day block drills into hours view', () => {
+ const container = document.getElementById('lb-container');
+ const block = container.querySelector('.lb-block:not(.lb-dead)');
+ if (!block) return; // guard for edge cases where no interactive blocks exist
+ block.click();
+ // After drill-down the breadcrumb should show a back-navigation item,
+ // confirming the view level actually changed.
+ const breadcrumb = document.getElementById('lb-breadcrumb');
+ // The days view breadcrumb has no [data-nav] links; a deeper level will.
+ // Either way, the container must still have content.
+ expect(container.innerHTML.length).toBeGreaterThan(0);
+ // lb-info should show a time-unit label (e.g. "00:xx — select a minute")
+ const info = document.getElementById('lb-info');
+ if (info) expect(info.textContent.length).toBeGreaterThan(0);
+ });
+
+ test('clicking breadcrumb navigates back to days view', () => {
+ const container = document.getElementById('lb-container');
+ const breadcrumb = document.getElementById('lb-breadcrumb');
+ // Click a block to drill down
+ const block = container.querySelector('.lb-block:not(.lb-dead)');
+ if (block) block.click();
+ // Now try to click a breadcrumb item to go back (if one exists)
+ const navBtn = breadcrumb.querySelector('[data-nav]');
+ if (navBtn) {
+ expect(() => navBtn.click()).not.toThrow();
+ }
+ });
+
+ test('pressing Enter on a breadcrumb item navigates', () => {
+ const container = document.getElementById('lb-container');
+ const block = container.querySelector('.lb-block:not(.lb-dead)');
+ if (block) block.click();
+ const breadcrumb = document.getElementById('lb-breadcrumb');
+ const navBtn = breadcrumb.querySelector('[data-nav]');
+ if (navBtn) {
+ expect(() => navBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))).not.toThrow();
+ }
+ });
+
+ test('clicking a stack panel block navigates drill-down', () => {
+ // scrollIntoView is not implemented in jsdom — stub it
+ Element.prototype.scrollIntoView = jest.fn();
+ const stackEl = document.getElementById('lb-stack-seconds');
+ const block = stackEl.querySelector('[data-stack-level]');
+ if (block) {
+ expect(() => block.click()).not.toThrow();
+ }
+ });
+
+ test('pressing Space on a stack panel block navigates drill-down', () => {
+ Element.prototype.scrollIntoView = jest.fn();
+ const stackEl = document.getElementById('lb-stack-seconds');
+ const block = stackEl.querySelector('[data-stack-level]');
+ if (block) {
+ expect(() => block.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))).not.toThrow();
+ }
+ });
+});
+
+// ============================================================
+// Accelerator: Share text and more interactions (DOM)
+// ============================================================
+describe('Accelerator interactions (DOM)', () => {
+ test('pressing Enter on bigRedButton taps it', () => {
+ document.getElementById('accelToggleBtn').click(); // open panel first
+ const btn = document.getElementById('bigRedButton');
+ expect(() => btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))).not.toThrow();
+ });
+
+ test('share acceleration button triggers share without error', () => {
+ const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+ document.getElementById('accelToggleBtn').click();
+ document.getElementById('bigRedButton').click(); // add some tokens
+ const shareBtn = document.getElementById('shareAccelerationBtn');
+ expect(() => shareBtn.click()).not.toThrow();
+ openSpy.mockRestore();
+ });
+
+ test('closing the accelerator panel hides the content', () => {
+ const toggleBtn = document.getElementById('accelToggleBtn');
+ const content = document.getElementById('accel-content');
+ toggleBtn.click(); // open
+ toggleBtn.click(); // close
+ expect(content.hidden).toBe(true);
+ });
+});
+
+// ============================================================
+// Receipt keyboard handler (DOM)
+// ============================================================
+describe('Receipt keyboard handler (DOM)', () => {
+ test('pressing Escape key in the receipt modal closes it', () => {
+ const openBtn = document.getElementById('getReceiptBtn');
+ const modal = document.getElementById('receipt-modal');
+ openBtn.click(); // open modal (attaches trapFocus handler)
+ expect(modal.hidden).toBe(false);
+ // trapFocus listens on the modal element, not document
+ modal.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ expect(modal.hidden).toBe(true);
+ });
+
+ test('Tab key in the receipt modal does not throw', () => {
+ const openBtn = document.getElementById('getReceiptBtn');
+ const modal = document.getElementById('receipt-modal');
+ openBtn.click();
+ // Tab key triggers focus-trap logic; should not throw even if active element is unset
+ expect(() => {
+ modal.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ }).not.toThrow();
+ expect(modal.hidden).toBe(false); // modal should still be open after Tab
+ });
+});
+
+// ============================================================
+// Villain Leaderboard (DOM)
+// ============================================================
+describe('Villain Leaderboard (DOM)', () => {
+ test('villain table body is populated on init', () => {
+ const tbody = document.getElementById('villainTableBody');
+ expect(tbody).not.toBeNull();
+ expect(tbody.children.length).toBeGreaterThan(0);
+ });
+
+ test('villain rank title is populated on init', () => {
+ const titleEl = document.getElementById('villainRankTitle');
+ expect(titleEl).not.toBeNull();
+ expect(titleEl.textContent.length).toBeGreaterThan(0);
+ });
+});
+
+// ============================================================
+// Intervention modal (DOM)
+// ============================================================
+describe('Intervention modal (DOM)', () => {
+ test('stay button dismisses the intervention modal', () => {
+ const modal = document.getElementById('intervention-modal');
+ const stayBtn = document.getElementById('intervention-stay');
+ // Show the modal manually to test the close button
+ modal.hidden = false;
+ stayBtn.click();
+ expect(modal.hidden).toBe(true);
+ });
+});
+
+// ============================================================
+// Grim Reaper (DOM)
+// ============================================================
+describe('Grim Reaper (DOM)', () => {
+ test('grim reaper element exists in DOM', () => {
+ expect(document.getElementById('grim-reaper')).not.toBeNull();
+ });
+});
+
+// ============================================================
+// Copy to clipboard helpers (DOM)
+// ============================================================
+describe('Copy to clipboard (DOM)', () => {
+ test('footer share copy button does not throw', () => {
+ const btn = document.getElementById('footerShareCopy');
+ // Mock clipboard API
+ const mockClipboard = {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ };
+ Object.defineProperty(navigator, 'clipboard', { value: mockClipboard, writable: true });
+ expect(() => btn.click()).not.toThrow();
+ });
+});
+
+// ============================================================
+// Milestone flash overlay (DOM)
+// ============================================================
+describe('Milestone flash overlay (DOM)', () => {
+ test('milestone flash overlay exists in DOM', () => {
+ expect(document.getElementById('milestone-flash-overlay')).not.toBeNull();
+ });
+});