← back to ClawCoder

src/tests/lib/generator.test.ts

168 lines

import { describe, it, expect } from 'vitest'
import { PROFILES, getProfile } from '@/lib/profiles/data'
import { generateClaudeMd, type GeneratorVars } from '@/lib/generator/claude-md'
import { generateRulesFiles } from '@/lib/generator/rules'
import { generateSettingsJson, serializeSettingsJson } from '@/lib/generator/hooks'
import { generateBundle } from '@/lib/generator/bundle'

const testVars: GeneratorVars = {
  projectName: 'TestApp',
  projectDescription: 'A sample test application for unit testing.',
  techStack: 'Next.js, TypeScript, PostgreSQL',
}

describe('Template Rendering', () => {
  it('renders templates without leftover {{}} placeholders', () => {
    for (const profile of PROFILES) {
      const rendered = generateClaudeMd(profile, testVars)
      const leftoverPlaceholders = rendered.match(/\{\{[^}]+\}\}/g)
      expect(
        leftoverPlaceholders,
        `${profile.slug} has leftover placeholders: ${leftoverPlaceholders}`
      ).toBeNull()
    }
  })

  it('includes the project name in rendered output', () => {
    for (const profile of PROFILES) {
      const rendered = generateClaudeMd(profile, testVars)
      expect(
        rendered,
        `${profile.slug} should contain project name`
      ).toContain('TestApp')
    }
  })

  it('includes the project description in rendered output', () => {
    for (const profile of PROFILES) {
      const rendered = generateClaudeMd(profile, testVars)
      expect(
        rendered,
        `${profile.slug} should contain project description`
      ).toContain('A sample test application')
    }
  })

  it('includes the tech stack in rendered output', () => {
    for (const profile of PROFILES) {
      const rendered = generateClaudeMd(profile, testVars)
      expect(
        rendered,
        `${profile.slug} should contain tech stack`
      ).toContain('Next.js, TypeScript, PostgreSQL')
    }
  })
})

describe('Verbosity Progression', () => {
  it('expert template output is shorter than beginner for same age group', () => {
    const ageGroups = ['teen', 'young_adult', 'professional', 'pre_senior', 'senior'] as const

    for (const age of ageGroups) {
      const beginner = PROFILES.find(p => p.ageGroup === age && p.skillLevel === 'beginner')!
      const expert = PROFILES.find(p => p.ageGroup === age && p.skillLevel === 'expert')!

      const beginnerOutput = generateClaudeMd(beginner, testVars)
      const expertOutput = generateClaudeMd(expert, testVars)

      expect(
        expertOutput.length,
        `${age} expert output (${expertOutput.length} chars) should be shorter than beginner (${beginnerOutput.length} chars)`
      ).toBeLessThan(beginnerOutput.length)
    }
  })
})

describe('Rules Generation', () => {
  it('generates rules for intermediate+ profiles that have them', () => {
    // Intermediate and expert profiles should generally have rules files
    const profilesWithRules = PROFILES.filter(p => p.rulesFiles.length > 0)
    expect(profilesWithRules.length).toBeGreaterThan(0)

    for (const profile of profilesWithRules) {
      const rules = generateRulesFiles(profile)
      expect(rules.length).toBeGreaterThan(0)

      for (const rule of rules) {
        expect(rule.path).toBeTruthy()
        expect(rule.content).toBeTruthy()
        expect(rule.path).toContain('.claude/rules/')
      }
    }
  })

  it('beginner profiles have no rules files (or very few)', () => {
    const beginners = PROFILES.filter(p => p.skillLevel === 'beginner')
    for (const profile of beginners) {
      const rules = generateRulesFiles(profile)
      // Beginners should have 0 rules files
      expect(
        rules.length,
        `${profile.slug} (beginner) should have 0 rules files`
      ).toBe(0)
    }
  })
})

describe('Hooks Generation', () => {
  it('generates valid settings JSON', () => {
    for (const profile of PROFILES) {
      const settings = generateSettingsJson(profile)
      const json = serializeSettingsJson(settings)

      // Should be valid JSON
      expect(() => JSON.parse(json)).not.toThrow()
    }
  })

  it('expert profiles with destructive command hooks have proper quoting', () => {
    const expertProfiles = PROFILES.filter(p => p.skillLevel === 'expert')

    for (const profile of expertProfiles) {
      if (profile.hooks.PreToolUse) {
        for (const rule of profile.hooks.PreToolUse) {
          for (const hook of rule.hooks) {
            // If the hook references TOOL_INPUT, it should use quoted form
            if (hook.command.includes('TOOL_INPUT')) {
              expect(
                hook.command,
                `${profile.slug} hook should use quoted "$TOOL_INPUT"`
              ).toContain('"$TOOL_INPUT"')
            }
          }
        }
      }
    }
  })
})

describe('Bundle Generation', () => {
  it('generates a complete bundle for every profile', () => {
    for (const profile of PROFILES) {
      const bundle = generateBundle(profile, testVars)

      expect(bundle.claudeMd, `${profile.slug}: claudeMd`).toBeTruthy()
      expect(bundle.settingsJson, `${profile.slug}: settingsJson`).toBeTruthy()
      expect(bundle.profile.slug, `${profile.slug}: profile.slug`).toBe(profile.slug)
      expect(bundle.profile.displayName, `${profile.slug}: profile.displayName`).toBeTruthy()
      expect(Array.isArray(bundle.rulesFiles), `${profile.slug}: rulesFiles`).toBe(true)

      // claudeMd should not have leftover placeholders
      expect(bundle.claudeMd.match(/\{\{[^}]+\}\}/g)).toBeNull()

      // settingsJson should be valid JSON
      expect(() => JSON.parse(bundle.settingsJson)).not.toThrow()
    }
  })

  it('preserves profile metadata in the bundle', () => {
    const profile = getProfile('professional-expert')!
    const bundle = generateBundle(profile, testVars)

    expect(bundle.profile.slug).toBe('professional-expert')
    expect(bundle.profile.ageGroup).toBe('professional')
    expect(bundle.profile.skillLevel).toBe('expert')
    expect(bundle.profile.displayName).toBe('Professional Expert')
  })
})