frontend/test/card-art/framing.test.ts
Rama master · réplica de lectura del repositorio original.
/* 图片落地之后的取景:头像按人脸放大、封面按人脸与正封框取景、过小的图原尺寸摆、加载中的微光。
*
* 测试环境没有布局也不解码图片:框的尺寸和图的像素直接写在元素上。 */
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
avatarFrame, coverAnchor, fitNativeImage, installCardArt, refitNativeImages, settleImage, upgradeCover,
watchPendingImages,
} from '../../src/card-art/framing';
function sized(img: HTMLImageElement, width: number, height: number): HTMLImageElement {
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: width });
Object.defineProperty(img, 'naturalHeight', { configurable: true, value: height });
Object.defineProperty(img, 'complete', { configurable: true, value: true });
return img;
}
function boxOf(el: Element, width: number, height: number): void {
el.getBoundingClientRect = () => ({ width, height, x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, toJSON: () => ({}) });
}
function mount(html: string): HTMLElement {
const host = document.createElement('div');
host.innerHTML = html;
document.body.append(host);
return host;
}
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
});
describe('avatarFrame:圆框按人脸放大', () => {
it('图撑到 cover 缩放乘倍数那么大,负偏移把脸拉向框心,并撤掉预检的宽度上限', () => {
const host = mount('<span class="ring"><img data-facebox="0.439 0.224 67 640 960"></span>');
const img = sized(host.querySelector('img')!, 640, 960);
boxOf(img.parentElement!, 160, 160);
vi.stubGlobal('devicePixelRatio', 2);
avatarFrame(img);
vi.unstubAllGlobals();
expect(img.dataset.faceFramed).toBe('');
expect(img.style.position).toBe('absolute');
expect(img.style.width).toBe('200%');
expect(img.style.height).toBe('300%');
expect(img.style.left).toBe('-37.8%');
expect(img.style.top).toBe('-17.2%');
expect(img.style.maxWidth).toBe('none');
});
it('派生件按比例换算脸框,换成别的图就退回几何居中', () => {
const host = mount('<span class="ring"><img data-facebox="0.439 0.224 67 640 960"></span>');
const derived = sized(host.querySelector('img')!, 320, 480);
boxOf(derived.parentElement!, 160, 160);
avatarFrame(derived);
expect(derived.style.width).not.toBe('');
const other = mount('<span class="ring"><img data-facebox="0.439 0.224 67 640 960"></span>').querySelector('img')!;
sized(other, 500, 500);
boxOf(other.parentElement!, 160, 160);
avatarFrame(other);
expect(other.style.objectPosition).toBe('50% 50%');
expect(other.style.width).toBe('');
});
it('原尺寸摆放的小图不放大', () => {
const host = mount('<span class="ring" data-native-small="true"><img data-facebox="0.439 0.224 67 640 960"></span>');
const img = sized(host.querySelector('img')!, 640, 960);
boxOf(img.parentElement!, 160, 160);
avatarFrame(img);
expect(img.dataset.faceFramed).toBe('');
expect(img.style.width).toBe('');
});
it('框还没布局时先藏着,等量到尺寸再取景', () => {
const observers: { fire(): void }[] = [];
vi.stubGlobal('ResizeObserver', class {
constructor(private readonly callback: () => void) { observers.push({ fire: () => this.callback() }); }
observe() {}
disconnect() {}
});
const host = mount('<span class="ring"><img data-facebox="0.439 0.224 67 640 960"></span>');
const img = sized(host.querySelector('img')!, 640, 960);
boxOf(img.parentElement!, 0, 0);
avatarFrame(img);
expect(img.dataset.faceFramed).toBeUndefined();
boxOf(img.parentElement!, 160, 160);
observers[0]!.fire();
vi.unstubAllGlobals();
expect(img.dataset.faceFramed).toBe('');
expect(img.style.width).not.toBe('');
});
});
describe('coverAnchor:封面按自己的宽高比分流', () => {
const cover = (src: string, width: number, height: number, attrs = '') => {
const host = mount(`<div class="pic"><img class="poster cover front" src="${src}" style="--card-ratio:0.75" ${attrs}></div>`);
return sized(host.querySelector('img')!, width, height);
};
it('封套、正封、剧照三种形态;FC2 一律当整幅画面', () => {
const sleeve = cover('/cover?code=SSIS-001', 800, 538);
coverAnchor(sleeve);
expect(sleeve.dataset.frame).toBe('sleeve');
const front = cover('/cover?code=SSIS-002', 350, 500);
coverAnchor(front);
expect(front.dataset.frame).toBe('front');
const still = cover('/cover?code=SSIS-003', 1600, 900);
coverAnchor(still);
expect(still.dataset.frame).toBe('still');
const fc2 = cover('/cover?code=FC2-PPV-1', 800, 538);
coverAnchor(fc2);
expect(fc2.dataset.frame).toBe('still');
});
it('人脸落到可见窗口正中:锚点是 (脸 − 窗口/2) / (1 − 窗口),夹在 0–100%', () => {
const img = cover('/cover?code=SSIS-003', 1600, 900, 'data-cx="0.6"');
coverAnchor(img);
const visible = 0.75 / (1600 / 900);
expect(img.style.getPropertyValue('--cover-x')).toBe(`${Math.round((0.6 - visible / 2) / (1 - visible) * 100)}%`);
const edge = cover('/cover?code=SSIS-004', 1600, 900, 'data-cx="0.95"');
coverAnchor(edge);
expect(edge.style.getPropertyValue('--cover-x')).toBe('100%');
// 横向整幅可见的那根轴不裁,锚点在那里是死值,不写。
expect(edge.style.getPropertyValue('--cover-y')).toBe('');
});
it('没有框的封套按正封比例从右缘量回去,正封边缘使用封面模糊补底', () => {
const img = cover('/cover?code=SSIS-001&thumb=1', 800, 538);
img.parentElement!.style.setProperty('--cover-blur', 'url("/cover?code=SSIS-001&thumb=1")');
coverAnchor(img);
expect(img.classList.contains('panel')).toBe(true);
expect(img.style.getPropertyValue('--panel-clip')).toMatch(/^0% 0% 0% \d/);
expect(img.style.getPropertyValue('--panel-height')).toBe('100%');
expect(img.parentElement!.style.getPropertyValue('--cover-blur')).toContain('/cover?code=SSIS-001&thumb=1');
});
it('整幅大封按主体人脸居中,重取景会清掉正封框样式', () => {
const img = cover('/cover?code=SPONE-001', 2184, 1638, 'data-cx=".555" data-cy=".331"');
coverAnchor(img);
expect(img.classList.contains('panel')).toBe(true);
img.dataset.coverLayout = 'still';
coverAnchor(img);
expect(img.dataset.frame).toBe('still');
expect(img.classList.contains('panel')).toBe(false);
expect(img.style.getPropertyValue('--panel-clip')).toBe('');
expect(img.style.getPropertyValue('--cover-x')).toBe('63%');
expect(img.parentElement!.style.getPropertyValue('--cover-blur')).toBe('');
});
it('Blu-ray 与已知宽封套按宽正封取景,FC2 保留整幅画面', () => {
const wide = cover('https://covers.example/MIDA.jpg', 800, 438, 'data-cover-code="MIDA-122" data-wide-sleeve="1"');
coverAnchor(wide);
expect(wide.dataset.frame).toBe('sleeve');
expect(wide.style.getPropertyValue('--panel-clip')).toBe('0% 0% 0% 53.5%');
expect(parseFloat(wide.style.getPropertyValue('--panel-height'))).toBeLessThan(100);
const fc2 = cover('https://covers.example/fc2.jpg', 800, 438, 'data-cover-code="FC2-PPV-1" data-wide-sleeve="1"');
coverAnchor(fc2);
expect(fc2.dataset.frame).toBe('still');
expect(fc2.classList.contains('panel')).toBe(false);
const dvd = cover('/cover?code=MIDA-159', 800, 538, 'data-wide-sleeve="1"');
coverAnchor(dvd);
expect(dvd.style.getPropertyValue('--panel-height')).toBe('100%');
});
it('框描述的是另一张图时一个字都不写;等比缩小的派生档照用', () => {
const derived = cover('/cover?code=SSIS-001&thumb=1', 800, 538, 'data-posterbox="842 1600 1076 0 1600 1076"');
coverAnchor(derived);
expect(derived.classList.contains('panel')).toBe(true);
const img = cover('/cover?code=SSIS-001', 800, 538, 'data-posterbox="210 400 269 0 400 269"');
coverAnchor(img);
expect(img.classList.contains('panel')).toBe(false);
});
});
describe('宽正封框', () => {
it('VR 画面在 4:3 源图上按左侧人脸取景', () => {
const host = mount('<div class="pic"><img class="poster cover front" src="/cover?code=MDVR-276" style="--card-ratio:.75" data-cover-layout="still" data-cx=".22" data-posterbox="380 800 600 0 800 600"></div>');
const img = sized(host.querySelector('img')!, 800, 600);
coverAnchor(img);
expect(img.dataset.frame).toBe('still');
expect(img.classList.contains('panel')).toBe(false);
expect(img.style.getPropertyValue('--cover-x')).toBe('0%');
});
it('来源框独立于标题保留完整宽正封', () => {
const host = mount('<div class="pic"><img class="poster cover front" src="/cover?code=MIDA-239" style="--card-ratio:.75" data-posterbox="428 800 438 0 800 438"></div>');
const img = sized(host.querySelector('img')!, 800, 438);
coverAnchor(img);
expect(img.classList.contains('panel')).toBe(true);
expect(parseFloat(img.style.getPropertyValue('--panel-height'))).toBeLessThan(100);
expect(parseFloat(img.style.getPropertyValue('--panel-top'))).toBeGreaterThan(0);
});
});
describe('upgradeCover:派生档不够清楚就换回原件', () => {
it('一个源像素要占不止一个设备像素才换,模糊底仍取派生档', () => {
const host = mount('<div class="pic"><img class="poster cover" src="/cover?code=A&thumb=1"></div>');
const img = sized(host.querySelector('img')!, 300, 200);
boxOf(img, 600, 400);
upgradeCover(img);
expect(new URL(img.src).search).toBe('?code=A');
expect(img.dataset.thumbSrc).toContain('/cover?code=A&thumb=1');
const sharp = sized(mount('<img class="poster cover" src="/cover?code=B&thumb=1">').querySelector('img')!, 1200, 800);
boxOf(sharp, 600, 400);
upgradeCover(sharp);
expect(sharp.getAttribute('src')).toBe('/cover?code=B&thumb=1');
});
it('800 那一档不够清楚先换 1600 那一档,版本号留着;模糊底记第一张派生档', () => {
const host = mount('<div class="pic"><img class="poster cover" src="/cover?code=A&thumb=2&v=9"></div>');
const img = sized(host.querySelector('img')!, 800, 538);
boxOf(img, 1000, 672);
upgradeCover(img);
expect(new URL(img.src).search).toBe('?code=A&thumb=1&v=9');
sized(img, 1600, 1076);
upgradeCover(img);
expect(new URL(img.src).search).toBe('?code=A&thumb=1&v=9');
expect(img.dataset.thumbSrc).toContain('/cover?code=A&thumb=2&v=9');
const fits = sized(mount('<img class="poster cover" src="/cover?code=B&thumb=2">').querySelector('img')!, 800, 538);
boxOf(fits, 333, 224);
upgradeCover(fits);
expect(fits.getAttribute('src')).toBe('/cover?code=B&thumb=2');
});
});
describe('fitNativeImage:比框还小的图原尺寸摆', () => {
it('小图按源尺寸摆并拿自己垫模糊底,够大的铺满', () => {
const host = mount('<span data-fit-native><img src="/logo?studio=a"></span>');
const box = host.querySelector<HTMLElement>('[data-fit-native]')!;
Object.defineProperty(box, 'clientWidth', { value: 180 });
Object.defineProperty(box, 'clientHeight', { value: 180 });
const img = sized(box.querySelector('img')!, 42, 42);
fitNativeImage(img);
expect(box.dataset.nativeSmall).toBe('true');
expect(box.style.getPropertyValue('--markw')).toBe('42px');
expect(box.style.getPropertyValue('--markbg')).toContain('/logo?studio=a');
sized(img, 1378, 1378);
refitNativeImages(host);
expect(box.dataset.nativeSmall).toBe('false');
expect(box.style.getPropertyValue('--markw')).toBe('100%');
expect(box.style.getPropertyValue('--markbg')).toBe('none');
});
it('超扁的标识短边撑到下限、不补模糊底;人像框不走这一条', () => {
const host = mount('<span data-fit-native="mark"><img src="/logo?studio=b"></span>'
+ '<span data-fit-native="portrait"><img src="/avatar?p=c"></span>');
const [mark, portrait] = [...host.querySelectorAll<HTMLElement>('[data-fit-native]')];
for (const box of [mark!, portrait!]) {
Object.defineProperty(box, 'clientWidth', { value: 180 });
Object.defineProperty(box, 'clientHeight', { value: 180 });
}
fitNativeImage(sized(mark!.querySelector('img')!, 1378, 42));
expect(mark!.dataset.nativeClamp).toBe('true');
expect(mark!.style.getPropertyValue('--markw')).toBe('180px');
expect(mark!.style.getPropertyValue('--markh')).toBe('18px');
expect(mark!.style.getPropertyValue('--markbg')).toBe('none');
fitNativeImage(sized(portrait!.querySelector('img')!, 1378, 42));
expect(portrait!.dataset.nativeClamp).toBe('false');
});
});
describe('加载微光', () => {
it('插进页面时还没到手的图在框上标 imgwait,等得不久就直接摘', () => {
const host = mount('<span class="ring"><img src="/a"></span>');
const img = host.querySelector('img')!;
Object.defineProperty(img, 'complete', { configurable: true, value: false });
watchPendingImages(host);
expect(img.parentElement!.classList.contains('imgwait')).toBe(true);
settleImage(img);
expect(img.parentElement!.classList.contains('imgwait')).toBe(false);
expect(img.parentElement!.classList.contains('ui-imgdone')).toBe(false);
});
it('等过门槛的淡出完再摘;动效不来时一秒兜底', () => {
vi.useFakeTimers();
const host = mount('<span class="ring"><img src="/a"></span>');
const img = host.querySelector('img')!;
Object.defineProperty(img, 'complete', { configurable: true, value: false });
watchPendingImages(host);
vi.advanceTimersByTime(500);
settleImage(img);
expect(img.parentElement!.classList.contains('ui-imgdone')).toBe(true);
vi.advanceTimersByTime(1000);
expect(img.parentElement!.classList.contains('ui-imgdone')).toBe(false);
});
});
describe('installCardArt:全站一组捕获监听', () => {
it('头像加载完按脸框取景;封面加载完取景;取不到图收掉微光', () => {
installCardArt();
installCardArt();
const host = mount('<span class="ring"><img data-facebox="0.439 0.224 67 640 960"></span><div class="pic"><img class="poster cover" src="/cover?code=X" style="--card-ratio:0.75"></div><span class="ring imgwait"><img src="/gone"></span>');
const face = sized(host.querySelector<HTMLImageElement>('.ring img')!, 640, 960);
boxOf(face.parentElement!, 160, 160);
face.dispatchEvent(new Event('load'));
expect(face.dataset.faceFramed).toBe('');
expect(face.style.width).not.toBe('');
const cover = sized(host.querySelector<HTMLImageElement>('img.cover')!, 1600, 900);
cover.dispatchEvent(new Event('load'));
expect(cover.dataset.frame).toBe('still');
const gone = host.querySelector<HTMLImageElement>('img[src="/gone"]')!;
gone.dispatchEvent(new Event('error'));
expect(gone.parentElement!.classList.contains('imgwait')).toBe(false);
});
});