mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
212 lines
8.1 KiB
TypeScript
212 lines
8.1 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
import { computeFitScale, fitEmailBodyWidth, FIT_MIN_SCALE, releaseFixedWidthTables } from '@/lib/email-fit-width';
|
|
|
|
/** jsdom does no layout, so the widths the fit reads are stubbed by hand. */
|
|
function stubWidths(opts: { viewport: number; content: number | (() => number) }) {
|
|
Object.defineProperty(document.documentElement, 'clientWidth', {
|
|
configurable: true,
|
|
get: () => opts.viewport,
|
|
});
|
|
const content = typeof opts.content === 'function' ? opts.content : () => opts.content as number;
|
|
// Only body carries the width: the helper reads both and takes the max, and a
|
|
// shared getter would advance a scripted sequence twice per measurement.
|
|
Object.defineProperty(document.body, 'scrollWidth', { configurable: true, get: content });
|
|
Object.defineProperty(document.documentElement, 'scrollWidth', { configurable: true, get: () => 0 });
|
|
}
|
|
|
|
describe('computeFitScale', () => {
|
|
it('shrinks desktop-width mail to a phone screen', () => {
|
|
expect(computeFitScale(780, 390)).toBeCloseTo(0.5);
|
|
});
|
|
|
|
it('leaves content that already fits alone', () => {
|
|
expect(computeFitScale(390, 390)).toBe(1);
|
|
// Sub-pixel rounding routinely reports a scrollWidth a hair over the client
|
|
// width on content that visually fits.
|
|
expect(computeFitScale(390.5, 390)).toBe(1);
|
|
});
|
|
|
|
it('leaves a desktop reading pane at 1:1', () => {
|
|
expect(computeFitScale(900, 720)).toBe(1);
|
|
});
|
|
|
|
it('keeps the horizontal scroll for content too wide to stay legible', () => {
|
|
// 390 / 2000 is far below the floor: scaled, this is unreadable, so panning
|
|
// at full size is the better of the two bad options.
|
|
expect(computeFitScale(2000, 390)).toBe(1);
|
|
expect(computeFitScale(390 / FIT_MIN_SCALE - 10, 390)).toBeGreaterThan(FIT_MIN_SCALE);
|
|
});
|
|
|
|
it('ignores nonsense measurements', () => {
|
|
expect(computeFitScale(0, 390)).toBe(1);
|
|
expect(computeFitScale(780, 0)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('releaseFixedWidthTables', () => {
|
|
/** A table whose laid-out width is `offsetWidth` (jsdom lays nothing out). */
|
|
function addTable(attrs: string, offsetWidth: number, parent: HTMLElement = document.body) {
|
|
parent.insertAdjacentHTML('beforeend', `<table ${attrs}><tr><td>text</td></tr></table>`);
|
|
const table = parent.lastElementChild as HTMLTableElement;
|
|
Object.defineProperty(table, 'offsetWidth', { configurable: true, get: () => offsetWidth });
|
|
return table;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
document.body.innerHTML = '';
|
|
document.body.removeAttribute('style');
|
|
Object.defineProperty(document.body, 'clientWidth', { configurable: true, get: () => 390 });
|
|
});
|
|
|
|
it('lets a table pinned by its width attribute shrink to the body (#1020)', () => {
|
|
// WebKit treats the attribute width as the table's minimum, so the
|
|
// stylesheet's max-width: 100% never takes effect on iOS.
|
|
const table = addTable('width="800"', 800);
|
|
|
|
expect(releaseFixedWidthTables(document)).toBe(1);
|
|
expect(table.style.getPropertyValue('width')).toBe('100%');
|
|
expect(table.style.getPropertyPriority('width')).toBe('important');
|
|
// The old width becomes the cap, so a wider pane restores it.
|
|
expect(table.style.maxWidth).toBe('800px');
|
|
});
|
|
|
|
it('releases an inline pixel width, keeping its unit as the cap', () => {
|
|
const table = addTable('style="width: 45em"', 720);
|
|
|
|
releaseFixedWidthTables(document);
|
|
|
|
expect(table.style.width).toBe('100%');
|
|
expect(table.style.maxWidth).toBe('45em');
|
|
});
|
|
|
|
it('measures against the body content box, not its border box', () => {
|
|
document.body.style.padding = '0 12px';
|
|
// 380 fits the 390px body but not its 366px content box.
|
|
const table = addTable('width="380"', 380);
|
|
|
|
releaseFixedWidthTables(document);
|
|
|
|
expect(table.style.width).toBe('100%');
|
|
});
|
|
|
|
it('leaves tables that fit, or have no fixed width, alone', () => {
|
|
const fits = addTable('width="300"', 300);
|
|
// Wide only because its content is (a long URL, many columns): width:100%
|
|
// could not make it any narrower.
|
|
const auto = addTable('', 650);
|
|
const percent = addTable('width="100%"', 650);
|
|
// Inline style overrides the attribute in the cascade.
|
|
const inlinePercent = addTable('width="800" style="width:100%"', 650);
|
|
|
|
expect(releaseFixedWidthTables(document)).toBe(0);
|
|
for (const table of [fits, auto, percent, inlinePercent]) {
|
|
expect(table.style.maxWidth).toBe('');
|
|
}
|
|
});
|
|
|
|
it('releases nested fixed-width tables in one pass', () => {
|
|
const outer = addTable('width="640"', 640);
|
|
const inner = addTable('width="600"', 600, outer.querySelector('td')!);
|
|
|
|
expect(releaseFixedWidthTables(document)).toBe(2);
|
|
expect(outer.style.maxWidth).toBe('640px');
|
|
expect(inner.style.maxWidth).toBe('600px');
|
|
});
|
|
|
|
it('touches each table once across repeated fits', () => {
|
|
// The fit re-runs as images load; the width attribute is still there.
|
|
addTable('width="800"', 800);
|
|
releaseFixedWidthTables(document);
|
|
|
|
expect(releaseFixedWidthTables(document)).toBe(0);
|
|
});
|
|
|
|
it('runs even when the scale-to-fit is disabled', () => {
|
|
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, get: () => 390 });
|
|
const table = addTable('width="800"', 800);
|
|
|
|
expect(fitEmailBodyWidth(document, { enabled: false })).toBe(1);
|
|
expect(table.style.width).toBe('100%');
|
|
});
|
|
});
|
|
|
|
describe('fitEmailBodyWidth', () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = '';
|
|
document.body.removeAttribute('style');
|
|
document.body.removeAttribute('dir');
|
|
});
|
|
|
|
it('lays the body out at its content width and scales it down', () => {
|
|
stubWidths({ viewport: 390, content: 780 });
|
|
const scale = fitEmailBodyWidth(document);
|
|
|
|
expect(scale).toBeCloseTo(0.5);
|
|
expect(document.body.style.width).toBe('780px');
|
|
expect(document.body.style.boxSizing).toBe('border-box');
|
|
expect(document.body.style.transform).toBe('scale(0.5)');
|
|
expect(document.body.style.transformOrigin).toBe('top left');
|
|
});
|
|
|
|
it('anchors the shrink at the right edge in an RTL body', () => {
|
|
stubWidths({ viewport: 390, content: 780 });
|
|
document.body.style.direction = 'rtl';
|
|
fitEmailBodyWidth(document);
|
|
|
|
expect(document.body.style.transformOrigin).toBe('top right');
|
|
});
|
|
|
|
it('touches nothing when the mail already fits', () => {
|
|
stubWidths({ viewport: 390, content: 380 });
|
|
|
|
expect(fitEmailBodyWidth(document)).toBe(1);
|
|
expect(document.body.getAttribute('style')).toBeFalsy();
|
|
});
|
|
|
|
it('undoes a previous fit when the mail reflows to fit', () => {
|
|
stubWidths({ viewport: 390, content: 780 });
|
|
fitEmailBodyWidth(document);
|
|
expect(document.body.style.transform).toBe('scale(0.5)');
|
|
|
|
// e.g. a blocked image was loaded and the layout collapsed to one column.
|
|
stubWidths({ viewport: 390, content: 300 });
|
|
|
|
expect(fitEmailBodyWidth(document)).toBe(1);
|
|
expect(document.body.style.transform).toBe('');
|
|
expect(document.body.style.width).toBe('');
|
|
expect(document.body.style.boxSizing).toBe('');
|
|
});
|
|
|
|
it('undoes a previous fit when disabled', () => {
|
|
stubWidths({ viewport: 390, content: 780 });
|
|
fitEmailBodyWidth(document);
|
|
|
|
expect(fitEmailBodyWidth(document, { enabled: false })).toBe(1);
|
|
expect(document.body.style.transform).toBe('');
|
|
expect(document.body.style.width).toBe('');
|
|
});
|
|
|
|
it('re-measures when widening the body reveals more width', () => {
|
|
// Percentage-width children can grow with the body they were just given.
|
|
const widths = [700, 900, 900];
|
|
let call = 0;
|
|
stubWidths({ viewport: 390, content: () => widths[Math.min(call++, widths.length - 1)] });
|
|
|
|
const scale = fitEmailBodyWidth(document);
|
|
|
|
expect(document.body.style.width).toBe('900px');
|
|
expect(scale).toBeCloseTo(390 / 900);
|
|
});
|
|
|
|
it('backs out of a fit that settles past the shrink floor', () => {
|
|
const widths = [700, 3000, 3000];
|
|
let call = 0;
|
|
stubWidths({ viewport: 390, content: () => widths[Math.min(call++, widths.length - 1)] });
|
|
|
|
expect(fitEmailBodyWidth(document)).toBe(1);
|
|
expect(document.body.style.width).toBe('');
|
|
expect(document.body.style.transform).toBe('');
|
|
});
|
|
});
|