Files
zhangxiang 82217576b9 feat(detail): Layer 3 信任层空数据兜底骨架(L3SignalsSlot + L3EmptyFallback + EarlyAccessNotice)
- 新增 L3SignalsSlot:4 类可验证替代信号卡(方法论/团队/历程/服务承诺),
  服务承诺卡 disabled 占位待业务确认;零编造约束下替代真实案例/认证
- 新增 EarlyAccessNotice:首批客户共创状态条(单一真源 company.ts EARLY_ACCESS)
- 新增 L3EmptyFallback:caseStudies/certifications/dataProofs 三者全空时渲染兜底,
  任一非空 return null 保留本地真实 section 视觉
- 接入 5 处详情页:solution v3 / service v4 / product v3 / erp-upgrade-v3 / standalone/[id]
- 修复 StaggerReveal 网格布局:grid 类须挂在 StaggerReveal 自身(否则卡片堆单列)
- 更新 solution 测试至新行为(空数据→兜底渲染)+ 补 lucide 图标 mock
- 修复 header.test.tsx 双 logo 断言(2593599 遗留)
- 新增 scripts/audit/l3-fallback-verify.mjs 运行时验证(浅/深双版 PASS)
  注:必须用 localhost 而非 127.0.0.1 —— Next dev allowedDevOrigins 默认白名单
  不含 127.0.0.1,chunk 请求带该 Origin 被 403 → hydration 不发生 → 动画全冻结

验证:type-check 0 错 · lint 0 错(127 既有 warnings) · 单测 129 套件/1628 通过 ·
L3 兜底浅深双版渲染截图 PASS
2026-09-20 11:50:58 +08:00

124 lines
4.5 KiB
JavaScript
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.
// 验证 Layer 3 空数据兜底(L3EmptyFallback):信号卡 + 共创占位渲染
// 用法:node scripts/audit/l3-fallback-verify.mjs
//
// 断言(solution 详情页,当前无 caseStudies/certifications → 兜底应渲染):
// 1. section[aria-labelledby=l3-signals-heading] 存在且可见(opacity→1
// 2. 4 张信号卡齐全,其中 3 张为链接(/methodology /team /about/brand),1 张 disabled
// 3. EarlyAccessNotice(首批客户共创中)存在
// 4. 深色模式下同样成立(截图目视 + token 反色由 globals.css 变量层保证)
import { chromium } from 'playwright';
// 注意:必须用 localhost(非 127.0.0.1)。Next dev 的 allowedDevOrigins 默认白名单
// 不含 127.0.0.1chunk 子资源请求带 Origin: http://127.0.0.1:3000 会被 403 拒掉,
// 导致 JS 加载失败 → hydration 不发生 → 全站 whileInView 动画冻结(页面空白)。
// 代理问题由 chromium --no-proxy-server 解决(见 launch 参数),与 URL 无关。
const BASE = 'http://localhost:3000';
const OUT = 'dogfood-output/l3-fallback-verify';
const PAGE_URL = `${BASE}/solutions/manufacturing`;
const results = [];
async function probe(page, theme) {
await page.goto(PAGE_URL, { waitUntil: 'networkidle' });
// dev 模式 hydration 较慢(Turbopack 按需编译):等待 framer-motion 生效的标志——
// 页面上任一 motion div 的 opacity 变为 '1'(说明 IO + rAF 循环已运行)
let hydrated = true;
try {
await page.waitForFunction(
() => [...document.querySelectorAll('div[style]')].some((d) => d.style.opacity === '1'),
{ timeout: 20000 }
);
} catch {
hydrated = false;
}
await page.waitForTimeout(500);
const sec = page.locator('section[aria-labelledby="l3-signals-heading"]');
const secCount = await sec.count();
if (secCount === 0) {
results.push({ theme, pass: false, reason: 'L3 signals section not found' });
return;
}
// 滚动触发 whileInView,等动画完成(轮询 opacity,上限 10s
await sec.scrollIntoViewIfNeeded();
let headingOpacity = '0';
try {
await page.waitForFunction(
() => {
const h = document.getElementById('l3-signals-heading');
if (!h) return false;
const o = Number(getComputedStyle(h.closest('div')).opacity);
return o > 0.9;
},
{ timeout: 10000 }
);
headingOpacity = '1';
} catch {
headingOpacity = await sec
.locator('#l3-signals-heading')
.evaluate((el) => getComputedStyle(el.closest('div')).opacity);
}
const cards = await sec.locator('a, [aria-disabled="true"]').count();
const linkHrefs = await sec.locator('a').evaluateAll((as) => as.map((a) => a.getAttribute('href')));
const disabledCard = await sec.locator('[aria-disabled="true"]').count();
const notice = page.locator('section[aria-labelledby="early-access-notice-heading"]');
const noticeCount = await notice.count();
const noticeText = noticeCount > 0 ? await notice.locator('p').first().textContent() : null;
await page.screenshot({ path: `${OUT}/l3-${theme}-full.png` });
await sec.screenshot({ path: `${OUT}/l3-${theme}-section.png` });
if (noticeCount > 0) {
await notice.screenshot({ path: `${OUT}/notice-${theme}.png` });
}
const pass =
Number(headingOpacity) > 0.9 &&
cards === 4 &&
disabledCard === 1 &&
linkHrefs.includes('/methodology') &&
linkHrefs.includes('/team') &&
linkHrefs.includes('/about/brand') &&
noticeCount === 1 &&
(noticeText || '').includes('首批客户共创中');
results.push({
theme,
pass,
hydrated,
headingOpacity,
signalCards: cards,
disabledCard,
linkHrefs,
noticeCount,
noticeText: (noticeText || '').slice(0, 20),
});
}
async function run() {
// --no-proxy-server:本机系统代理会 403 掉 _next/static chunkJS 加载失败 → hydration 不发生)
const browser = await chromium.launch({ args: ['--no-proxy-server'] });
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage();
await probe(page, 'light');
await page.evaluate(() => localStorage.setItem('novalon-theme', 'dark'));
await probe(page, 'dark');
await browser.close();
console.log(JSON.stringify(results, null, 2));
const allPass = results.every((r) => r.pass);
console.log(`\n[result] ${allPass ? 'PASS' : 'FAIL'} (light: ${results[0].pass}, dark: ${results[1]?.pass})`);
if (!allPass) process.exit(1);
}
run().catch((e) => {
console.error(e);
process.exit(1);
});