echore_vault-autopilot/tests/video-note.test.ts
liyachen bd47b6250a test: lock null title never renders '# undefined'
The coercion itself landed in the injection-hardening commit (inlineText
coerces null/undefined to ''); this pins the behavior as a regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:05:27 -04:00

306 lines
16 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.

import { buildAnchor, ensurePublished, mergeSection, coverSection, hookSection, keyframeSection, screenshotSection, VideoNoteMeta } from '../src/video-note';
import { yamlString } from '../src/util';
import { setLanguage } from '../src/i18n';
beforeEach(() => setLanguage('zh'));
afterEach(() => setLanguage('en'));
describe('yamlString', () => {
test('quotes and escapes double quotes', () => {
expect(yamlString('He said "hi"')).toBe('"He said \\"hi\\""');
});
test('escapes newlines to spaces', () => {
expect(yamlString('line1\nline2')).toBe('"line1 line2"');
});
});
test('buildAnchor keeps valid frontmatter when title has quotes', () => {
const fm = buildAnchor({ platform: 'youtube', videoId: 'abc', videoUrl: 'https://y.com', title: 'A "quoted" title' });
const body = fm.split('\n---')[0];
expect(body).toContain('title: "A \\"quoted\\" title"');
});
test('screenshot sections sort after 动效 and renumber ①②', () => {
let c = buildAnchor({ platform: 'youtube', videoId: 'abc123', videoUrl: 'https://www.youtube.com/watch?v=abc123', title: 'Bee' });
c = mergeSection(c, keyframeSection({ url: 'https://www.youtube.com/watch?v=abc123', platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] })).content;
c = mergeSection(c, screenshotSection(['s1.png'])).content;
c = mergeSection(c, screenshotSection(['s2.png'])).content;
expect(c.indexOf('## ✨ 动效')).toBeLessThan(c.indexOf('## 📸 截图'));
expect(c).toContain('## 📸 截图 ①');
expect(c).toContain('## 📸 截图 ②');
expect(c).toContain('dimensions: [动效, 截图]');
});
test('render adds emoji headings, --- dividers (no stacking), and a top overview', () => {
let c = buildAnchor({ platform: 'youtube', videoId: 'abc123', videoUrl: 'https://www.youtube.com/watch?v=abc123', title: 'Bee', channel: 'Ali' });
c = mergeSection(c, coverSection('cover.webp')).content;
c = mergeSection(c, keyframeSection({ url: 'https://www.youtube.com/watch?v=abc123', platform: 'youtube', start: 10, end: 14, frameNames: ['k.png'] })).content;
const bodyDividers = (s: string) => ((s.replace(/^---\n[\s\S]*?\n---\n/, '').match(/^---$/gm)) || []).length;
// dividers separate the two sections, exactly once
expect(bodyDividers(c)).toBe(1);
// emoji headings + overview callout
expect(c).toContain('## 🖼️ 封面标题');
expect(c).toContain('## ✨ 动效 ①');
expect(c).toContain('> [!abstract] Ali · youtube');
// a third merge must not stack a second divider between the same pair
c = mergeSection(c, screenshotSection(['s.png'])).content;
expect(bodyDividers(c)).toBe(2); // 3 sections → 2 dividers
});
test('upgrades an old emoji-less heading on the next merge', () => {
// simulate a note created before the emoji change
const old = `---\ntype: video\nplatform: youtube\nvideo_id: "abc123"\nvideo_url: "u"\ntitle: "Bee"\ndimensions: [内容]\n---\n\n# Bee\n\n## 内容\n\nbody\n`;
const c = mergeSection(old, screenshotSection(['s.png'])).content;
expect(c).toContain('## 🎬 内容');
expect(c).not.toMatch(/^## 内容$/m);
});
const meta: VideoNoteMeta = {
platform: 'youtube', videoId: 'abc123',
videoUrl: 'https://www.youtube.com/watch?v=abc123',
title: 'Bee Keeper', channel: 'NatureCh',
};
test('anchor has frontmatter with video_id and the title heading', () => {
const a = buildAnchor(meta);
expect(a).toContain('video_id: "abc123"');
expect(a).toContain('dimensions: []');
expect(a).toContain('# Bee Keeper');
});
test('cover then hook then keyframe ends ordered cover<hook<动效', () => {
let c = buildAnchor(meta);
c = mergeSection(c, hookSection({ url: meta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f1.png'] })).content;
c = mergeSection(c, coverSection('cover.jpg')).content;
c = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['k1.png'] })).content;
const iCover = c.indexOf('## 🖼️ 封面标题');
const iHook = c.indexOf('## 🎬 内容');
const iKf = c.indexOf('## ✨ 动效');
expect(iCover).toBeGreaterThanOrEqual(0);
expect(iCover).toBeLessThan(iHook);
expect(iHook).toBeLessThan(iKf);
expect(c).toContain('dimensions: [封面标题, 内容, 动效]');
});
test('multiple 动效 sort by start time and renumber ①②', () => {
let c = buildAnchor(meta);
c = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 130, end: 138, frameNames: ['a.png'] })).content;
c = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['b.png'] })).content;
const first = c.indexOf('45s');
const second = c.indexOf('130s');
expect(first).toBeLessThan(second);
expect(c).toContain('## ✨ 动效 ① · 45s52s');
expect(c).toContain('## ✨ 动效 ② · 130s138s');
});
test('singular 内容 re-capture is skipped and preserves user text', () => {
let c = buildAnchor(meta);
c = mergeSection(c, hookSection({ url: meta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f1.png'], transcript: 'sub' })).content;
c = c.replace('### 字幕', 'MY ANALYSIS\n\n### 字幕'); // user edits
const r = mergeSection(c, hookSection({ url: meta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f2.png'] }));
expect(r.skipped).toBe(true);
expect(r.content).toContain('MY ANALYSIS');
expect((r.content.match(/## 🎬 内容/g) || []).length).toBe(1);
});
test('a ## line inside a user code fence is NOT treated as a section', () => {
let c = buildAnchor(meta);
c = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] })).content;
// user pastes a fenced markdown example containing a "## " line into their analysis
c = c.replace('![[k.png]]', '![[k.png]]\n\n```md\n## 这是示例标题\n```');
const r = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 130, end: 138, frameNames: ['k2.png'] }));
// only two real 动效 sections, fenced example untouched, correct numbering
expect((r.content.match(/^## ✨ 动效/gm) || []).length).toBe(2);
expect(r.content).toContain('## 这是示例标题');
expect(r.content).toContain('## ✨ 动效 ① · 45s52s');
expect(r.content).toContain('## ✨ 动效 ② · 130s138s');
});
test('a hand-written unknown heading survives a merge untouched, placed after known sections', () => {
let c = buildAnchor(meta);
c = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] })).content;
c = c + '\n\n---\n\n## 我的分析\n\n这是我的想法\n';
const r = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 130, end: 138, frameNames: ['k2.png'] }));
expect(r.content).toContain('## 我的分析');
expect(r.content).toContain('这是我的想法');
expect(r.content.indexOf('## 我的分析')).toBeGreaterThan(r.content.lastIndexOf('## ✨ 动效'));
const r2 = mergeSection(r.content, screenshotSection(['s.png']));
expect((r2.content.match(/## 我的分析/g) || []).length).toBe(1);
expect(r2.content).toContain('这是我的想法');
expect(r2.content.indexOf('## 我的分析')).toBeGreaterThan(r2.content.lastIndexOf('## 📸 截图'));
});
test('dimensions survive Obsidian Properties re-serialization to block-style YAML', () => {
// Obsidian's Properties editor rewrites `dimensions: [内容]` to a block list
// (quoting items it considers special). A later merge must still dedupe/append.
const reserialized = `---\ntype: video\nvideo_id: abc123\nvideo_url: https://www.youtube.com/watch?v=abc123\ntitle: Bee\ndimensions:\n - "内容"\nanalyzed_at: 2026-07-01\n---\n\n# Bee\n\n## 🎬 内容\n\nbody\n`;
const r = mergeSection(reserialized, keyframeSection({ url: 'https://www.youtube.com/watch?v=abc123', platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] }));
const fm = r.content.match(/^---\n[\s\S]*?\n---/)![0];
expect(fm).toMatch(/dimensions:[\s\S]*内容/);
expect(fm).toMatch(/dimensions:[\s\S]*动效/);
const again = mergeSection(r.content, keyframeSection({ url: 'https://www.youtube.com/watch?v=abc123', platform: 'youtube', start: 100, end: 110, frameNames: ['k2.png'] }));
const fm2 = again.content.match(/^---\n[\s\S]*?\n---/)![0];
expect((fm2.match(/动效/g) || []).length).toBe(1);
});
test('hook section embeds the whole video from start (no end) + frames + 字幕', () => {
const s = hookSection({ url: meta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f1.png'], transcript: 'hello' });
expect(s.kind).toBe('content');
expect(s.text).toContain('embed/abc123?start=0');
expect(s.text).not.toContain('end=');
expect(s.text).toContain('![[f1.png]]');
expect(s.text).toContain('### 字幕');
expect(s.text).toContain('hello');
});
test('keyframe section cues to the segment start with no end cap', () => {
const s = keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] });
expect(s.text).toContain('embed/abc123?start=45');
expect(s.text).not.toContain('end=');
expect(s.text).toContain('## 动效 ① · 45s52s');
});
test('keyframe on a Bilibili url builds a Bilibili player cued to start', () => {
const s = keyframeSection({ url: 'https://www.bilibili.com/video/BV1xx411c7mD/', platform: 'bilibili', start: 30, end: 40, frameNames: ['k.png'] });
expect(s.text).toContain('player.bilibili.com/player.html?bvid=BV1xx411c7mD');
expect(s.text).toContain('t=30');
expect(s.text).not.toContain('youtube.com/embed');
});
test('keyframe float times become integer seconds in embed and heading', () => {
const s = keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 369.35182, end: 386.119189, frameNames: ['k.png'] });
expect(s.text).toContain('embed/abc123?start=369');
expect(s.text).not.toContain('369.35');
expect(s.text).toContain('## 动效 ① · 369s386s');
});
describe('bilingual headings', () => {
const bMeta: VideoNoteMeta = {
platform: 'youtube', videoId: 'abc123',
videoUrl: 'https://www.youtube.com/watch?v=abc123',
title: 'Bee Keeper', channel: 'NatureCh',
};
test('English mode writes English headings and dimensions', () => {
setLanguage('en');
let c = buildAnchor(bMeta);
c = mergeSection(c, coverSection('cover.webp')).content;
c = mergeSection(c, keyframeSection({ url: bMeta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] })).content;
expect(c).toContain('## 🖼️ Cover & Title');
expect(c).toContain('## ✨ Motion ① · 45s52s');
expect(c).toContain('dimensions: [Cover & Title, Motion]');
});
test('English section appended to a Chinese note: recognized, ordered, renumbered', () => {
setLanguage('zh');
let c = buildAnchor(bMeta);
c = mergeSection(c, hookSection({ url: bMeta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f1.png'] })).content;
c = mergeSection(c, keyframeSection({ url: bMeta.videoUrl, platform: 'youtube', start: 130, end: 138, frameNames: ['a.png'] })).content;
setLanguage('en');
const note = mergeSection(c, keyframeSection({ url: bMeta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['b.png'] })).content;
// Chinese hook still recognized and stays before both motion sections
expect(note.indexOf('## 🎬 内容')).toBeGreaterThanOrEqual(0);
expect(note.indexOf('## 🎬 内容')).toBeLessThan(note.indexOf('## ✨ Motion ①'));
// Mixed-language motion sections renumber as one sequence, sorted by start time
expect(note).toContain('## ✨ Motion ① · 45s52s');
expect(note).toContain('## ✨ 动效 ② · 130s138s');
});
test('singular-section skip works across languages', () => {
setLanguage('zh');
let c = buildAnchor(bMeta);
c = mergeSection(c, hookSection({ url: bMeta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f1.png'] })).content;
setLanguage('en');
const r = mergeSection(c, hookSection({ url: bMeta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f2.png'] }));
expect(r.skipped).toBe(true);
});
test('dimensions dedupe by kind across languages', () => {
setLanguage('zh');
let c = buildAnchor(bMeta);
c = mergeSection(c, keyframeSection({ url: bMeta.videoUrl, platform: 'youtube', start: 130, end: 138, frameNames: ['a.png'] })).content;
setLanguage('en');
const note = mergeSection(c, keyframeSection({ url: bMeta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['b.png'] })).content;
// motion is already listed as 动效 — appending an English Motion section must not add a second entry
expect(note).toContain('dimensions: [动效]');
expect(note).not.toContain('dimensions: [动效, Motion]');
});
});
describe('null/undefined title', () => {
test('a null title never renders "# undefined" / "# null"', () => {
const a = buildAnchor({ videoId: 'v1', videoUrl: 'https://y.com/v1', title: null as any, platform: 'youtube' });
const heading = a.split('\n').find((l) => l.startsWith('# '))!;
expect(heading).not.toMatch(/undefined|null/);
});
});
describe('injection hardening', () => {
const base = { videoId: 'v1', videoUrl: 'https://y.com/v1', title: 'T' };
test('crafted platform cannot inject frontmatter keys', () => {
const a = buildAnchor({ ...base, platform: 'x"\ninjected: true' });
const fm = a.match(/^---\n[\s\S]*?\n---/)![0];
expect(fm).not.toMatch(/^injected:/m);
});
test('crafted published cannot inject frontmatter keys', () => {
const a = buildAnchor({ ...base, platform: 'youtube', published: '2026-01-01\nevil: x' });
expect(a.match(/^---\n[\s\S]*?\n---/)![0]).not.toMatch(/^evil:/m);
const patched = ensurePublished('---\ntype: video\n---\n\n# T\n', '2026-01-01\nevil: x');
expect(patched.match(/^---\n[\s\S]*?\n---/)![0]).not.toMatch(/^evil:/m);
});
test('title with newline + fence cannot open a code block in the body', () => {
const a = buildAnchor({ ...base, platform: 'youtube', title: 'T\n```dataviewjs\nboom' });
const body = a.slice(a.match(/^---\n[\s\S]*?\n---/)![0].length);
expect(body).not.toContain('```');
expect(body).not.toContain('\nboom');
});
test('transcript backticks cannot open a fence', () => {
const s = hookSection({ url: 'https://youtu.be/abc', platform: 'youtube', endSeconds: 15, frameNames: ['f.png'], transcript: 'line1\n```dataviewjs\nboom\n```\nline2' });
expect(s.text).not.toContain('```');
expect(s.text).toContain('line1');
expect(s.text).toContain('line2');
});
test('overview label strips quotes from a quoted platform', () => {
const quoted = `---\ntype: video\nplatform: "youtube"\nvideo_id: "q1"\ntitle: "Bee"\ndimensions: []\n---\n\n# Bee\n`;
const c = mergeSection(quoted, screenshotSection(['s.png'])).content;
const label = c.match(/> \[!abstract\] ([^\n]*)/)![1];
expect(label).toContain('youtube');
expect(label).not.toContain('"');
});
});
describe('published frontmatter', () => {
const base = { platform: 'youtube', videoId: 'x', videoUrl: 'https://y/x', title: 'T' };
test('buildAnchor writes published when meta carries it', () => {
expect(buildAnchor({ ...base, published: '2026-07-10' })).toContain('published: "2026-07-10"');
});
test('buildAnchor omits published when absent', () => {
expect(buildAnchor(base)).not.toContain('published:');
});
test('ensurePublished inserts into existing frontmatter', () => {
const content = '---\ntype: video\ntitle: "T"\n---\n\n# T\n';
const out = ensurePublished(content, '2026-07-10');
expect(out).toContain('published: "2026-07-10"');
expect(out.indexOf('published:')).toBeLessThan(out.indexOf('\n---\n'));
});
test('ensurePublished is a no-op when already present', () => {
const content = '---\ntype: video\npublished: 2026-01-01\n---\n\n# T\n';
expect(ensurePublished(content, '2026-07-10')).toBe(content);
});
test('ensurePublished is a no-op without a date or frontmatter', () => {
expect(ensurePublished('---\ntype: video\n---\n', undefined)).toBe('---\ntype: video\n---\n');
expect(ensurePublished('no frontmatter', '2026-07-10')).toBe('no frontmatter');
});
});