diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 75b3c7c2b..48987d06f 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -21,6 +21,7 @@ import { callRateLimiter, setBrowserTabTitle } from './common/utils.js'; import { ConfigurationService } from './services/ConfigurationService.js'; import { MODE } from './constants/mode.const.js'; import Log from './log/Log.js'; +import Zoom from './log/Zoom.js'; import Table from './table/Table.js'; import Timezone from './common/Timezone.js'; @@ -68,8 +69,9 @@ export default class Model extends Observable { this.router.bubbleTo(this); this.handleLocationChange(); // Init first page - // Setup keyboard dispatcher + // Setup keyboard and wheel dispatchers window.addEventListener('keydown', this.handleKeyboardDown.bind(this)); + window.addEventListener('wheel', this.handleWheel.bind(this), { passive: false }); // Setup WS connection this.ws = new WebSocketClient(); @@ -81,6 +83,9 @@ export default class Model extends Observable { // Model can change very often we protect router with callRateLimiter // Router limit: 100 calls per 30 seconds max = 30ms, 2 FPS is enough (500ms) this.observe(callRateLimiter(this.updateRouteOnModelChange.bind(this), 500)); + + this.zoom = new Zoom(); + this.zoom.bubbleTo(this); } /** @@ -196,14 +201,44 @@ export default class Model extends Observable { return; } + /** + * Handles wheel events for zoom control + * @param {WheelEvent} e - wheel event + */ + handleWheel(e) { + // Only trigger zoom if Ctrl (or Cmd on Mac) is pressed + // Windows intercepts the Windows key events, so these do not reach the browser + if (!e.ctrlKey && !e.metaKey) { + return; + } + e.preventDefault(); + const now = Date.now(); + // throttle zoom to avoid too many events on fast scroll, especially on trackpads + if (now - this.zoom.lastScrollTime < 50) { + return; + } + this.zoom.lastScrollTime = now; + e.deltaY < 0 ? this.zoom.zoomIn() : this.zoom.zoomOut(); + } + /** * Delegates sub-model actions depending on incoming keyboard event * @param {Event} e - keyboard event */ handleKeyboardDown(e) { - // console.log( - // e.code, e.key,e.keyCode, e.metaKey, e.ctrlKey, e.altKey` - // ); + // Zoom shortcuts regardless of focus + if (e.ctrlKey || e.metaKey) { + if (e.key === '=' || e.key === '+') { + e.preventDefault(); + this.zoom.zoomIn(); + return; + } else if (e.key === '-') { + e.preventDefault(); + this.zoom.zoomOut(); + return; + } + } + const code = e.keyCode; // Enter diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index c262d759c..030491103 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -23,7 +23,11 @@ .table-filters { width: 100%; } /* logs div */ -.logs-container { cursor: default; } +.logs-container { + cursor: default; + --log-font-size: 0.7rem; /* default, overridden by JS zoom */ + --row-height: 0.91rem; /* default, overridden by JS zoom */ +} .logs-content { border-top: 1px solid #aaa; } /* logs tables */ @@ -35,12 +39,12 @@ .table-logs-content td {} .table-logs-header td, -.table-logs-content td { font-size: 0.7rem; } +.table-logs-content td { font-size: var(--log-font-size); } td, th { max-width: 0; /* allow ellipsis on tables */ vertical-align: top; } -.cell { line-height: 1rem; font-size: 1rem; padding: 0rem 0.2rem; font-weight: 100; line-height: 18px; /* must be sync with rowHeight constant in view */ } +.cell { line-height: var(--row-height); font-size: 1rem; padding: 0rem 0.2rem; font-weight: 100; } .cell-bordered { border-left: 1px solid rgb(170, 170, 170); } .cell-content { display: flex; justify-content: space-between; align-items: center; max-width: 100%; } .cell-text { diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 5ea88dc3f..fe6a04bbb 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -17,7 +17,6 @@ import LogFilter from '../logFilter/LogFilter.js'; import ContextMenu from './ContextMenu.js'; import { MODE } from '../constants/mode.const.js'; import { TIME_MS } from '../common/Timezone.js'; -import { ROW_HEIGHT } from '../constants/visual.const.js'; /** * Model Log, encapsulate all log management and queries @@ -599,8 +598,12 @@ export default class Log extends Observable { */ listLogsInViewportOnly() { return this.list.slice( - Math.floor(this.scrollTop / ROW_HEIGHT), - Math.floor(this.scrollTop / ROW_HEIGHT) + Math.ceil(this.scrollHeight / ROW_HEIGHT) + 1, + Math.floor(this.scrollTop / this.rowHeight), + Math.floor(this.scrollTop / this.rowHeight) + Math.ceil(this.scrollHeight / this.rowHeight) + 1, ); } + + get rowHeight() { + return this.model.zoom.rowHeightPx; + } } diff --git a/InfoLogger/public/log/Zoom.js b/InfoLogger/public/log/Zoom.js new file mode 100644 index 000000000..32c24a5ec --- /dev/null +++ b/InfoLogger/public/log/Zoom.js @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Observable } from '/js/src/index.js'; + +/** + * Model for log table zoom, controls row font size and height scaling + */ +export default class Zoom extends Observable { + constructor() { + super(); + + this.level = 1; + this.min = 0.5; + this.max = 4; + this.step = 0.1; + this.baseFontSize = 0.7; + this.rowHeightRatio = 1.3; + this.lastScrollTime = 0; + } + + /** + * Font size in rem units + * @returns {number} - font size in rem units + */ + get fontSize() { + return this.baseFontSize * this.level; + } + + /** + * Row height in rem units, computed with font size to keep the same ratio across zoom levels + * @returns {number} - row height in rem units + */ + get rowHeightRem() { + return this.fontSize * this.rowHeightRatio; + } + + /** + * Row height in pixels, used for the virtual scroll to know how many logs to render depending on the container size + * @returns {number} - row height in pixels + */ + get rowHeightPx() { + return this.rowHeightRem * parseFloat(getComputedStyle(document.documentElement).fontSize); + } + + /** + * Zoom in by increasing zoom level by step, with a maximum of zoom.max + */ + zoomIn() { + this.#setZoomLevel(Math.min(this.level + this.step, this.max)); + } + + /** + * Zoom out by decreasing zoom level by step, with a minimum of zoom.min + */ + zoomOut() { + this.#setZoomLevel(Math.max(this.level - this.step, this.min)); + } + + /** + * Reset zoom to base level of 1 + */ + resetZoom() { + this.#setZoomLevel(1); + } + + /** + * Set zoom level + * @param {number} level - zoom level to set, should be between zoom.min and zoom.max + */ + #setZoomLevel(level) { + // Keep zoom to 2 d.p. to avoid floating-point artifacts (for example 1.2000000000000002) + // This keeps CSS values stable and ensures users can reliably return to default zoom (1) + this.level = parseFloat(level.toFixed(2)); + const root = document.querySelector('.logs-container'); + if (root) { + // Keep CSS sizes to 3 d.p. to avoid floating-point artifacts (for example 1.0920000000000002rem) + root.style.setProperty('--log-font-size', `${this.fontSize.toFixed(3)}rem`); + root.style.setProperty('--row-height', `${this.rowHeightRem.toFixed(3)}rem`); + } + this.notify(); + } +} diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 231ff2f6f..9fb9c8ebd 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -12,7 +12,15 @@ * or submit itself to any jurisdiction. */ -import { h, iconPerson, iconMediaPlay, iconMediaStop, iconDataTransferDownload } from '/js/src/index.js'; +import { h, + iconPerson, + iconMediaPlay, + iconMediaStop, + iconDataTransferDownload, + iconMagnifyingGlass, + iconPlus, + iconMinus, +} from '/js/src/index.js'; import { BUTTON } from '../constants/button-states.const.js'; import { MODE } from '../constants/mode.const.js'; import { setBrowserTabTitle } from '../common/utils.js'; @@ -58,6 +66,7 @@ export default (model) => [ title: 'Go to last log message (ALT + down arrow)', }, '↓'), downloadButtonGroup(model.log), + zoomButtonGroup(model.zoom), ]; /** @@ -150,6 +159,33 @@ const downloadButtonGroup = (logModel) => ]), ]); +/** + * Group of buttons for controlling log table zoom level + * @param {Zoom} zoom - the zoom model + * @returns {vnode} - the view of the zoom button group + */ +const zoomButtonGroup = (zoom) => + h('.btn-group', [ + h('button.btn', { + onclick: () => zoom.zoomOut(), + disabled: zoom.level <= zoom.min, + id: 'zoom-out-button', + title: 'Zoom out (Ctrl/Cmd + -)', + }, h('span', { style: 'font-size:0.8em' }, iconMinus())), + h('button.btn', { + onclick: () => zoom.resetZoom(), + disabled: zoom.level === 1, + id: 'reset-zoom-button', + title: 'Reset zoom', + }, h('span', { style: 'font-size:0.9em' }, iconMagnifyingGlass())), + h('button.btn', { + onclick: () => zoom.zoomIn(), + disabled: zoom.level >= zoom.max, + id: 'zoom-in-button', + title: 'Zoom in (Ctrl/Cmd + +)', + }, h('span', { style: 'font-size:0.8em' }, iconPlus())), + ]); + /** * Live button final state depends on the following states * - services lookup diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 56e214175..8fd4ee8d0 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -16,7 +16,6 @@ import { h } from '/js/src/index.js'; import { severityClass } from './severityUtils.js'; import tableColGroup from './tableColGroup.js'; -import { ROW_HEIGHT } from './../constants/visual.const.js'; /** * Main content of ILG - simulates a big table scrolling. @@ -34,7 +33,7 @@ export default (model) => tableContainerHooks(model), h('div.tableLogsContentPlaceholder', { style: { - height: `${model.log.list.length * ROW_HEIGHT}px`, + height: `${model.log.list.length * model.log.rowHeight}px`, position: 'relative', }, }, [ @@ -55,7 +54,7 @@ export default (model) => const scrollStyling = (model) => ({ style: { position: 'absolute', - top: `${model.log.scrollTop - model.log.scrollTop % ROW_HEIGHT}px`, + top: `${model.log.scrollTop - model.log.scrollTop % model.log.rowHeight}px`, }, }); @@ -264,7 +263,7 @@ const autoscrollManager = (model, vnode) => { if (previousLastLogId !== currentLastLogId) { // scroll at maximum bottom possible - vnode.dom.scrollTo(0, ROW_HEIGHT * model.log.applicationLimit); + vnode.dom.scrollTo(0, model.log.rowHeight * model.log.applicationLimit); vnode.dom.dataset.lastLogId = currentLastLogId; } @@ -281,7 +280,7 @@ const autoscrollManager = (model, vnode) => { if (previousSelectedItemId !== currentSelectedItemId && model.log.autoScrollToItem) { // scroll to an index * height of row, centered const index = model.log.list.indexOf(model.log.item); - const positionRow = ROW_HEIGHT * index; + const positionRow = model.log.rowHeight * index; const halfView = model.log.scrollHeight / 2; vnode.dom.scrollTo(0, positionRow - halfView); } diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 8225532fa..c31d8c1e2 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -70,6 +70,7 @@ describe('InfoLogger', function() { // Start browser to test UI browser = await puppeteer.launch({headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox']}); page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); // 15" screen equivalent // Export page and configurations for the other mocha files exports.page = page; @@ -104,6 +105,7 @@ describe('InfoLogger', function() { require('./public/log-filter-actions-mocha'); require('./public/live-mode-mocha'); require('./public/query-mode-mocha'); + require('./public/zoom.mocha'); require('./public/log-context-menu-mocha'); after(async () => { diff --git a/InfoLogger/test/public/zoom.mocha.js b/InfoLogger/test/public/zoom.mocha.js new file mode 100644 index 000000000..5c86a52f5 --- /dev/null +++ b/InfoLogger/test/public/zoom.mocha.js @@ -0,0 +1,362 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); +const test = require('../mocha-index'); + +describe('Zoom test-suite', async () => { + let page = null; + + before(async () => { + ({ page } = test); + await page.goto(test.helpers.baseUrl, { waitUntil: 'networkidle0' }); + }); + + describe('Default state', () => { + it('should have default zoom level of 1 with font size 0.7rem and matching row height', async () => { + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + fontSize: style.getPropertyValue('--log-font-size').trim(), + rowHeight: style.getPropertyValue('--row-height').trim(), + zoomLevel: window.model.zoom.level, + }; + }); + + assert.strictEqual(result.zoomLevel, 1, 'default zoom level should be 1'); + assert.strictEqual(result.fontSize, '0.7rem', 'default font size should be 0.7rem'); + assert.strictEqual(result.rowHeight, '0.91rem', 'default row height should be 0.91rem'); + }); + }); + + describe('Keyboard shortcuts', () => { + it('should zoom in with Ctrl+Plus (Windows/Linux)', async () => { + await page.keyboard.down('Control'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Control'); + + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + zoomLevel: window.model.zoom.level, + fontSize: style.getPropertyValue('--log-font-size').trim(), + }; + }); + + assert.strictEqual(result.zoomLevel, 1.1, 'zoom level should increase by 0.1'); + assert.strictEqual(result.fontSize, '0.770rem', 'font size should scale with zoom'); + }); + + it('should zoom out with Ctrl+Minus (Windows/Linux)', async () => { + await page.keyboard.down('Control'); + await page.keyboard.press('Minus'); + await page.keyboard.up('Control'); + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); + + assert.strictEqual(result.zoomLevel, 1, 'zoom level should decrease back to 1'); + }); + + it('should zoom in with Meta+Plus (macOS)', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + await page.keyboard.down('Meta'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Meta'); + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); + + assert.strictEqual(result.zoomLevel, 1.1, 'Meta++ should zoom in'); + }); + + it('should zoom out with Meta+Minus (macOS)', async () => { + await page.keyboard.down('Meta'); + await page.keyboard.press('Minus'); + await page.keyboard.up('Meta'); + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + })); + + assert.strictEqual(result.zoomLevel, 1, 'Meta+- should zoom out'); + }); + }); + + describe('Mouse wheel', () => { + it('should zoom in with Ctrl+ScrollUp (Windows/Linux)', async () => { + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.evaluate(() => { + window.model.zoom.resetZoom(); + window.model.zoom.lastScrollTime = 0; + }); + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: -100, + ctrlKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomIn = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomIn, 1.1, 'Ctrl+scroll up should zoom in'); + }); + + it('should zoom out with Ctrl+ScrollDown (Windows/Linux)', async () => { + await page.evaluate(() => { + window.model.zoom.lastScrollTime = 0; + }); + + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: 100, + ctrlKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomOut = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomOut, 1, 'Ctrl+scroll down should zoom out'); + }); + + it('should zoom in with Meta+ScrollUp (macOS)', async () => { + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.evaluate(() => { + window.model.zoom.resetZoom(); + window.model.zoom.lastScrollTime = 0; + }); + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: -100, + metaKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomIn = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomIn, 1.1, 'Meta+scroll up should zoom in'); + }); + + it('should zoom out with Meta+ScrollDown (macOS)', async () => { + await page.evaluate(() => { + window.model.zoom.lastScrollTime = 0; + }); + + const container = await page.$('.logs-container'); + const box = await container.boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.evaluate((cx, cy) => { + const event = new WheelEvent('wheel', { + deltaY: 100, + metaKey: true, + bubbles: true, + clientX: cx, + clientY: cy, + }); + document.querySelector('.flex-column.absolute-fill').dispatchEvent(event); + }, x, y); + + const afterZoomOut = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(afterZoomOut, 1, 'Meta+scroll down should zoom out'); + }); + }); + + describe('Boundaries', () => { + it('should not zoom below minimum level', async () => { + for (let i = 0; i < 10; i++) { + await page.keyboard.down('Control'); + await page.keyboard.press('Minus'); + await page.keyboard.up('Control'); + } + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + min: window.model.zoom.min, + })); + + assert.strictEqual(result.zoomLevel, result.min, 'zoom should not go below minimum'); + }); + + it('should not zoom above maximum level', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + for (let i = 0; i < 35; i++) { + await page.keyboard.down('Control'); + await page.keyboard.press('Equal'); + await page.keyboard.up('Control'); + } + + const result = await page.evaluate(() => ({ + zoomLevel: window.model.zoom.level, + max: window.model.zoom.max, + })); + + assert.strictEqual(result.zoomLevel, result.max, 'zoom should not go above maximum'); + }); + }); + + describe('Reset', () => { + it('should reset zoom to default level', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + + const result = await page.evaluate(() => { + const container = document.querySelector('.logs-container'); + const style = getComputedStyle(container); + return { + zoomLevel: window.model.zoom.level, + fontSize: style.getPropertyValue('--log-font-size').trim(), + rowHeight: style.getPropertyValue('--row-height').trim(), + }; + }); + + assert.strictEqual(result.zoomLevel, 1, 'zoom level should reset to 1'); + assert.strictEqual(result.fontSize, '0.700rem', 'font size should reset to default'); + assert.strictEqual(result.rowHeight, '0.910rem', 'row height should reset to default'); + }); + }); + + describe('Zoom buttons', () => { + beforeEach(async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === true; + }); + }); + + it('should have reset button disabled at default zoom', async () => { + await page.evaluate(() => window.model.zoom.resetZoom()); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === true; + }); + }); + + it('should have reset button enabled when zoomed', async () => { + await page.evaluate(() => window.model.zoom.zoomIn()); + await page.waitForFunction(() => window.model.zoom.level === 1.1); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === false; + }); + }); + + it('should reset zoom when reset button is clicked', async () => { + await page.evaluate(() => window.model.zoom.zoomIn()); + await page.waitForFunction(() => window.model.zoom.level === 1.1); + await page.waitForFunction(() => { + const resetBtn = document.querySelector('#reset-zoom-button'); + return resetBtn && resetBtn.disabled === false; + }); + + await page.evaluate(() => document.querySelector('#reset-zoom-button').click()); + + await page.waitForFunction(() => window.model.zoom.level === 1); + }); + + it('should enable both -/+ buttons at default zoom', async () => { + await page.waitForFunction(() => { + const zoomIn = document.querySelector('#zoom-in-button'); + const zoomOut = document.querySelector('#zoom-out-button'); + return zoomIn && !zoomIn.disabled && zoomOut && !zoomOut.disabled; + }); + }); + + it('should zoom in when + button is clicked', async () => { + await page.evaluate(() => document.querySelector('#zoom-in-button').click()); + const level = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(level, 1.1); + }); + + it('should zoom out when - button is clicked', async () => { + await page.evaluate(() => window.model.zoom.zoomIn()); + await page.evaluate(() => document.querySelector('#zoom-out-button').click()); + const level = await page.evaluate(() => window.model.zoom.level); + assert.strictEqual(level, 1); + }); + + it('should disable - button at minimum zoom', async () => { + await page.evaluate(() => { + window.model.zoom.level = window.model.zoom.min; + window.model.zoom.notify(); + }); + await page.waitForFunction(() => { + const btn = document.querySelector('#zoom-out-button'); + return btn && btn.disabled === true; + }); + }); + + it('should disable + button at maximum zoom', async () => { + await page.evaluate(() => { + window.model.zoom.level = window.model.zoom.max; + window.model.zoom.notify(); + }); + await page.waitForFunction(() => { + const btn = document.querySelector('#zoom-in-button'); + return btn && btn.disabled === true; + }); + }); + }); + + describe('Visual consistency', () => { + it('should maintain row height ratio relative to font size across zoom levels', async () => { + const ratios = []; + for (let i = 0; i < 5; i++) { + const result = await page.evaluate(() => { + const { fontSize } = window.model.zoom; + const { rowHeightRem: rowHeight } = window.model.zoom; + return { ratio: rowHeight / fontSize }; + }); + ratios.push(Number(result.ratio.toFixed(2))); + await page.evaluate(() => window.model.zoom.zoomIn()); + } + + const allSame = ratios.every((r) => r === ratios[0]); + assert.ok(allSame, `row height / font size ratio should be constant, got: ${ratios.join(', ')}`); + + await page.evaluate(() => window.model.zoom.resetZoom()); + }); + }); +});