import { describe, it, expect } from 'vitest';
import { docParts, deriveTopicGuess, extractTitle, hasSupersededBy, STATUSES, scaffoldEntryObject, findUnregistered } from './design-index.mjs';

describe('docParts', () => {
  it('spec のパスを分解', () => {
    expect(docParts('specs/2026-07-02-foo-design.md')).toEqual({ type: 'spec', date: '2026-07-02', base: '2026-07-02-foo-design', archived: false });
  });
  it('archived plan', () => {
    expect(docParts('archived/plans/2026-03-14-bar.md')).toEqual({ type: 'plan', date: '2026-03-14', base: '2026-03-14-bar', archived: true });
  });
});

describe('deriveTopicGuess', () => {
  it('spec の -design を除く', () => {
    expect(deriveTopicGuess('specs/2026-07-01-workspace-task-search-design.md')).toBe('workspace-task-search');
  });
  it('plan は接尾なし', () => {
    expect(deriveTopicGuess('plans/2026-07-02-workspace-task-search.md')).toBe('workspace-task-search');
  });
  it('redesign を誤って削らない', () => {
    expect(deriveTopicGuess('specs/2026-03-25-memory-redesign.md')).toBe('memory-redesign');
  });
});

describe('extractTitle', () => {
  it('見出しから接尾を除く', () => {
    expect(extractTitle('# ワークスペース内タスク横断検索 実装計画\n\n本文')).toBe('ワークスペース内タスク横断検索');
    expect(extractTitle('# A2A プロトコル対応 — 設計書\n')).toBe('A2A プロトコル対応');
  });
  it('見出し無しは空', () => { expect(extractTitle('本文だけ')).toBe(''); });
});

describe('hasSupersededBy', () => {
  it('null/空はなし', () => { expect(hasSupersededBy(null)).toBe(false); expect(hasSupersededBy('')).toBe(false); });
  it('文字列はあり', () => { expect(hasSupersededBy('specs/x.md')).toBe(true); });
});

it('STATUSES は6値', () => { expect(STATUSES).toHaveLength(6); });

describe('scaffoldEntryObject', () => {
  it('active は unknown, archived は shipped', () => {
    const a = scaffoldEntryObject('plans/2026-07-02-foo.md', '# Foo 実装計画\n');
    expect(a).toMatchObject({ path: 'plans/2026-07-02-foo.md', type: 'plan', topic: 'foo', status: 'unknown', pr: [], superseded_by: null });
    expect(a.title).toBe('Foo');
    const b = scaffoldEntryObject('archived/specs/2026-03-14-bar-design.md', '# Bar — 設計書\n');
    expect(b.status).toBe('shipped');
    expect(b.type).toBe('spec');
  });
});

describe('findUnregistered', () => {
  it('manifest に無いものだけ返す', () => {
    expect(findUnregistered(['a', 'b', 'c'], new Set(['b']))).toEqual(['a', 'c']);
  });
});

import { renderIndex, escapeCell, prUrl, BADGE } from './design-index.mjs';

describe('escapeCell', () => {
  it('パイプと改行を無害化', () => {
    expect(escapeCell('a|b\nc')).toBe('a\\|b c');
  });
});

