/**
* @jest-environment jsdom
*
* Tests the standalone ASCII Art Generator window's renderer controller
* (src/renderer/ascii-controller.js). Loads the real HTML and the real
* controller script into jsdom, mocks window.electronAPI.generators.ascii,
* and asserts that:
* - The fonts.css uses a src/-relative path (no `..` escape).
* - Mode-tab clicks switch the visible mode section.
* - Template-button clicks update the preview with the orchestrator's
* template: output.
* - The pure box renderer wraps text with the requested border style.
*/
const fs = require('fs');
const path = require('path');
const HTML_PATH = path.join(__dirname, '..', 'src', 'ascii-generator.html');
const CONTROLLER_PATH = path.join(__dirname, '..', 'src', 'renderer', 'ascii-controller.js');
/**
* Build the ascii-generator DOM in the jsdom document and execute the
* controller script in the same realm (window + document globals).
*
* The HTML file is parsed by the jsdom document and the controller
* source is run via `vm.runInThisContext` so its IIFE captures the real
* `window` and `document` without string interpolation.
*/
function mount() {
// Parse the HTML body content into the jsdom document. We use the
// existing testRealm — the controller only inspects IDs that exist in
// the standalone window's HTML, so a minimal copy is fine.
const html = fs.readFileSync(HTML_PATH, 'utf-8');
// Strip / wrappers — jsdom already provides those.
const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i);
document.body.innerHTML = bodyMatch ? bodyMatch[1] : html;
// Mock the electronAPI bridge before the controller reads it.
const generateMock = jest.fn(async ({ text, font }) => {
if (typeof font === 'string' && font.startsWith('template:')) {
return `<<${font.slice('template:'.length)}>>`;
}
return `<>`;
});
const apiMock = {
listFonts: jest.fn(async () => [
{ id: 'standard', label: 'Standard', kind: 'hand-coded' },
{ id: 'big', label: 'Big', kind: 'hand-coded' },
{ id: 'template:arrow-right', label: 'Template · Arrow Right', kind: 'template' },
]),
generate: generateMock,
lastFont: jest.fn(async () => null),
copy: jest.fn(async () => undefined),
save: jest.fn(async () => ({ canceled: true })),
};
window.electronAPI = {
generators: { ascii: apiMock },
send: jest.fn(),
};
// Execute the controller source in the test realm. The IIFE references
// `window` and `document` directly, so we wrap it in an outer function
// that passes the jsdom globals as parameters. Using Function() here is
// safe: the only interpolated value is the file contents (read from a
// path we control in the repo), not user input.
const src = fs.readFileSync(CONTROLLER_PATH, 'utf-8');
// eslint-disable-next-line no-new-func
new Function('window', 'document', src)(window, document);
return { apiMock, generateMock };
}
afterEach(() => {
jest.clearAllMocks();
});
describe('ascii-generator.html — fonts.css link', () => {
test('does not escape src/ (no "../" in href)', () => {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
const linkRe = /]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/i;
const match = html.match(linkRe);
expect(match).not.toBeNull();
const href = match[1];
expect(href.startsWith('../')).toBe(false);
// The standalone window's document base is src/, so fonts.css must be
// src/-relative (not ../fonts.css).
expect(href).toBe('fonts.css');
});
});
describe('ascii-controller: pure box renderer', () => {
test('wraps text with a single-line border by default', () => {
mount();
const { renderBox } = window.ASCIIBoxRenderer;
const out = renderBox('Hi', 'single', 0);
expect(out).toBe('┌────┐\n│ Hi │\n└────┘');
});
test('honours padding and ascii (+|-) style', () => {
mount();
const { renderBox } = window.ASCIIBoxRenderer;
const out = renderBox('Hi', 'ascii', 1);
// 1 space pad + 'Hi' + 1 space pad = width 4 inside, +2 borders = 6 wide.
expect(out).toBe('+------+\n| Hi |\n+------+');
});
});
describe('ascii-controller: mode-tab switching', () => {
test('clicking the Box tab reveals box-mode and hides the others', async () => {
mount();
await window.ASCIIController.bootstrap();
const boxTab = document.querySelector('.mode-tab[data-mode="box"]');
boxTab.click();
expect(boxTab.classList.contains('active')).toBe(true);
expect(document.getElementById('box-mode').classList.contains('active')).toBe(true);
expect(document.getElementById('text-mode').classList.contains('active')).toBe(false);
expect(document.getElementById('templates-mode').classList.contains('active')).toBe(false);
});
test('clicking the Templates tab reveals templates-mode', async () => {
mount();
await window.ASCIIController.bootstrap();
const tplTab = document.querySelector('.mode-tab[data-mode="templates"]');
tplTab.click();
expect(tplTab.classList.contains('active')).toBe(true);
expect(document.getElementById('templates-mode').classList.contains('active')).toBe(true);
});
});
describe('ascii-controller: template buttons', () => {
test('clicking a template button renders its content into the preview', async () => {
const { generateMock } = mount();
await window.ASCIIController.bootstrap();
const btn = document.querySelector('.template-btn[data-template="arrow-right"]');
btn.click();
// Wait for the async generate() call to resolve.
await Promise.resolve();
await Promise.resolve();
expect(generateMock).toHaveBeenCalledWith(
expect.objectContaining({ font: 'template:arrow-right' })
);
expect(document.getElementById('preview').textContent).toBe('<>');
expect(btn.classList.contains('active')).toBe(true);
});
});