Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions .claude/skills/audit-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,15 @@ Before writing the report, run `.claude/skills/_shared/verification-protocol.md`
ships only after it survives a challenge, and the sweep must prove it covered the paths
that matter.

1. **Adversarial pass (§2).** For every **Critical / High** finding, spawn an independent
skeptic subagent (3 concurrent) whose only job is to *refute* it — read the cited test
+ source in full context and argue the gap is a false positive (the path is actually
pinned by a test elsewhere, the test *would* fail on a real break, a duplicate, the
severity inflated). Default to refuted when uncertain. Drop or downgrade anything the
skeptic disproves; survivors ship with confidence.
1. **Adversarial pass (§2).** Refute before shipping — but **bound the fan-out** so the
audit stays affordable on a systemically-weak suite (where Critical/High findings can
run to dozens). **Every Critical finding gets its own skeptic; High findings are batched
(one skeptic per ~5, grouped by area) or capped at the top 15 by leverage, the remainder
rolled into Deferred.** Each skeptic (3 concurrent) reads the cited test + source in full
context and argues the gap is a false positive (pinned by a test elsewhere, the test
*would* fail on a real break, a duplicate, the severity inflated). Default to refuted
when uncertain. Drop or downgrade anything the skeptic disproves; survivors ship with
confidence.
2. **Completeness critic + loop-until-dry (§3).** Run a fresh critic asking *"what did this
audit NOT examine — a critical path never mapped, a test area skipped, a suite only
half-read?"* Spawn a focused finder round on each gap it names; repeat until a round
Expand All @@ -139,6 +142,17 @@ mkdir -p .claude/audits
**Rubric**: `.claude/skills/audit-tests/rubric.md` (behavior + edge + failure)
**Verdict**: {1 line — e.g. "Critical paths covered except Play webhook rejection; 3 happy-path-only suites"}

## Suite health — the scale of the rot

> Quantify it so pervasive weakness reads as a number, not something buried in a list of
> individual findings. This is the section that answers "are our tests systemically bad?"

- **Files scored**: {N} of {total}
- **Behavior-only (happy-path)**: {X} ({X/N %})
- **Carry a smell** (rubber-stamp / over-mocked / assertion-free / impl-coupled / snapshot-crutch): {Y} ({Y/N %})
- **Pin behavior on all three axes**: {Z} ({Z/N %})
- **One-line read**: {e.g. "~60% happy-path-only — the green bar is mostly theater" vs "largely healthy; gaps are localized"}

## Critical-path coverage

| Path | Tested? | Quality | Gap |
Expand All @@ -161,11 +175,24 @@ mkdir -p .claude/audits
### Medium — missing edge/failure case off the critical path
{… or "None"}

## Fix first — top 10 by leverage

{The 10 highest-leverage tests to write or rewrite FIRST, ranked — so a systemically-weak
suite is actionable instead of paralyzing. Each: one line · severity · the path it protects.
Drawn from the Critical/High findings below. Fewer than 10 only if the suite is healthy.}

## Concrete tests to add

{A numbered, ready-to-write list. Each: name · file it goes in · arrange/act/assert ·
the factory to use. This is the actionable core — make it copy-pasteable-into-a-task.}

## Tests to delete or rewrite — false safety

{Existing tests to REMOVE or rewrite because they give false safety (rubber-stamp /
assertion-free / tautological / snapshot-as-crutch). Deleting a test that can't fail is a
real, valuable action — it's a liability, not coverage. Each: file:line · which smell ·
delete vs rewrite · if rewrite, the observable assertion that would make it real.}

## Deferred — in scope but not verdicted

{Per the verification protocol §4: paths or test areas the sweep did not score with a
Expand Down Expand Up @@ -203,6 +230,7 @@ reason. "Nothing deferred — full coverage" if the contract was met.}

**Scope**: {what was audited}
**Verdict**: {1-line}
**Suite health**: {X}% happy-path-only · {Y}% smell-carrying · {Z}% pin all three axes (of {N} files scored)

| Severity | Count |
|---|---|
Expand All @@ -211,5 +239,5 @@ reason. "Nothing deferred — full coverage" if the contract was met.}
| Medium (missing edge/failure) | {N} |

