项目文件夹

文件
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 行
8.4 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// @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');
});
});