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 @@ [![Unit Tests](https://github.com/nitrocode/token-deathclock/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/nitrocode/token-deathclock/actions/workflows/unit-tests.yml) [![E2E Tests](https://github.com/nitrocode/token-deathclock/actions/workflows/e2e-tests.yml/badge.svg)](https://github.com/nitrocode/token-deathclock/actions/workflows/e2e-tests.yml) [![Deploy](https://github.com/nitrocode/token-deathclock/actions/workflows/deploy.yml/badge.svg)](https://github.com/nitrocode/token-deathclock/actions/workflows/deploy.yml) -[![codecov](https://codecov.io/gh/nitrocode/token-deathclock/branch/main/graph/badge.svg)](https://codecov.io/gh/nitrocode/token-deathclock) +[![codecov](https://codecov.io/gh/nitrocode/token-deathclock/graph/badge.svg)](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 | |---|---| -| [![Codecov icicle](https://codecov.io/gh/nitrocode/token-deathclock/branch/main/graphs/icicle.svg)](https://codecov.io/gh/nitrocode/token-deathclock) | [![Codecov sunburst](https://codecov.io/gh/nitrocode/token-deathclock/branch/main/graphs/sunburst.svg)](https://codecov.io/gh/nitrocode/token-deathclock) | +| [![Codecov icicle](https://codecov.io/gh/nitrocode/token-deathclock/graphs/icicle.svg)](https://codecov.io/gh/nitrocode/token-deathclock) | [![Codecov sunburst](https://codecov.io/gh/nitrocode/token-deathclock/graphs/sunburst.svg)](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 = `
+ + + + +
+

Home

+
+ + + +
+ + + + + +

+  
+  
+
+  
+  
+  
+  
+
+  
+  
+ + + + + + + + + + +
+ + + + + + + + + + +

+  
+  
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+ + + +
+ + + + + + + + + + + + + + + +
+
+ +
+
+
+ + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +

+ + + + + + + + +
+ + + + + + + + + + + + +
+
+
+
+
+
+ + +

Test Section

`; 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(); + }); +});