describe('renderIndex', () => {
  const entries = [
    { path: 'specs/2026-07-01-x-design.md', title: 'X', type: 'spec', topic: 'x', status: 'design', pr: [], superseded_by: null },
    { path: 'plans/2026-07-02-x.md', title: 'X 計画', type: 'plan', topic: 'x', status: 'design', pr: [712], superseded_by: null },
    { path: 'archived/plans/2026-03-14-y.md', title: 'Y', type: 'plan', topic: 'y', status: 'shipped', pr: [], superseded_by: null },
  ];
  const md = renderIndex(entries);
  it('サマリに件数', () => { expect(md).toMatch(/🔵\s*2/); expect(md).toMatch(/🟢\s*1/); });
  it('topic 見出しで spec/plan が同居', () => {
    const xSection = md.slice(md.indexOf('x'));
    expect(xSection).toContain('X');
    expect(xSection).toContain('X 計画');
  });
  it('PR リンクを描く', () => { expect(md).toContain(prUrl(712)); });
  it('archived は別セクション', () => { expect(md).toMatch(/archived|アーカイブ/i); });
  it('決定的（2回同じ）', () => { expect(renderIndex(entries)).toBe(md); });
  it('superseded_by があると後継リンクを描く', () => {
    const supersededEntry = {
      path: 'specs/2026-01-01-old-design.md',
      title: 'Old',
      type: 'spec',
      topic: 'old',
      status: 'superseded',
      pr: [],
      superseded_by: 'specs/2026-01-01-z-design.md',
    };
    const successor = {
      path: 'specs/2026-01-01-z-design.md',
      title: 'Z',
      type: 'spec',
      topic: 'old',
      status: 'shipped',
      pr: [],
      superseded_by: null,
    };
    const out = renderIndex([supersededEntry, successor]);
    expect(out).toContain('[→](./specs/2026-01-01-z-design.md)');
  });
  it('同日・同トピック 2エントリを逆順に渡しても同じ出力（C1 order-independence）', () => {
    const e1 = { path: 'specs/2026-05-01-alpha-design.md', title: 'Alpha', type: 'spec', topic: 'tie', status: 'design', pr: [], superseded_by: null };
    const e2 = { path: 'specs/2026-05-01-beta-design.md', title: 'Beta', type: 'spec', topic: 'tie', status: 'design', pr: [], superseded_by: null };
    expect(renderIndex([e1, e2])).toBe(renderIndex([e2, e1]));
  });
  it('未知の status は ❔ バッジにフォールバック', () => {
    const weirdEntry = { path: 'specs/2026-06-01-foo-design.md', title: 'Foo', type: 'spec', topic: 'foo', status: 'weird', pr: [], superseded_by: null };
    const out = renderIndex([weirdEntry]);
    expect(out).toContain('❔');
  });
});

import { validateManifest } from './design-index.mjs';

describe('validateManifest', () => {
  const ok = { path: 'specs/2026-01-01-a-design.md', title: 'A', type: 'spec', topic: 'a', status: 'shipped', pr: [], superseded_by: null };
  it('整合していれば error 無し', () => {
    const r = validateManifest([ok], ['specs/2026-01-01-a-design.md']);
    expect(r.errors).toEqual([]);
  });
  it('孤児（ファイルはあるが manifest に無い）を error に', () => {
    const r = validateManifest([ok], ['specs/2026-01-01-a-design.md', 'plans/2026-01-02-b.md']);
    expect(r.errors.some((e) => e.includes('2026-01-02-b.md'))).toBe(true);
  });
  it('欠落（manifest にあるがファイルが無い）を error に', () => {
    const r = validateManifest([ok], []);
    expect(r.errors.some((e) => e.includes('does not exist') || e.includes('欠落'))).toBe(true);
  });
  it('不正 status を error に', () => {
    const bad = { ...ok, status: 'done' };
    const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
    expect(r.errors.some((e) => e.includes('status'))).toBe(true);
  });
  it('superseded_by あり + status!=superseded は warning', () => {
    const s = { ...ok, superseded_by: 'specs/2026-01-01-a-design.md' };
    const r = validateManifest([s], ['specs/2026-01-01-a-design.md']);
    expect(r.warnings.some((w) => w.includes('superseded'))).toBe(true);
  });
  it('必須フィールド欠如（title が空）を error に', () => {
    const bad = { ...ok, title: '' };
    const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
    expect(r.errors.some((e) => e.includes("'title'"))).toBe(true);
  });
  it('不正 type を error に', () => {
    const bad = { ...ok, type: 'note' };
    const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
    expect(r.errors.some((e) => e.includes('type'))).toBe(true);
  });
  it('superseded_by が manifest 内に無い path を error に', () => {
    const bad = { ...ok, status: 'superseded', superseded_by: 'specs/9999-nonexistent.md' };
    const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
    expect(r.errors.some((e) => e.includes('superseded_by') && e.includes('9999-nonexistent'))).toBe(true);
  });
  it('status=superseded かつ superseded_by 空は warning', () => {
    const bad = { ...ok, status: 'superseded', superseded_by: null };
    const r = validateManifest([bad], ['specs/2026-01-01-a-design.md']);
    expect(r.warnings.some((w) => w.includes('superseded_by'))).toBe(true);
  });
  it('status=unknown のエントリは warning に件数を含む', () => {
    const u1 = { ...ok, path: 'specs/2026-01-01-a-design.md', status: 'unknown' };
    const u2 = { ...ok, path: 'plans/2026-01-03-c.md', status: 'unknown' };
    const r = validateManifest([u1, u2], ['specs/2026-01-01-a-design.md', 'plans/2026-01-03-c.md']);
    expect(r.warnings.some((w) => w.includes('2') && w.includes('unknown'))).toBe(true);
  });
});
