| 1 | // fixtures/fonts.test.mjs — the font vocabulary, and the one thing in |
| 2 | // it that can be wrong without anything breaking. |
| 3 | // |
| 4 | // Runs under Node's own test runner, no framework and no DOM: fonts.mjs |
| 5 | // is four functions over plain strings, and the toolbar's own reading of |
| 6 | // a selection reduces to fontForMarks once the active marks are known. |
| 7 | // |
| 8 | // The expectations below are LITERAL — 'sans', 'font_serif' — and not |
| 9 | // derived from DEFAULT_FONT or FONT_MARKS. That is the whole point. |
| 10 | // Asserting fontForMarks([]) === DEFAULT_FONT would pass for any value |
| 11 | // of DEFAULT_FONT, including one that disagrees with the stylesheet, and |
| 12 | // this repo has found seven tests that could not fail against the thing |
| 13 | // they guarded. So the default is written out here, and moving it is a |
| 14 | // two-file edit on purpose. internal/docs's |
| 15 | // TestTheToolbarDefaultFontIsTheDefaultFace is the other half: it holds |
| 16 | // the literal in fonts.mjs to internal/fonts' own Default, which is what |
| 17 | // the PDF exporter and the stylesheet answer to. |
| 18 | // Importing fonts.mjs is also what makes Node run its load-time guard |
| 19 | // (fontsAreCovered), for the reason align.test.mjs gives about |
| 20 | // toolbar.mjs's: a guard that runs only when a browser mounts the |
| 21 | // editor is dead code in the gate. That guard is why there is no test |
| 22 | // below for "the default is one of the faces" or "the default is listed |
| 23 | // first" — it throws on import, so a file that gets this far has |
| 24 | // already satisfied both, and a test restating them could never fail. |
| 25 | import { test } from 'node:test' |
| 26 | import assert from 'node:assert/strict' |
| 27 | import { FONTS, FONT_MARKS, fontMark, fontForMarks } from './fonts.mjs' |
| 28 | |
| 29 | test('the faces are serif, sans and mono', () => { |
| 30 | assert.deepEqual([...FONTS].sort(), ['mono', 'sans', 'serif']) |
| 31 | assert.deepEqual([...FONT_MARKS].sort(), ['font_mono', 'font_sans', 'font_serif']) |
| 32 | }) |
| 33 | |
| 34 | // The default, written out. If this line and views/show.html's |
| 35 | // [docs-text] rule disagree, the font control names a face the text is |
| 36 | // not in and nothing else goes wrong. |
| 37 | test('an unmarked run is sans', () => { |
| 38 | assert.equal(fontForMarks([]), 'sans') |
| 39 | }) |
| 40 | |
| 41 | // The default is spelled as the absence of a mark, so choosing it is a |
| 42 | // clear rather than a set. A document only carries a font mark where |
| 43 | // somebody asked for a face other than the one they had. |
| 44 | test('choosing the default clears the mark rather than setting one', () => { |
| 45 | assert.equal(fontMark('sans'), null) |
| 46 | }) |
| 47 | |
| 48 | test('choosing a non-default face sets that face mark', () => { |
| 49 | assert.equal(fontMark('serif'), 'font_serif') |
| 50 | assert.equal(fontMark('mono'), 'font_mono') |
| 51 | }) |
| 52 | |
| 53 | // undefined, not null: null means "clear the group", which is a real |
| 54 | // edit. A stale page sending an option this build has dropped must |
| 55 | // change nothing, not silently reset a person's font to the default. |
| 56 | test('a face this build does not know is not an instruction to clear', () => { |
| 57 | assert.equal(fontMark('comic'), undefined) |
| 58 | assert.equal(fontMark(''), undefined) |
| 59 | assert.equal(fontMark('constructor'), undefined) |
| 60 | }) |
| 61 | |
| 62 | // Every mark reads back as the face that set it, the default's mark |
| 63 | // included. font_sans is not hypothetical: it is what everyone who chose |
| 64 | // sans before the move has in their documents, and the control must go |
| 65 | // on saying "Sans serif" over those runs rather than treating the mark |
| 66 | // as noise now that it names the default. |
| 67 | test('every font mark reads back as its own face', () => { |
| 68 | assert.equal(fontForMarks(['font_serif']), 'serif') |
| 69 | assert.equal(fontForMarks(['font_sans']), 'sans') |
| 70 | assert.equal(fontForMarks(['font_mono']), 'mono') |
| 71 | }) |
| 72 | |
| 73 | // A mark from some other group must not be read as a font. |
| 74 | test('a non-font mark leaves the face at the default', () => { |
| 75 | assert.equal(fontForMarks(['strong', 'size_lg']), 'sans') |
| 76 | }) |
| 77 | |
| 78 | // The schema's `excludes` makes this unreachable through the editor and |
| 79 | // POST /d/{sqid}/updates makes it reachable anyway. So the answer is not |
| 80 | // free: the screen and the PDF both already answer it, and they answer |
| 81 | // it the same way. |
| 82 | // |
| 83 | // core/src/fragment.rs opens its spans in MARKS order and closes them |
| 84 | // reversed, so a run marked mono and sans is <span serif-ward>…<span |
| 85 | // mono>text</span></span> and the INNERMOST family wins the cascade: |
| 86 | // mono. internal/export/pdf.go's styleFor tests font_mono first for the |
| 87 | // same result. A toolbar that said "sans" over text drawn in mono would |
| 88 | // be the exact mislabel this module exists to prevent, so the literal |
| 89 | // below is 'mono' and not merely stable. |
| 90 | test('a run carrying two font marks reads as the face that is drawn', () => { |
| 91 | const both = ['font_mono', 'font_sans'] |
| 92 | assert.equal(fontForMarks(both), fontForMarks([...both].reverse())) |
| 93 | assert.equal(fontForMarks(both), 'mono') |
| 94 | }) |
| 95 | |