Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

183 lines
8.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { parseDeckThumbnails } from '../../src/runtime/deck-thumbnail-parser';
// Canonical OD framework deck: `.deck-shell > .deck-stage#deck-stage >
// section.slide`, styles in a <head> <style>, `:root` vars, chrome outside the
// stage.
function frameworkDeck(slides: number): string {
const sections = Array.from({ length: slides }, (_, i) =>
`<section class="slide${i === 0 ? ' active' : ''}" data-screen-label="0${i + 1} Title">
<h1 class="title">Slide ${i + 1}</h1>
<img src="assets/pic-${i}.png" alt="" />
</section>`,
).join('\n');
return `<!doctype html><html><head><style>
:root { --bg: #fff; --fg: #111; }
html, body { background: var(--shell); color: var(--fg); }
.deck-stage { width: 1920px; height: 1080px; background: var(--bg); }
.slide:not(.active) { display: none !important; }
.title { background: url(bg/hero.png); }
</style></head><body>
<div class="deck-shell"><div class="deck-stage" id="deck-stage">
${sections}
</div></div>
<nav class="deck-counter"><button id="deck-prev"></button></nav>
<script>/* nav */</script>
</body></html>`;
}
describe('parseDeckThumbnails', () => {
it('extracts slides, styles, ancestors and design size from a framework deck', () => {
const parsed = parseDeckThumbnails(frameworkDeck(3), '/api/projects/p1/raw/');
expect(parsed.renderable).toBe(true);
expect(parsed.slides).toHaveLength(3);
expect(parsed.slides[0]).toMatch(/^<section/);
expect(parsed.slides[1]).toContain('Slide 2');
expect(parsed.designWidth).toBe(1920);
expect(parsed.designHeight).toBe(1080);
expect(parsed.ancestors.map((a) => a.tag)).toEqual(['div', 'div']);
// outer→inner: deck-shell then deck-stage
expect(parsed.ancestors[0]!.attributes).toContainEqual(['class', 'deck-shell']);
expect(parsed.ancestors[1]!.attributes).toContainEqual(['id', 'deck-stage']);
});
it('rewrites :root / html / body selectors to :host', () => {
const parsed = parseDeckThumbnails(frameworkDeck(1));
expect(parsed.styleText).toContain(':host { --bg: #fff');
expect(parsed.styleText).toContain(':host { background: var(--shell)');
expect(parsed.styleText).not.toMatch(/:root\s*\{/);
// Compound selectors are left alone.
expect(parsed.styleText).toContain('.deck-stage {');
});
it('rewrites :root to :host even when a CSS comment precedes it', () => {
// Real decks lead their `<style>` with a banner comment right before the
// `:root` custom-property block (e.g. `/* === VIEWPORT BASE === */`). If the
// rewrite is fooled by the comment, `:root` survives, matches nothing in the
// shadow tree, and every `var(--slide-bg)` resolves to transparent — the
// slide paints nothing over the near-black thumbnail host = black thumbnail.
const html = `<!doctype html><html><head><style>
/* === VIEWPORT BASE === */
:root { --stage-bg: #0a0a0a; --slide-bg: #ffffff; }
html, body { background: var(--stage-bg); }
.deck-stage { width: 1920px; height: 1080px; background: var(--slide-bg); }
.slide { position: absolute; inset: 0; background: var(--slide-bg); }
</style></head><body>
<div class="deck-viewport"><main class="deck-stage" id="deck-stage">
<section class="slide active" data-screen-label="01">A</section>
</main></div>
</body></html>`;
const parsed = parseDeckThumbnails(html);
expect(parsed.renderable).toBe(true);
// The custom properties must land on :host so they inherit into the slide.
expect(parsed.styleText).toContain(':host { --stage-bg: #0a0a0a; --slide-bg: #ffffff; }');
expect(parsed.styleText).not.toMatch(/:root\s*\{/);
});
it('absolutizes relative asset URLs against the base href', () => {
const parsed = parseDeckThumbnails(frameworkDeck(1), '/api/projects/p1/raw/sub');
expect(parsed.slides[0]).toContain('src="/api/projects/p1/raw/sub/assets/pic-0.png"');
expect(parsed.styleText).toContain('url(/api/projects/p1/raw/sub/bg/hero.png)');
});
it('lifts @font-face out of the shadow styles into fontFaces', () => {
const html = frameworkDeck(1).replace(
'<style>',
'<style>@font-face { font-family: "X"; src: url(fonts/x.woff2); }',
);
const parsed = parseDeckThumbnails(html, '/api/projects/p1/raw/');
expect(parsed.fontFaces).toContain('@font-face');
expect(parsed.fontFaces).toContain('/api/projects/p1/raw/fonts/x.woff2');
expect(parsed.styleText).not.toContain('@font-face');
});
it('collects external font-stylesheet links and stays renderable', () => {
const html = frameworkDeck(1).replace(
'</head>',
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter"></head>',
);
const parsed = parseDeckThumbnails(html);
expect(parsed.renderable).toBe(true);
expect(parsed.fontLinks).toEqual(['https://fonts.googleapis.com/css2?family=Inter']);
});
it('reads design size + ancestors from a <deck-stage> template deck', () => {
const html = `<!doctype html><html><head><style>
deck-stage > section.slide { width: 1280px; height: 720px; }
</style></head><body>
<deck-stage width="1280" height="720">
<section class="s1" data-screen-label="01">A</section>
<section class="s2" data-screen-label="02">B</section>
</deck-stage>
</body></html>`;
const parsed = parseDeckThumbnails(html);
expect(parsed.renderable).toBe(true);
expect(parsed.slides).toHaveLength(2);
expect(parsed.designWidth).toBe(1280);
expect(parsed.designHeight).toBe(720);
expect(parsed.ancestors.map((a) => a.tag)).toEqual(['deck-stage']);
});
it('rewrites viewport units in CSS to canvas px (renderable, faithful)', () => {
// No explicit px canvas → defaults to 1920×1080; 100vw→1920px, 100vh→1080px.
const html = `<!doctype html><html><head><style>
#deck > section.slide { width: 100vw; height: 100vh; }
.title { font-size: clamp(24px, 4vh, 48px); padding: 6vw; }
</style></head><body>
<div id="deck"><section class="slide">A</section></div>
</body></html>`;
const parsed = parseDeckThumbnails(html);
expect(parsed.renderable).toBe(true);
expect(parsed.styleText).toContain('width: calc(100 * 19.2px)');
expect(parsed.styleText).toContain('height: calc(100 * 10.8px)');
expect(parsed.styleText).toContain('clamp(24px, calc(4 * 10.8px), 48px)');
expect(parsed.styleText).toContain('padding: calc(6 * 19.2px)');
expect(parsed.styleText).not.toMatch(/\d(?:vw|vh)\b/);
});
it('rewrites viewport units in slide inline styles', () => {
const html = `<!doctype html><html><head><style>
.deck-stage { width: 1920px; height: 1080px; }
</style></head><body><div class="deck-stage" id="deck-stage">
<section class="slide active"><div style="height: 12vh">bar</div></section>
</div></body></html>`;
const parsed = parseDeckThumbnails(html);
expect(parsed.renderable).toBe(true);
expect(parsed.slides[0]).toContain('calc(12 * 10.8px)');
expect(parsed.slides[0]).not.toContain('12vh');
});
it('stays renderable for a fixed px canvas with percent-sized slides', () => {
const html = `<!doctype html><html><head><style>
.deck-stage { width: 1920px; height: 1080px; }
.slide { width: 100%; height: 100%; position: absolute; }
</style></head><body><div class="deck-stage" id="deck-stage">
<section class="slide active">A</section>
</div></body></html>`;
const parsed = parseDeckThumbnails(html);
expect(parsed.renderable).toBe(true);
expect(parsed.designWidth).toBe(1920);
// Percent sizing is left untouched — it already resolves to the canvas.
expect(parsed.styleText).toContain('width: 100%');
});
it('falls back when the deck depends on an external layout stylesheet', () => {
const html = frameworkDeck(1).replace(
'</head>',
'<link rel="stylesheet" href="/api/projects/p1/raw/deck.css"></head>',
);
const parsed = parseDeckThumbnails(html);
expect(parsed.renderable).toBe(false);
expect(parsed.reason).toBe('external-stylesheet');
});
it('falls back for documents with no slides or no styles', () => {
expect(parseDeckThumbnails('<div>not a deck</div>').reason).toBe('no-slides');
expect(parseDeckThumbnails('').reason).toBe('no-slides');
const styleless = '<body><section class="slide">A</section></body>';
expect(parseDeckThumbnails(styleless).reason).toBe('no-styles');
});
});