// tests/onboarding-pages.test.ts
//
// 「上手」两页的机械护栏。
//
// **2026-08-13 迁移**：这两页从静态页（`playground/public/*.html`）搬进站内成为真页面
// （`playground/docs/pages/{Onboarding,ForDevelopers}Page.vue`，见
// `docs/superpowers/specs/2026-08-13-onboarding-in-site-page-design.md`）。
// 本文件的判据**跟着搬**，不是删 —— 它们守的东西一条没变，只是宿主换了：
//   · 站内 `href="#x"` 指向不存在的目标 → 点了不动（现在多一种形态，见下）
//   · 两页互指断了 → 读者掉进死路
//   · 「必须你自己敲」的键盘块数量变了 → 读者会把该自己做的事交给 Claude 代做
//
// 三条**已随搬家消失**的判据，以及为什么不必在别处补：
//   · `data-aud` 取值枚举 —— 身份筛选整套已删（决策①：实测 17 个 data-aud 里 16 个是 `all`，
//     切身份只隐藏 1/13 段，而隐藏段与 shell 的 CONTENTS / 段锚点直接冲突）。属性本身
//     已从模板删除，没有可校的东西了。
//   · `LEGACY` / `MOVED` 的值可解析 —— 这两张表现在住在重定向壳里，由
//     `tests/docs-section-anchor.test.ts` 拿**真实段落 slug 集合**核（比原来更强：原来核的是
//     「值是不是本页某个 id」，现在核的是「值是不是那 16 个 slug 之一」）。不在这里重复一遍。
//   · 静态页正文的措辞红线 —— 正文搬到 .vue，下面的措辞判据直接改读 .vue。
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { slugifySectionTitle } from '../playground/docs/navigation'

const HERE = dirname(fileURLToPath(import.meta.url))
const read = (p: string) => readFileSync(resolve(HERE, '..', p), 'utf8')
const ONB = read('playground/docs/pages/OnboardingPage.vue')
const DEV = read('playground/docs/pages/ForDevelopersPage.vue')
const ONB_SHELL = read('playground/public/onboarding.html')
const DEV_SHELL = read('playground/public/for-developers.html')

const idsOf = (src: string) => new Set([...src.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]))
const titlesOf = (src: string) =>
  [...src.matchAll(/<h2 class="docs-section__title">([^<]+)<\/h2>/g)].map((m) => m[1].trim())

/**
 * 站内页的锚点有**两种**形态，判据必须都认，否则会把好链接判成坏的：
 *   ① `#<模板里真实存在的 id>` —— 例如 h3 上留下的 `#figma-step0`
 *   ② `#section-<段落 slug>` —— 段落的 DOM id 是 `DocsShell.collectContentSections()`
 *      **运行时**加上的（`section-` 前缀 + slug），模板源码里根本不存在这个 id。
 *      所以这一类要拿「本页 h2 派生出的 slug 集合」去核，不是拿模板里的 id 去核。
 *   ③ `#/guide/…` / `#/component/…` —— 那是路由，不是页内锚点，跳过。
 */
function danglingAnchors(src: string) {
  const ids = idsOf(src)
  const slugs = new Set(titlesOf(src).map(slugifySectionTitle))
  return [...src.matchAll(/href="#([^"]+)"/g)]
    .map((m) => m[1])
    .filter((target) => {
      if (!target) return false
      if (target.startsWith('/')) return false // 路由，另有判据
      if (target.startsWith('section-')) return !slugs.has(target.slice('section-'.length))
      return !ids.has(target)
    })
}

describe('站内锚点零悬空（两种形态都核）', () => {
  it('onboarding 页', () => {
    expect(danglingAnchors(ONB)).toEqual([])
  })

  it('for-developers 页', () => {
    expect(danglingAnchors(DEV)).toEqual([])
  })

  it('判据本身认得 `#section-<slug>` 这一形态（否则上面两条会是空过）', () => {
    // Triage 段那两条直达链接正是这一形态；它们必须被算作"有目标"
    expect(ONB).toContain('href="#section-clone"')
    expect(ONB).toContain('href="#section-plugin"')
    // 而一个不存在的 slug 必须被抓出来
    expect(danglingAnchors('<h2 class="docs-section__title">Clone · x</h2><a href="#section-nope">')).toEqual([
      'section-nope',
    ])
  })
})

describe('两类可粘块的约定', () => {
  it('⌨️（Claude 代不了、必须你自己敲的）恰好 3 处', () => {
    // 3 处 = 装 Claude Code（它还不在）· clone 时输 Gitea 凭据 · plugin 报 auth 错时 prime 凭据。
    // 后两处凭据必须从键盘直接进钥匙串、不经过对话记录 —— 数量变了说明这条语义被动了。
    expect((ONB.match(/⌨️/g) ?? []).length).toBe(3)
  })
})

describe('对外措辞红线', () => {
  it('不得把文档站搜索写成全文检索（INFRA-F111 ②a 只做名字过滤）', () => {
    expect(ONB).not.toMatch(/全文检索/)
  })

  it('不得复述「搜索是死的」旧现状', () => {
    expect(ONB).not.toMatch(/搜索.{0,6}(是死的|没用|点了没反应)/)
  })

  it('不得再声称这两页是「独立静态页」—— 它们现在是站内页', () => {
    for (const src of [ONB, DEV]) {
      expect(src).not.toMatch(/独立的?静态页/)
    }
  })
})

describe('两页互指（读者不许掉进死路）', () => {
  it('上手页指向写代码页，写代码页指回上手页 —— 都走站内路由', () => {
    expect(ONB).toContain('#/guide/for-developers')
    expect(DEV).toContain('#/guide/onboarding')
  })

  it('⛔ 正文里不许再出现指向静态页的 `.html` 链接（那会把读者踢出 SPA）', () => {
    for (const src of [ONB, DEV]) {
      expect(src).not.toMatch(/href="\.\/(onboarding|for-developers)\.html/)
    }
  })
})

describe('壳与页的分工（搬家后的形态钉子）', () => {
  it('两个壳都只剩重定向，不含正文', () => {
    for (const shell of [ONB_SHELL, DEV_SHELL]) {
      expect(shell).toContain('location.replace(')
      expect(shell).not.toContain('class="docs-section"')
    }
  })

  it('正文只在页面组件里 —— 段数 13 / 3', () => {
    expect(titlesOf(ONB).length).toBe(13)
    expect(titlesOf(DEV).length).toBe(3)
  })
})
