import { describe, it, expect } from 'vitest'; import { toggleStyle, detectStyles, type TextStyle, type DetectedStyles } from '../src/utils/unicodeTextStyles'; // ── helpers ──────────────────────────────────────────────────────────────── function mapStr(s: string, upperBase: number, lowerBase: number, digitsBase?: number): string { let out = ''; for (const ch of s) { const cp = ch.codePointAt(0)!; if (cp >= 65 && cp <= 90) out += String.fromCodePoint(upperBase + (cp - 65)); else if (cp >= 97 && cp <= 122) out += String.fromCodePoint(lowerBase + (cp - 97)); else if (cp >= 48 && cp <= 57 && digitsBase !== undefined) out += String.fromCodePoint(digitsBase + (cp - 48)); else out += ch; } return out; } /** Bold */ function B(s: string): string { return mapStr(s, 0x1D400, 0x1D41A, 0x1D7CE); } /** Italic (with h → U+210E exception) */ const ITALIC_LOWER: readonly number[] = [ 0x1D44E, 0x1D44F, 0x1D450, 0x1D451, 0x1D452, 0x1D453, 0x1D454, 0x210E, 0x1D456, 0x1D457, 0x1D458, 0x1D459, 0x1D45A, 0x1D45B, 0x1D45C, 0x1D45D, 0x1D45E, 0x1D45F, 0x1D460, 0x1D461, 0x1D462, 0x1D463, 0x1D464, 0x1D465, 0x1D466, 0x1D467, ]; function I(s: string): string { let out = ''; for (const ch of s) { const cp = ch.codePointAt(0)!; if (cp >= 65 && cp <= 90) out += String.fromCodePoint(0x1D434 + (cp - 65)); else if (cp >= 97 && cp <= 122) out += String.fromCodePoint(ITALIC_LOWER[cp - 97]); else out += ch; } return out; } /** Bold Italic */ function BI(s: string): string { return mapStr(s, 0x1D468, 0x1D482); } // Script uppercase with Letterlike exceptions const SCRIPT_UPPER: readonly number[] = [ 0x1D49C, 0x212C, 0x1D49E, 0x1D49F, 0x2130, 0x2131, 0x1D4A2, 0x210B, 0x2110, 0x1D4A5, 0x1D4A6, 0x2112, 0x2133, 0x1D4A9, 0x1D4AA, 0x1D4AB, 0x1D4AC, 0x211B, 0x1D4AE, 0x1D4AF, 0x1D4B0, 0x1D4B1, 0x1D4B2, 0x1D4B3, 0x1D4B4, 0x1D4B5, ]; /** Script (fancy) */ function S(s: string): string { let out = ''; for (const ch of s) { const cp = ch.codePointAt(0)!; if (cp >= 65 && cp <= 90) out += String.fromCodePoint(SCRIPT_UPPER[cp - 65]); else if (cp >= 97 && cp <= 122) out += String.fromCodePoint(0x1D4B6 + (cp - 97)); else out += ch; } return out; } /** Bold Script */ function BS(s: string): string { return mapStr(s, 0x1D4D0, 0x1D4EA); } // Fraktur uppercase with Letterlike exceptions const FRAKTUR_UPPER: readonly number[] = [ 0x1D504, 0x1D505, 0x212D, 0x1D507, 0x1D508, 0x1D509, 0x1D50A, 0x210C, 0x2111, 0x1D50D, 0x1D50E, 0x1D50F, 0x1D510, 0x1D511, 0x1D512, 0x1D513, 0x1D514, 0x211C, 0x1D516, 0x1D517, 0x1D518, 0x1D519, 0x1D51A, 0x1D51B, 0x1D51C, 0x2128, ]; /** Fraktur */ function F(s: string): string { let out = ''; for (const ch of s) { const cp = ch.codePointAt(0)!; if (cp >= 65 && cp <= 90) out += String.fromCodePoint(FRAKTUR_UPPER[cp - 65]); else if (cp >= 97 && cp <= 122) out += String.fromCodePoint(0x1D51E + (cp - 97)); else out += ch; } return out; } /** Bold Fraktur */ function BF(s: string): string { return mapStr(s, 0x1D56C, 0x1D586); } // Double-struck uppercase with Letterlike exceptions const DS_UPPER: readonly number[] = [ 0x1D538, 0x1D539, 0x2102, 0x1D53B, 0x1D53C, 0x1D53D, 0x1D53E, 0x210D, 0x1D540, 0x1D541, 0x1D542, 0x1D543, 0x1D544, 0x2115, 0x1D546, 0x2119, 0x211A, 0x211D, 0x1D54A, 0x1D54B, 0x1D54C, 0x1D54D, 0x1D54E, 0x1D54F, 0x1D550, 0x2124, ]; /** Double-struck */ function DS(s: string): string { let out = ''; for (const ch of s) { const cp = ch.codePointAt(0)!; if (cp >= 65 && cp <= 90) out += String.fromCodePoint(DS_UPPER[cp - 65]); else if (cp >= 97 && cp <= 122) out += String.fromCodePoint(0x1D552 + (cp - 97)); else if (cp >= 48 && cp <= 57) out += String.fromCodePoint(0x1D7D8 + (cp - 48)); else out += ch; } return out; } /** Sans-serif */ function SS(s: string): string { return mapStr(s, 0x1D5A0, 0x1D5BA, 0x1D7E2); } /** Sans-serif Bold */ function SSB(s: string): string { return mapStr(s, 0x1D5D4, 0x1D5EE, 0x1D7EC); } /** Sans-serif Italic */ function SSI(s: string): string { return mapStr(s, 0x1D608, 0x1D622); } /** Sans-serif Bold Italic */ function SSBI(s: string): string { return mapStr(s, 0x1D63C, 0x1D656); } /** Monospace */ function M(s: string): string { return mapStr(s, 0x1D670, 0x1D68A); } /** Underline (without no-break space handling for simplicity) */ function UL(s: string): string { let out = ''; for (const ch of s) { out += ch + '\u0332'; } return out; } /** Strikethrough */ function ST(s: string): string { let out = ''; for (const ch of s) { out += ch + '\u0336'; } return out; } /** Stroke (enclosing square) */ function SK(s: string): string { let out = ''; for (const ch of s) { out += ch + '\u20DE'; } return out; } // ── tests ────────────────────────────────────────────────────────────────── describe('unicodeTextStyles', () => { // --- Bold --- describe('bold', () => { it('enables bold on plain text', () => { expect(toggleStyle('Hello', 'bold')).toBe(B('Hello')); }); it('disables bold on bold text', () => { expect(toggleStyle(B('Hello'), 'bold')).toBe('Hello'); }); it('handles digits', () => { expect(toggleStyle('42', 'bold')).toBe(B('42')); }); it('preserves spaces and punctuation', () => { expect(toggleStyle('Hi, world!', 'bold')).toBe(B('Hi') + ', ' + B('world') + '!'); }); }); // --- Italic --- describe('italic', () => { it('enables italic on plain text', () => { expect(toggleStyle('Hello', 'italic')).toBe(I('Hello')); }); it('disables italic on italic text', () => { expect(toggleStyle(I('Hello'), 'italic')).toBe('Hello'); }); it('handles italic h exception (U+210E)', () => { expect(toggleStyle('h', 'italic')).toBe('\u210E'); expect(toggleStyle('\u210E', 'italic')).toBe('h'); }); it('enables italic on bold text → bold+italic', () => { expect(toggleStyle(B('Hi'), 'italic')).toBe(BI('Hi')); }); it('removes italic from bold-italic → bold', () => { expect(toggleStyle(BI('Hi'), 'italic')).toBe(B('Hi')); }); }); // --- Fancy (script) --- describe('fancy (script)', () => { it('enables script on plain text', () => { expect(toggleStyle('ab', 'fancy')).toBe(S('ab')); }); it('enables bold-script on bold text', () => { expect(toggleStyle(B('ab'), 'fancy')).toBe(BS('ab')); }); it('removes fancy from script → plain', () => { expect(toggleStyle(S('ab'), 'fancy')).toBe('ab'); }); it('removes fancy from bold-script → bold', () => { expect(toggleStyle(BS('ab'), 'fancy')).toBe(B('ab')); }); it('handles uppercase script chars including Letterlike Symbols exceptions', () => { const scriptB = S('B'); // ℬ expect(scriptB).toBe('\u212C'); expect(toggleStyle(scriptB, 'fancy')).toBe('B'); }); }); // --- Fraktur (gothic) --- describe('fraktur', () => { it('enables fraktur on plain text', () => { expect(toggleStyle('ab', 'fraktur')).toBe(F('ab')); }); it('enables bold-fraktur on bold text', () => { expect(toggleStyle(B('ab'), 'fraktur')).toBe(BF('ab')); }); it('disables fraktur on fraktur text', () => { expect(toggleStyle(F('ab'), 'fraktur')).toBe('ab'); }); it('disables fraktur on bold-fraktur → bold', () => { expect(toggleStyle(BF('ab'), 'fraktur')).toBe(B('ab')); }); it('handles uppercase fraktur Letterlike exceptions', () => { // C → ℭ (U+212D) expect(toggleStyle('C', 'fraktur')).toBe('\u212D'); expect(toggleStyle('\u212D', 'fraktur')).toBe('C'); // H → ℌ (U+210C) expect(toggleStyle('H', 'fraktur')).toBe('\u210C'); // R → ℜ (U+211C) expect(toggleStyle('R', 'fraktur')).toBe('\u211C'); // Z → ℨ (U+2128) expect(toggleStyle('Z', 'fraktur')).toBe('\u2128'); }); it('replaces script with fraktur', () => { expect(toggleStyle(S('ab'), 'fraktur')).toBe(F('ab')); }); }); // --- Double-struck --- describe('doubleStruck', () => { it('enables double-struck on plain text', () => { expect(toggleStyle('ab', 'doubleStruck')).toBe(DS('ab')); }); it('handles digits', () => { expect(toggleStyle('42', 'doubleStruck')).toBe(DS('42')); }); it('disables double-struck', () => { expect(toggleStyle(DS('ab'), 'doubleStruck')).toBe('ab'); }); it('handles uppercase double-struck Letterlike exceptions', () => { // C → ℂ (U+2102) expect(toggleStyle('C', 'doubleStruck')).toBe('\u2102'); // H → ℍ (U+210D) expect(toggleStyle('H', 'doubleStruck')).toBe('\u210D'); // N → ℕ (U+2115) expect(toggleStyle('N', 'doubleStruck')).toBe('\u2115'); // P → ℙ (U+2119) expect(toggleStyle('P', 'doubleStruck')).toBe('\u2119'); // Q → ℚ (U+211A) expect(toggleStyle('Q', 'doubleStruck')).toBe('\u211A'); // R → ℝ (U+211D) expect(toggleStyle('R', 'doubleStruck')).toBe('\u211D'); // Z → ℤ (U+2124) expect(toggleStyle('Z', 'doubleStruck')).toBe('\u2124'); }); it('preserves bold when enabling double-struck (bold is lost — no bold+DS block)', () => { // No bold double-struck block exists. Enabling DS on bold text replaces bold. expect(toggleStyle(B('ab'), 'doubleStruck')).toBe(DS('ab')); }); }); // --- Sans-serif --- describe('sansSerif', () => { it('enables sans-serif on plain text', () => { expect(toggleStyle('ab', 'sansSerif')).toBe(SS('ab')); }); it('handles digits', () => { expect(toggleStyle('42', 'sansSerif')).toBe(SS('42')); }); it('disables sans-serif', () => { expect(toggleStyle(SS('ab'), 'sansSerif')).toBe('ab'); }); it('enables sans-serif bold on bold text', () => { expect(toggleStyle(B('ab'), 'sansSerif')).toBe(SSB('ab')); }); it('enables sans-serif italic on italic text', () => { expect(toggleStyle(I('ab'), 'sansSerif')).toBe(SSI('ab')); }); it('enables sans-serif bold italic on bold-italic text', () => { expect(toggleStyle(BI('ab'), 'sansSerif')).toBe(SSBI('ab')); }); it('removes sans-serif from sans-serif bold → bold', () => { expect(toggleStyle(SSB('ab'), 'sansSerif')).toBe(B('ab')); }); }); // --- Monospace --- describe('monospace', () => { it('enables monospace on plain text', () => { expect(toggleStyle('ab', 'monospace')).toBe(M('ab')); }); it('disables monospace', () => { expect(toggleStyle(M('ab'), 'monospace')).toBe('ab'); }); it('replaces bold with monospace', () => { // No bold+monospace block; enabling mono on bold replaces bold expect(toggleStyle(B('ab'), 'monospace')).toBe(M('ab')); }); }); // --- Underline --- describe('underline', () => { it('adds combining low lines', () => { const result = toggleStyle('Hi', 'underline'); expect(result).toBe('H\u0332i\u0332'); }); it('removes underlines', () => { expect(toggleStyle('H\u0332i\u0332', 'underline')).toBe('Hi'); }); it('uses no-break space for continuous underline across spaces', () => { const result = toggleStyle('Hi there', 'underline'); expect(result).toContain('\u00A0\u0332'); const removed = toggleStyle(result, 'underline'); expect(removed).toBe('Hi there'); }); it('combines with bold+underline', () => { const boldUL = toggleStyle(toggleStyle('Hi', 'underline'), 'bold'); const stripped = boldUL.replace(/\u0332/g, ''); expect(stripped).toBe(B('Hi')); expect(boldUL.includes('\u0332')).toBe(true); }); }); // --- Strikethrough --- describe('strikethrough', () => { it('adds combining long stroke overlays', () => { const result = toggleStyle('Hi', 'strikethrough'); expect(result).toBe('H\u0336i\u0336'); }); it('removes strikethrough', () => { expect(toggleStyle('H\u0336i\u0336', 'strikethrough')).toBe('Hi'); }); it('uses no-break space for continuous strikethrough across spaces', () => { const result = toggleStyle('Hi there', 'strikethrough'); expect(result).toContain('\u00A0\u0336'); const removed = toggleStyle(result, 'strikethrough'); expect(removed).toBe('Hi there'); }); it('combines with bold+strikethrough', () => { const boldST = toggleStyle(toggleStyle('Hi', 'strikethrough'), 'bold'); const stripped = boldST.replace(/\u0336/g, ''); expect(stripped).toBe(B('Hi')); expect(boldST.includes('\u0336')).toBe(true); }); it('combines with underline+strikethrough', () => { let text = 'Hi'; text = toggleStyle(text, 'underline'); text = toggleStyle(text, 'strikethrough'); // Should have both combining marks expect(text.includes('\u0332')).toBe(true); expect(text.includes('\u0336')).toBe(true); // Remove just strikethrough text = toggleStyle(text, 'strikethrough'); expect(text.includes('\u0332')).toBe(true); expect(text.includes('\u0336')).toBe(false); // Remove underline text = toggleStyle(text, 'underline'); expect(text.includes('\u0332')).toBe(false); expect(text).toBe('Hi'); }); it('bold+underline+strikethrough triple', () => { let text = 'Hi'; text = toggleStyle(text, 'bold'); text = toggleStyle(text, 'underline'); text = toggleStyle(text, 'strikethrough'); const det = detectStyles(text); expect(det.bold).toBe(true); expect(det.underline).toBe(true); expect(det.strikethrough).toBe(true); // Remove bold → keep underline+strikethrough text = toggleStyle(text, 'bold'); const det2 = detectStyles(text); expect(det2.bold).toBe(false); expect(det2.underline).toBe(true); expect(det2.strikethrough).toBe(true); }); }); // --- Stroke (enclosing square) --- describe('stroke', () => { it('adds combining enclosing squares', () => { const result = toggleStyle('Hi', 'stroke'); expect(result).toBe('H\u20DEi\u20DE'); }); it('removes stroke', () => { expect(toggleStyle('H\u20DEi\u20DE', 'stroke')).toBe('Hi'); }); it('uses no-break space for continuous stroke across spaces', () => { const result = toggleStyle('Hi there', 'stroke'); expect(result).toContain('\u00A0\u20DE'); const removed = toggleStyle(result, 'stroke'); expect(removed).toBe('Hi there'); }); it('combines with bold+stroke', () => { const boldSK = toggleStyle(toggleStyle('Hi', 'stroke'), 'bold'); const stripped = boldSK.replace(/\u20DE/g, ''); expect(stripped).toBe(B('Hi')); expect(boldSK.includes('\u20DE')).toBe(true); }); it('combines with underline+stroke', () => { let text = 'Hi'; text = toggleStyle(text, 'underline'); text = toggleStyle(text, 'stroke'); expect(text.includes('\u0332')).toBe(true); expect(text.includes('\u20DE')).toBe(true); // Remove just stroke text = toggleStyle(text, 'stroke'); expect(text.includes('\u0332')).toBe(true); expect(text.includes('\u20DE')).toBe(false); // Remove underline text = toggleStyle(text, 'underline'); expect(text.includes('\u0332')).toBe(false); expect(text).toBe('Hi'); }); it('combines with strikethrough+stroke', () => { let text = 'Hi'; text = toggleStyle(text, 'strikethrough'); text = toggleStyle(text, 'stroke'); expect(text.includes('\u0336')).toBe(true); expect(text.includes('\u20DE')).toBe(true); // Remove strikethrough → keep stroke text = toggleStyle(text, 'strikethrough'); expect(text.includes('\u0336')).toBe(false); expect(text.includes('\u20DE')).toBe(true); }); it('bold+underline+strikethrough+stroke quadruple', () => { let text = 'Hi'; text = toggleStyle(text, 'bold'); text = toggleStyle(text, 'underline'); text = toggleStyle(text, 'strikethrough'); text = toggleStyle(text, 'stroke'); const det = detectStyles(text); expect(det.bold).toBe(true); expect(det.underline).toBe(true); expect(det.strikethrough).toBe(true); expect(det.stroke).toBe(true); // Remove bold → keep others text = toggleStyle(text, 'bold'); const det2 = detectStyles(text); expect(det2.bold).toBe(false); expect(det2.underline).toBe(true); expect(det2.strikethrough).toBe(true); expect(det2.stroke).toBe(true); }); }); // --- Font family mutual exclusivity --- describe('font family exclusivity', () => { it('switching from script to fraktur replaces correctly', () => { const scriptText = toggleStyle('ab', 'fancy'); expect(scriptText).toBe(S('ab')); const frakturText = toggleStyle(scriptText, 'fraktur'); expect(frakturText).toBe(F('ab')); }); it('switching from fraktur to sans-serif replaces correctly', () => { const frakturText = toggleStyle('ab', 'fraktur'); const ssText = toggleStyle(frakturText, 'sansSerif'); expect(ssText).toBe(SS('ab')); }); it('switching from sans-serif to monospace replaces correctly', () => { const ssText = toggleStyle('ab', 'sansSerif'); const monoText = toggleStyle(ssText, 'monospace'); expect(monoText).toBe(M('ab')); }); it('switching from monospace to double-struck replaces correctly', () => { const monoText = toggleStyle('ab', 'monospace'); const dsText = toggleStyle(monoText, 'doubleStruck'); expect(dsText).toBe(DS('ab')); }); }); // --- Combinations --- describe('combinations', () => { it('bold+italic round-trip', () => { const bi = toggleStyle(toggleStyle('Hello', 'bold'), 'italic'); expect(bi).toBe(BI('Hello')); const back = toggleStyle(toggleStyle(bi, 'italic'), 'bold'); expect(back).toBe('Hello'); }); it('bold+underline round-trip', () => { const bu = toggleStyle(toggleStyle('Hello', 'bold'), 'underline'); const noUnderline = toggleStyle(bu, 'underline'); expect(noUnderline).toBe(B('Hello')); const noBold = toggleStyle(noUnderline, 'bold'); expect(noBold).toBe('Hello'); }); it('bold+italic+underline triple combination', () => { const styled = toggleStyle(toggleStyle(toggleStyle('Hi', 'bold'), 'italic'), 'underline'); const noUL = toggleStyle(styled, 'underline'); expect(noUL).toBe(BI('Hi')); const noItalic = toggleStyle(noUL, 'italic'); expect(noItalic).toBe(B('Hi')); const noBold = toggleStyle(noItalic, 'bold'); expect(noBold.replace(/\u0332/g, '')).toBe('Hi'); }); it('bold+italic+underline can be individually removed', () => { let text = 'Hi'; text = toggleStyle(text, 'bold'); text = toggleStyle(text, 'italic'); text = toggleStyle(text, 'underline'); const det = detectStyles(text); expect(det.bold).toBe(true); expect(det.italic).toBe(true); expect(det.underline).toBe(true); text = toggleStyle(text, 'italic'); const det2 = detectStyles(text); expect(det2.bold).toBe(true); expect(det2.italic).toBe(false); expect(det2.underline).toBe(true); text = toggleStyle(text, 'bold'); const det3 = detectStyles(text); expect(det3.bold).toBe(false); expect(det3.underline).toBe(true); text = toggleStyle(text, 'underline'); const det4 = detectStyles(text); expect(det4.underline).toBe(false); expect(text.replace(/\u0332/g, '')).toBe('Hi'); }); it('sans-serif bold + italic + underline', () => { let text = 'ab'; text = toggleStyle(text, 'sansSerif'); text = toggleStyle(text, 'bold'); text = toggleStyle(text, 'italic'); text = toggleStyle(text, 'underline'); const det = detectStyles(text); expect(det.fontFamily).toBe('sansSerif'); expect(det.bold).toBe(true); expect(det.italic).toBe(true); expect(det.underline).toBe(true); // Remove underline → sans-serif bold italic text = toggleStyle(text, 'underline'); expect(text).toBe(SSBI('ab')); }); }); // --- Detect --- describe('detectStyles', () => { it('returns none for plain text', () => { const d = detectStyles('Hello'); expect(d).toEqual({ bold: false, italic: false, underline: false, strikethrough: false, stroke: false, fontFamily: 'none' }); }); it('detects bold', () => { expect(detectStyles(B('x')).bold).toBe(true); }); it('detects italic', () => { expect(detectStyles(I('x')).italic).toBe(true); }); it('detects bold+italic', () => { const d = detectStyles(BI('x')); expect(d.bold).toBe(true); expect(d.italic).toBe(true); }); it('detects fancy (script)', () => { expect(detectStyles(S('x')).fontFamily).toBe('script'); }); it('detects fraktur', () => { expect(detectStyles(F('x')).fontFamily).toBe('fraktur'); }); it('detects double-struck', () => { expect(detectStyles(DS('x')).fontFamily).toBe('doubleStruck'); }); it('detects sans-serif', () => { expect(detectStyles(SS('x')).fontFamily).toBe('sansSerif'); }); it('detects sans-serif bold', () => { const d = detectStyles(SSB('x')); expect(d.fontFamily).toBe('sansSerif'); expect(d.bold).toBe(true); }); it('detects monospace', () => { expect(detectStyles(M('x')).fontFamily).toBe('monospace'); }); it('detects underline', () => { expect(detectStyles('x\u0332').underline).toBe(true); }); it('detects bold+underline', () => { const text = toggleStyle(toggleStyle('x', 'bold'), 'underline'); const d = detectStyles(text); expect(d.bold).toBe(true); expect(d.underline).toBe(true); }); }); // --- Edge cases --- describe('edge cases', () => { it('handles empty string', () => { expect(toggleStyle('', 'bold')).toBe(''); expect(detectStyles('')).toEqual({ bold: false, italic: false, underline: false, strikethrough: false, stroke: false, fontFamily: 'none' }); }); it('preserves emojis and non-ASCII characters', () => { const result = toggleStyle('Hello 🌍!', 'bold'); expect(result).toBe(B('Hello') + ' 🌍!'); }); it('preserves newlines', () => { const result = toggleStyle('Hi\nthere', 'bold'); expect(result).toBe(B('Hi') + '\n' + B('there')); }); it('handles mixed styled text (toggles based on first char)', () => { const mixed = B('Hello') + ' world'; const result = toggleStyle(mixed, 'bold'); expect(result).toBe('Hello world'); }); it('toggling same font family off restores plain', () => { const fText = toggleStyle('abc', 'fraktur'); const plain = toggleStyle(fText, 'fraktur'); expect(plain).toBe('abc'); }); it('full alphabet round-trip for each font family', () => { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const families: TextStyle[] = ['fancy', 'fraktur', 'doubleStruck', 'sansSerif', 'monospace']; for (const fam of families) { const styled = toggleStyle(alphabet, fam); const restored = toggleStyle(styled, fam); // For families without digits, digits pass through unchanged // For double-struck: no bold, so digits are styled expect(restored).toBe(alphabet); } }); }); });