// Task 3 用例 2/4:load-render-data 对六文件夹具的加载形状、缺文件明错、name 兜底、 // 缓存单例、编码 slug 原样贯穿(前置事实 7——不得 decodeURIComponent)。 import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { loadRenderData, resetRenderDataCache } from '../src/lib/load-render-data.js' import { routeHrefFor } from '../src/lib/ui.js' const FIXTURE = fileURLToPath(new URL('./fixtures/render-input', import.meta.url)) const LOADER_SRC = fileURLToPath(new URL('../src/lib/load-render-data.ts', import.meta.url)) const PREV_ENV = process.env.SF_BUILD_INPUT afterEach(() => { resetRenderDataCache() if (PREV_ENV === undefined) delete process.env.SF_BUILD_INPUT else process.env.SF_BUILD_INPUT = PREV_ENV }) /** 具夹目录的临时副本(变体测试可随意改写,用后即删) */ function tempCopy(name: string): string { const dir = mkdtempSync(join(tmpdir(), `sf-theme-${name}-`)) cpSync(FIXTURE, dir, { recursive: true }) return dir } describe('load-render-data(渲染数据唯一入口)', () => { it('六文件具夹 → 返回形状:site/contents/taxonomies/routes/assets Map/renderManifest', () => { const data = loadRenderData(FIXTURE) // 占位 name(siteKey 派生)原样贯穿——兜底只对空值生效 expect(data.site.name).toBe('demo-site') expect(data.site.origin).toBe('https://demo.example') expect(data.site.locale).toBe('zh-CN') expect(data.site.brand).toStrictEqual({}) // Task 4 扩夹后:11 条(3 basic_page + 4 news_article + 4 product)——六页集 + 首页截断断言的第 4 条 expect(data.contents).toHaveLength(11) const news = data.contents.find((c) => c.slug === 'fixture-news-a') expect(news?.typeKey).toBe('news_article') expect(news?.data.bodyHtml).toContain('src="/wp-content/uploads/2026/10/fixture-a.png"') // media 块键 = media_usages 真实 fieldPath(Task 2 物化面),非裸 'featured' expect(news?.media?.['attachmentRefs.featured']).toBe('/wp-content/uploads/2026/10/fixture-a.png') expect(data.taxonomies[0]).toMatchObject({ key: 'product-category', objectType: 'product' }) expect(data.taxonomies[0]?.terms[0]?.contentEntryIds).toHaveLength(4) const redirect = data.routes.find((r) => r.routeKind === 'redirect') expect(redirect).toMatchObject({ routePath: '/old-path/', redirectStatus: 301, redirectLocation: '/', indexable: false, }) expect(data.assets.size).toBe(3) expect(data.assets.get('/wp-content/uploads/2026/10/fixture-a.png')).toMatchObject({ detectedMimeType: 'image/png', byteSize: 1234, width: 100, height: 80, altText: null, }) expect(data.renderManifest.counts).toStrictEqual({ contents: 11, taxonomies: 1, routes: 15, assets: 3 }) expect(data.renderManifest.renderSnapshotVersion).toBe(1) }) it("site.name 空 → 'Untitled Site';brand 键缺席 → {}(Task 1 审查移交:缺省容忍)", () => { const dir = tempCopy('name') try { writeFileSync( join(dir, 'site.json'), JSON.stringify({ name: '', origin: 'https://demo.example', locale: 'zh-CN' }), ) const data = loadRenderData(dir) expect(data.site.name).toBe('Untitled Site') expect(data.site.brand).toStrictEqual({}) } finally { rmSync(dir, { recursive: true, force: true }) } }) it('缺文件 → 明确错误(不静默空)', () => { const dir = tempCopy('missing') try { rmSync(join(dir, 'routes.json')) expect(() => loadRenderData(dir)).toThrow(/routes\.json/) expect(() => loadRenderData(dir)).toThrow(/缺失|不完整/) } finally { rmSync(dir, { recursive: true, force: true }) } }) it('SF_BUILD_INPUT 未设置且未传目录 → 明确错误', () => { delete process.env.SF_BUILD_INPUT expect(() => loadRenderData()).toThrow(/SF_BUILD_INPUT/) }) it('缺省读 SF_BUILD_INPUT 环境变量目录', () => { process.env.SF_BUILD_INPUT = FIXTURE expect(loadRenderData().site.name).toBe('demo-site') }) it('缓存单例:同目录多次调用只读一次盘(引用同一 + 落盘变化不可见)', () => { const dir = tempCopy('cache') try { const first = loadRenderData(dir) writeFileSync( join(dir, 'site.json'), JSON.stringify({ name: 'changed-after-load', origin: 'https://demo.example', locale: 'zh-CN', brand: {} }), ) const second = loadRenderData(dir) expect(second).toBe(first) expect(second.site.name).toBe('demo-site') // 未重读盘 } finally { rmSync(dir, { recursive: true, force: true }) } }) it('编码 slug 原样贯穿:字节不变、路由编码路径原样(前置事实 7)', () => { const data = loadRenderData(FIXTURE) const page = data.contents.find((c) => c.typeKey === 'basic_page') expect(page?.slug).toBe('%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac') expect(decodeURIComponent(page!.slug)).not.toBe(page!.slug) // 夹具确为编码形态 expect(data.routes.some((r) => r.routePath === '/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/')).toBe(true) // 结构保证:loader 源码不得引入 decodeURIComponent expect(readFileSync(LOADER_SRC, 'utf8')).not.toContain('decodeURIComponent') }) it('content.json 顶层非数组 → 明确错误(不静默空)', () => { const dir = tempCopy('shape') try { writeFileSync(join(dir, 'content.json'), JSON.stringify({ oops: true })) expect(() => loadRenderData(dir)).toThrow(/content\.json/) } finally { rmSync(dir, { recursive: true, force: true }) } }) it('routes.json 空表 → 明确错误(正常发布站点至少含首页路由)', () => { const dir = tempCopy('noroutes') try { writeFileSync(join(dir, 'routes.json'), JSON.stringify([])) expect(() => loadRenderData(dir)).toThrow(/路由表为空/) } finally { rmSync(dir, { recursive: true, force: true }) } }) it('routeHrefFor:targetContentId → 落点路由;无落点 undefined;redirect/查询行不参与', () => { const data = loadRenderData(FIXTURE) expect(routeHrefFor(data.routes, '33333333-3333-4333-8333-333333333333')).toBe('/news/fixture-news-a.html') expect(routeHrefFor(data.routes, '44444444-4444-4444-8444-444444444444')).toBe('/product/fixture-product/') // 无落点(含 redirect 行即便带 targetContentId 也不算内容页公开落点) expect(routeHrefFor(data.routes, '00000000-0000-4000-8000-000000000000')).toBeUndefined() const redirectWithTarget: typeof data.routes[number] = { ...data.routes.find((r) => r.routeKind === 'redirect')!, targetContentId: '33333333-3333-4333-8333-333333333333', } expect(routeHrefFor([redirectWithTarget], '33333333-3333-4333-8333-333333333333')).toBeUndefined() // 带 query_key 的 html 行不是内容页纯路径落点 const queryRoute: typeof data.routes[number] = { ...data.routes.find((r) => r.routePath === '/news/fixture-news-a.html')!, queryKey: 'attachment_id', } expect(routeHrefFor([queryRoute], '33333333-3333-4333-8333-333333333333')).toBeUndefined() }) })