From f407f0c5a690f47fa843c1b669917d754cc0ec25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 31 Jul 2023 21:24:39 +0200 Subject: [PATCH] unit tests --- .../dom/DomRendererRowFactory.test.ts | 68 ++++++++-- src/browser/renderer/dom/WidthCache.test.ts | 127 ++++++++++++++++++ src/browser/renderer/dom/WidthCache.ts | 18 +-- 3 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 src/browser/renderer/dom/WidthCache.test.ts diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 7fd7352b..455a401d 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -12,18 +12,10 @@ import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test'; import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test'; -import { WidthCache } from 'browser/renderer/dom/WidthCache'; +import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test'; -class EmptyWidthCache extends WidthCache { - public widths: {[key: string]: number} = {}; - public get(c: string, bold: boolean | number, italic: boolean | number): number { - if (this.widths[c] !== undefined) { - return this.widths[c]; - } - return 5; // 5 is default width below in tests - } -} -const EMPTY_WIDTH = new EmptyWidthCache(new jsdom.JSDOM('').window.document); + +const EMPTY_WIDTH = new TestWidthCache(new jsdom.JSDOM('').window.document); describe('DomRendererRowFactory', () => { @@ -54,7 +46,7 @@ describe('DomRendererRowFactory', () => { }); it('should set correct attributes for double width characters', () => { - EMPTY_WIDTH.widths['語'] = 10; + EMPTY_WIDTH.widths['語'] = [10, 10, 10, 10]; lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)])); // There should be no element for the following "empty" cell lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0])); @@ -333,7 +325,7 @@ describe('DomRendererRowFactory', () => { }); it('should not merge codepoints with different spacing', () => { - EMPTY_WIDTH.widths['€'] = 2; + EMPTY_WIDTH.widths['€'] = [2, 2, 2, 2]; lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)])); lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)])); @@ -409,6 +401,56 @@ describe('DomRendererRowFactory', () => { ); }); + it('should apply correct positive or negative spacing', () => { + EMPTY_WIDTH.widths['€'] = [2, 2, 2, 2]; // too small, should add 3px + EMPTY_WIDTH.widths['語'] = [10, 10, 10, 10]; // exact match for its width, should merge + EMPTY_WIDTH.widths['𝄞'] = [7, 7, 7, 7]; // too wide, should subtract -2px + lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)])); + lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)])); + lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, '語', 2, 'c'.charCodeAt(0)])); + lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, '𝄞', 1, 'c'.charCodeAt(0)])); + const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1); + assert.equal(extractHtml(spans), + 'ac語𝄞' + ); + }); + + it('should not merge across link borders', () => { + lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); + lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); + lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); + lineData.setCell(5, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); + lineData.setCell(6, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); + const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, 2, 4); + assert.equal(extractHtml(spans), + 'aaxxxbb' + ); + }); + + it('empty cells included in link underline', () => { + lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); + lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); + const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, 2, 4); + assert.equal(extractHtml(spans), + 'aax x' + ); + }); + + it('link range gets capped to actual line borders', () => { + for (let i = 0; i < 10; ++i) { + lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + } + const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -100, 100); + assert.equal(extractHtml(spans), + 'aaaaaaaaaa' + ); + }); + }); function extractHtml(spans: HTMLSpanElement[]): string { diff --git a/src/browser/renderer/dom/WidthCache.test.ts b/src/browser/renderer/dom/WidthCache.test.ts new file mode 100644 index 00000000..8efc6ff4 --- /dev/null +++ b/src/browser/renderer/dom/WidthCache.test.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import * as assert from 'assert'; +import { WidthCache, WidthCacheSettings } from 'browser/renderer/dom/WidthCache'; +import jsdom = require('jsdom'); + + +export class TestWidthCache extends WidthCache { + public get flat(): Float32Array { + return (this as any)._flat; + } + public get holey(): Map | undefined { + return (this as any)._holey; + } + + public widths: {[key: string]: [number, number, number, number]} = {}; + protected _measure(c: string, variant: number): number { + if (this.widths[c] !== undefined) { + return this.widths[c][variant]; + } + return 5; // 5 is default width in tests in DomRendererRowFactory.test.ts + } +} + + +function castf32(v: number): number { + const buffer = new Float32Array(1); + buffer[0] = v; + return buffer[0]; +} + + +describe('WidthCache', () => { + let wc: TestWidthCache; + beforeEach(() => { + wc = new TestWidthCache(new jsdom.JSDOM('').window.document); + wc.setFont('monospace', 15, 'normal', 'bold'); + }); + describe('cache invalidation', () => { + beforeEach(() => { + wc.flat.fill(1.23); + wc.holey?.set('a', 2.34); + }); + it('can cache values', () => { + assert.deepStrictEqual(wc.flat[0], castf32(1.23)); + assert.deepStrictEqual(wc.holey?.get('a'), 2.34); + assert.deepStrictEqual(wc.holey?.size, 1); + }); + it('clear resets cache entries', () => { + wc.clear(); + assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET)); + assert.deepStrictEqual(wc.holey?.get('a'), undefined); + assert.deepStrictEqual(wc.holey?.size, 0); + }); + it('setFont with changed font name', () => { + wc.setFont('Arial', 15, 'normal', 'bold'); + assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET)); + assert.deepStrictEqual(wc.holey?.get('a'), undefined); + assert.deepStrictEqual(wc.holey?.size, 0); + }); + it('setFont with changed font size', () => { + wc.setFont('monospace', 14, 'normal', 'bold'); + assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET)); + assert.deepStrictEqual(wc.holey?.get('a'), undefined); + assert.deepStrictEqual(wc.holey?.size, 0); + }); + it('setFont with changed weight', () => { + wc.setFont('monospace', 15, '100', 'bold'); + assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET)); + assert.deepStrictEqual(wc.holey?.get('a'), undefined); + assert.deepStrictEqual(wc.holey?.size, 0); + }); + it('setFont with changed weightBold', () => { + wc.setFont('monospace', 15, 'normal', '900'); + assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET)); + assert.deepStrictEqual(wc.holey?.get('a'), undefined); + assert.deepStrictEqual(wc.holey?.size, 0); + }); + it('setFont with unchanged settings does not cache entries', () => { + wc.setFont('monospace', 15, 'normal', 'bold'); + assert.deepStrictEqual(wc.flat[0], castf32(1.23)); + assert.deepStrictEqual(wc.holey?.get('a'), 2.34); + assert.deepStrictEqual(wc.holey?.size, 1); + }); + }); + describe('get', () => { + it('store regular < WidthCacheSettings.FLAT_SIZE in flat', () => { + for (let i = 0; i < WidthCacheSettings.FLAT_SIZE + 10; ++i) { + const width = wc.get(String.fromCharCode(i), false, false); + assert.deepStrictEqual(width, 5); + if (i < WidthCacheSettings.FLAT_SIZE) { + assert.deepStrictEqual(wc.flat[i], 5); + assert.deepStrictEqual(wc.holey?.get(String.fromCharCode(i)), undefined); + } else { + assert.deepStrictEqual(wc.holey?.get(String.fromCharCode(i)), 5); + } + } + }); + it('stores bold & italic in holey', () => { + // bold + let width = wc.get('b', true, false); + assert.deepStrictEqual(width, 5); + assert.deepStrictEqual(wc.holey?.get('bB'), 5); + // italic + width = wc.get('i', false, true); + assert.deepStrictEqual(width, 5); + assert.deepStrictEqual(wc.holey?.get('iI'), 5); + // bold&italic + width = wc.get('x', true, true); + assert.deepStrictEqual(width, 5); + assert.deepStrictEqual(wc.holey?.get('xBI'), 5); + }); + it('can store any string', () => { + // regular + let width = wc.get('foo', false, false); + assert.deepStrictEqual(width, 5); + assert.deepStrictEqual(wc.holey?.get('foo'), 5); + // bold&italic + width = wc.get('bar&baz', true, true); + assert.deepStrictEqual(width, 5); + assert.deepStrictEqual(wc.holey?.get('bar&bazBI'), 5); + }); + }); +}); diff --git a/src/browser/renderer/dom/WidthCache.ts b/src/browser/renderer/dom/WidthCache.ts index 45a9d094..ad8a5725 100644 --- a/src/browser/renderer/dom/WidthCache.ts +++ b/src/browser/renderer/dom/WidthCache.ts @@ -7,7 +7,7 @@ import { IDisposable } from 'common/Types'; import { FontWeight } from 'common/services/Services'; -const enum CacheSettings { +export const enum WidthCacheSettings { FLAT_UNSET = -9999, // sentinel for unset values in flat cache FLAT_SIZE = 256, // codepoint upper bound to handle in flat cache REPEAT = 32 // char repeat for measuring @@ -20,12 +20,12 @@ export class WidthCache implements IDisposable { // It has a small memory footprint (only 1MB for full BMP caching), // still the sweet spot is not reached before touching 32k different codepoints, // thus we store the remaining <<20% of terminal data in a holey structure. - private _flat = new Float32Array(CacheSettings.FLAT_SIZE); + protected _flat = new Float32Array(WidthCacheSettings.FLAT_SIZE); // holey cache for bold, italic and bold&italic for any string // FIXME: can grow really big over time (~8.5 MB for full BMP caching), // so a shared API across terminals is needed - private _holey: Map | undefined; + protected _holey: Map | undefined; private _font = ''; private _fontSize = 0; @@ -80,7 +80,7 @@ export class WidthCache implements IDisposable { * Clear the width cache. */ public clear(): void { - this._flat.fill(CacheSettings.FLAT_UNSET); + this._flat.fill(WidthCacheSettings.FLAT_UNSET); // .clear() has some overhead, re-assign instead (>3 times faster) this._holey = new Map(); } @@ -121,8 +121,8 @@ export class WidthCache implements IDisposable { */ public get(c: string, bold: boolean | number, italic: boolean | number): number { let cp = 0; - if (!bold && !italic && c.length === 1 && (cp = c.charCodeAt(0)) < CacheSettings.FLAT_SIZE) { - return this._flat[cp] !== CacheSettings.FLAT_UNSET + if (!bold && !italic && c.length === 1 && (cp = c.charCodeAt(0)) < WidthCacheSettings.FLAT_SIZE) { + return this._flat[cp] !== WidthCacheSettings.FLAT_UNSET ? this._flat[cp] : (this._flat[cp] = this._measure(c, 0)); } @@ -140,9 +140,9 @@ export class WidthCache implements IDisposable { return width; } - private _measure(c: string, variant: number): number { + protected _measure(c: string, variant: number): number { const el = this._measureElements[variant]; - el.textContent = c.repeat(CacheSettings.REPEAT); - return el.getBoundingClientRect().width / CacheSettings.REPEAT; + el.textContent = c.repeat(WidthCacheSettings.REPEAT); + return el.getBoundingClientRect().width / WidthCacheSettings.REPEAT; } }