**Report**: `.claude/audits/tests-{scope}.md`
**Top gap**: {the single most important test to write first}
**Fix first**: {the single most important test to write or delete first}
```
16 changes: 16 additions & 0 deletions .claude/skills/audit-tests/rubric.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ is **High** or **Critical**; off a critical path it's **Medium**.

---

## Aggregate read & delete-vs-rewrite

The per-test axis scores roll up into the skill's **Suite-health** metric — *what share of the
suite is Behavior-only vs pins all three axes* — so systemic rot reads as a number, not a pile
of individual findings.

A smelled test is not only something to add coverage *around* — it is a **delete-or-rewrite**
action in its own right:
- **Delete** when it can't fail and there's nothing real to assert (assertion-free, tautological,
`expect(mock).toHaveBeenCalled()` with no outcome, a snapshot nobody reads). It is a liability;
removing it removes false safety.
- **Rewrite** when the path *is* worth pinning but the assertion is wrong (asserts internals / call
order → assert the observable outcome instead).

---

## Every finding ships the fix

A finding is not "this test is weak." It is the **concrete test to add or rewrite**:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest'

import { NextRewardCarrot } from '@/app/(tabs)/profile/_components/next-reward-carrot'

const TestRenderer = require('react-test-renderer')

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params) return `${key}:${JSON.stringify(params)}`
return key
},
}),
}))

vi.mock('@/lib/use-app-theme', () => ({
useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }),
}))

vi.mock('@/lib/theme', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/theme')>()
return {
...actual,
createTokensV2: () => ({
primary: '#7c5cff',
primaryPressed: '#6a4ce0',
fgOnPrimary: '#ffffff',
fg1: '#ffffff',
fg2: '#cccccc',
fg3: '#999999',
}),
primaryGlow: () => ({}),
tintFromPrimary: () => 'rgba(124, 92, 255, 0.08)',
}
})

vi.mock('lucide-react-native', () => ({
Lock: () => null,
Sparkles: () => null,
}))

function render(props: Parameters<typeof NextRewardCarrot>[0]) {
let tree: { toJSON: () => unknown; root: { findAll: (predicate: (node: { props?: Record<string, unknown> }) => boolean) => unknown[] } }
TestRenderer.act(() => {
tree = TestRenderer.create(<NextRewardCarrot {...props} />)
})
return tree!
}

function collectText(node: unknown): string {
if (node == null) return ''
if (typeof node === 'string') return node
if (Array.isArray(node)) return node.map(collectText).join(' ')
if (typeof node === 'object' && 'children' in (node as Record<string, unknown>)) {
return collectText((node as { children: unknown }).children)
}
return ''
}

function serialize(tree: { toJSON: () => unknown }) {
return collectText(tree.toJSON())
}

function findUpgradeButton(tree: { root: { findAll: (predicate: (node: { props?: Record<string, unknown> }) => boolean) => unknown[] } }) {
return tree.root.findAll(
(node) =>
!!node.props &&
node.props.accessibilityRole === 'button' &&
typeof node.props.onPress === 'function',
)
}

const baseCarrot = { nextLevel: 4, nextLevelTitle: 'Navigator', xpToNextLevel: 300 }

describe('NextRewardCarrot (mobile)', () => {
it('renders nothing when carrot is null', () => {
const tree = render({ carrot: null, onUpgrade: vi.fn() })
expect(tree.toJSON()).toBeNull()
})

it('shows the next level, XP-to-go, Pro teaser, and upgrade CTA', () => {
const onUpgrade = vi.fn()
const tree = render({ carrot: { ...baseCarrot, showProTeaser: true }, onUpgrade })

const serialized = serialize(tree)
expect(serialized).toContain('gamification.carrot.title'.toUpperCase())
expect(serialized).toContain('gamification.carrot.toNextLevel:{"xp":300,"level":4}')
expect(serialized).toContain('gamification.carrot.proTeaser.title')
expect(serialized).toContain('gamification.carrot.proTeaser.achievements')

const [button] = findUpgradeButton(tree) as Array<{ props: { onPress: () => void } }>
expect(button).toBeTruthy()
TestRenderer.act(() => button!.props.onPress())
expect(onUpgrade).toHaveBeenCalledTimes(1)
})

it('omits the Pro teaser and CTA when showProTeaser is false', () => {
const tree = render({ carrot: { ...baseCarrot, showProTeaser: false }, onUpgrade: vi.fn() })

const serialized = serialize(tree)
expect(serialized).toContain('gamification.carrot.toNextLevel:{"xp":300,"level":4}')
expect(serialized).not.toContain('gamification.carrot.proTeaser.title')
expect(findUpgradeButton(tree)).toHaveLength(0)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,26 @@ import {

describe('OnboardingFlow helpers', () => {
it('keeps pro users on the full step sequence', () => {
expect(getOnboardingDisplayTotal(true)).toBe(6)
expect(getOnboardingDisplayTotal(true)).toBe(7)
expect(getOnboardingDisplayStep(0, true)).toBe(1)
expect(getOnboardingNextStep(2, true)).toBe(3)
expect(getOnboardingPreviousStep(3, true)).toBe(2)
})

it('skips the goal creation step for free users', () => {
expect(getOnboardingDisplayTotal(false)).toBe(5)
expect(getOnboardingNextStep(2, false)).toBe(4)
expect(getOnboardingDisplayStep(4, false)).toBe(4)
expect(getOnboardingPreviousStep(4, false)).toBe(2)
expect(getOnboardingDisplayTotal(false)).toBe(6)
expect(getOnboardingNextStep(3, false)).toBe(5)
expect(getOnboardingDisplayStep(5, false)).toBe(5)
expect(getOnboardingPreviousStep(5, false)).toBe(3)
})

it('hides the footer only on interactive onboarding steps', () => {
expect(shouldHideOnboardingFooter(0)).toBe(false)
expect(shouldHideOnboardingFooter(1)).toBe(true)
expect(shouldHideOnboardingFooter(2)).toBe(true)
expect(shouldHideOnboardingFooter(3)).toBe(true)
expect(shouldHideOnboardingFooter(4)).toBe(false)
expect(shouldHideOnboardingFooter(4)).toBe(true)
expect(shouldHideOnboardingFooter(5)).toBe(false)
expect(shouldHideOnboardingFooter(ONBOARDING_COMPLETE_STEP)).toBe(true)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))

import { OnboardingMeetAstra } from '@/components/onboarding/onboarding-meet-astra'

const TestRenderer = require('react-test-renderer')

describe('OnboardingMeetAstra (mobile)', () => {
it('renders Astra avatar with an accessibility label', async () => {
let tree: any
await TestRenderer.act(async () => {
tree = TestRenderer.create(<OnboardingMeetAstra />)
})
expect(tree.root.findByProps({ accessibilityLabel: 'chat.astraAvatarLabel' })).toBeDefined()
})

it('renders the orbital mark for the hero and the bubble', async () => {
let tree: any
await TestRenderer.act(async () => {
tree = TestRenderer.create(<OnboardingMeetAstra />)
})
expect(tree.root.findAllByType('Svg').length).toBeGreaterThanOrEqual(2)
})
})
Loading