From 57fee91e32117cb9f7adc179bc011cfcd39931c3 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 14 Sep 2026 20:45:05 +0530 Subject: [PATCH] feat(ascii-art): pure AsciiArt module with generate/listFonts/getFontMeta Pure CommonJS orchestrator that unifies hand-coded fonts, figlet adapter, and templates behind a single public API. Wired to main.js via ipcMain.handle. Public API: - generate({ text, font, options }) - resolves 'template:' / 'figlet:' / bare id, falls back to standard for unknown fonts. - listFonts() - hand-coded first (17), then figlet, then templates (19). - getFontMeta(id) - null for unknown id, full meta for hand-coded, { kind } for figlet/templates. Amit Haridas --- src/main/AsciiArt.js | 97 ++++++++++++++++++++++ tests/main/ascii-art.test.js | 156 +++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/main/AsciiArt.js create mode 100644 tests/main/ascii-art.test.js diff --git a/src/main/AsciiArt.js b/src/main/AsciiArt.js new file mode 100644 index 0000000..a9776f0 --- /dev/null +++ b/src/main/AsciiArt.js @@ -0,0 +1,97 @@ +/** + * ASCII art orchestrator — pure module. + * + * Public API: + * - generate({ text, font, options }) → string + * - listFonts() → [{ id, label, kind, sample }] + * - getFontMeta(id) → { kind, height?, supportedChars? } | null + * + * `font` is a single string that namespaces each backend: + * - bare id ('standard', 'big', 'isometric1', ...) → hand-coded + * - 'template:' → templates + * - 'figlet:' → lazy figlet + * + * Pure: no Electron, no fs, no IPC. Wired to main.js via ipcMain.handle. + * + * @module AsciiArt + */ +'use strict'; + +const { HAND_CODED_FONTS } = require('./AsciiArt.fonts'); +const { getTemplate, ASCII_TEMPLATES } = require('./AsciiArt.templates'); +const FigletAdapter = require('./AsciiArt.figlet-adapter'); + +const SAMPLE_TEXT = 'HELLO'; + +function _renderHandCoded(text, fontId) { + const font = HAND_CODED_FONTS[fontId]; + if (!font) return null; + const upper = String(text ?? '').toUpperCase(); + const lines = Array(font.height).fill(''); + for (const ch of upper) { + const glyph = font.chars[ch] || font.chars[' ']; + for (let i = 0; i < font.height; i++) lines[i] += glyph[i]; + } + return lines.join('\n'); +} + +function generate({ text, font, options: _options } = {}) { + const f = typeof font === 'string' && font ? font : 'standard'; + if (f.startsWith('template:')) { + return getTemplate(f.slice('template:'.length)); + } + if (f.startsWith('figlet:')) { + return FigletAdapter.generateFiglet(String(text ?? ''), f.slice('figlet:'.length)); + } + const rendered = _renderHandCoded(text, f); + return rendered !== null ? rendered : _renderHandCoded(text, 'standard'); +} + +function listFonts() { + const out = []; + for (const [id] of Object.entries(HAND_CODED_FONTS)) { + out.push({ + id, + label: id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), + kind: 'hand-coded', + sample: _renderHandCoded(SAMPLE_TEXT, id), + }); + } + for (const name of FigletAdapter.listFigletFonts()) { + out.push({ + id: `figlet:${name}`, + label: `FIGlet · ${name}`, + kind: 'figlet', + sample: '', + }); + } + for (const name of Object.keys(ASCII_TEMPLATES)) { + out.push({ + id: `template:${name}`, + label: `Template · ${name.replace(/-/g, ' ')}`, + kind: 'template', + sample: ASCII_TEMPLATES[name], + }); + } + return out; +} + +function getFontMeta(id) { + if (typeof id !== 'string' || !id) return null; + if (id.startsWith('template:')) { + return ASCII_TEMPLATES[id.slice('template:'.length)] ? { kind: 'template' } : null; + } + if (id.startsWith('figlet:')) { + const name = id.slice('figlet:'.length); + return FigletAdapter.listFigletFonts().includes(name) ? { kind: 'figlet' } : null; + } + const font = HAND_CODED_FONTS[id]; + if (!font) return null; + return { + kind: 'hand-coded', + height: font.height, + supportedChars: Object.keys(font.chars), + }; +} + +module.exports = { generate, listFonts, getFontMeta }; diff --git a/tests/main/ascii-art.test.js b/tests/main/ascii-art.test.js new file mode 100644 index 0000000..a595721 --- /dev/null +++ b/tests/main/ascii-art.test.js @@ -0,0 +1,156 @@ +/** + * @jest-environment node + */ +const AsciiArt = require('../../src/main/AsciiArt'); +const AsciiArtFonts = require('../../src/main/AsciiArt.fonts'); +const AsciiArtTemplates = require('../../src/main/AsciiArt.templates'); + +describe('AsciiArt.generate', () => { + test('renders "HELLO" in standard font with known shape', () => { + const out = AsciiArt.generate({ text: 'HELLO', font: 'standard' }); + const lines = out.split('\n'); + expect(lines).toHaveLength(AsciiArtFonts.HAND_CODED_FONTS.standard.height); + expect(lines[0]).toContain('H'); + }); + + test('lowercase input is uppercased', () => { + const a = AsciiArt.generate({ text: 'hello', font: 'standard' }); + const b = AsciiArt.generate({ text: 'HELLO', font: 'standard' }); + expect(a).toBe(b); + }); + + test('unknown font falls back to standard', () => { + const a = AsciiArt.generate({ text: 'A', font: 'nope-not-a-font' }); + const b = AsciiArt.generate({ text: 'A', font: 'standard' }); + expect(a).toBe(b); + }); + + test('unknown character substitutes space glyph (column width preserved)', () => { + const font = AsciiArtFonts.HAND_CODED_FONTS.standard; + const spaceWidth = font.chars[' '][0].length; + const out = AsciiArt.generate({ text: 'A!A', font: 'standard' }); + const line = out.split('\n')[0]; + // "A" rendered + space-glyph for "!" + "A" rendered; the middle column + // is the space-width. + expect(line.length).toBeGreaterThanOrEqual(spaceWidth); + }); + + test('renders a named template via "template:"', () => { + const tpl = AsciiArtTemplates.getTemplate('arrow-right'); + expect(AsciiArt.generate({ text: 'ignored', font: 'template:arrow-right' })).toBe(tpl); + }); + + test('renders figlet font via "figlet:" — adapter is mocked below', () => { + // Mock the adapter module so we don't depend on the real figlet. + jest.resetModules(); + jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({ + AsciiArtFigletError: class extends Error {}, + generateFiglet: (t, f) => `FIGLET[${f}:${t}]`, + listFigletFonts: () => ['Big', 'Slant'], + loadFiglet: () => ({ fontsSync: () => ['Big', 'Slant'] }), + })); + const Fresh = require('../../src/main/AsciiArt'); + expect(Fresh.generate({ text: 'HI', font: 'figlet:Big' })).toBe('FIGLET[Big:HI]'); + }); + + test('propagates AsciiArtFigletError on figlet failure', () => { + jest.resetModules(); + jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({ + AsciiArtFigletError: class extends Error {}, + generateFiglet: () => { + throw new Error('boom'); + }, + listFigletFonts: () => [], + loadFiglet: () => null, + })); + const Fresh = require('../../src/main/AsciiArt'); + expect(() => Fresh.generate({ text: 'X', font: 'figlet:Big' })).toThrow(/figlet/i); + }); +}); + +describe('AsciiArt.listFonts', () => { + test('returns hand-coded fonts first, then figlet, then templates', () => { + jest.resetModules(); + jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({ + AsciiArtFigletError: class extends Error {}, + generateFiglet: () => '', + listFigletFonts: () => ['Big', 'Slant'], + loadFiglet: () => ({ fontsSync: () => ['Big', 'Slant'] }), + })); + const Fresh = require('../../src/main/AsciiArt'); + const list = Fresh.listFonts(); + expect(list.length).toBeGreaterThanOrEqual(17 + 2 + 19); + // first 17 should be hand-coded + for (let i = 0; i < 17; i++) { + expect(list[i].kind).toBe('hand-coded'); + expect(list[i].id).toMatch(/^[a-z0-9-]+$/); + } + // figlet entries come next + const firstFiglet = list.find((f) => f.kind === 'figlet'); + expect(firstFiglet.id).toBe('figlet:Big'); + }); + + test('still returns hand-coded + templates when figlet is unavailable', () => { + jest.resetModules(); + jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({ + AsciiArtFigletError: class extends Error {}, + generateFiglet: () => '', + listFigletFonts: () => [], + loadFiglet: () => null, + })); + const Fresh = require('../../src/main/AsciiArt'); + const list = Fresh.listFonts(); + expect(list.some((f) => f.kind === 'figlet')).toBe(false); + expect(list.filter((f) => f.kind === 'hand-coded')).toHaveLength(17); + expect(list.filter((f) => f.kind === 'template').length).toBeGreaterThanOrEqual(19); + }); + + test('every entry has id, label, kind, sample', () => { + jest.resetModules(); + jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({ + AsciiArtFigletError: class extends Error {}, + generateFiglet: () => '', + listFigletFonts: () => [], + loadFiglet: () => null, + })); + const Fresh = require('../../src/main/AsciiArt'); + for (const f of Fresh.listFonts()) { + expect(typeof f.id).toBe('string'); + expect(typeof f.label).toBe('string'); + expect(['hand-coded', 'figlet', 'template']).toContain(f.kind); + expect(typeof f.sample).toBe('string'); + expect(f.sample.length).toBeGreaterThan(0); + } + }); +}); + +describe('AsciiArt.getFontMeta', () => { + test('hand-coded font returns kind, height, supportedChars', () => { + const meta = AsciiArt.getFontMeta('big'); + expect(meta.kind).toBe('hand-coded'); + expect(typeof meta.height).toBe('number'); + expect(meta.height).toBeGreaterThanOrEqual(4); + expect(Array.isArray(meta.supportedChars)).toBe(true); + expect(meta.supportedChars).toEqual(expect.arrayContaining(['A', '0', ' '])); + }); + + test('template returns { kind: "template" }', () => { + expect(AsciiArt.getFontMeta('template:flowchart')).toEqual({ kind: 'template' }); + }); + + test('figlet returns { kind: "figlet" }', () => { + jest.resetModules(); + jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({ + AsciiArtFigletError: class extends Error {}, + generateFiglet: () => '', + listFigletFonts: () => ['Big'], + loadFiglet: () => ({}), + })); + const Fresh = require('../../src/main/AsciiArt'); + expect(Fresh.getFontMeta('figlet:Big')).toEqual({ kind: 'figlet' }); + }); + + test('unknown id returns null', () => { + expect(AsciiArt.getFontMeta('nope')).toBeNull(); + }); +});