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
3 changes: 2 additions & 1 deletion .config/mise/conf.d/tasks-quality.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ run = "fallow dupes --config .config/fallow.toml --fail-on-issues"

[tasks."lint:fallow:health"]
description = "Check for new complexity findings against the Fallow baseline"
run = "fallow health --config .config/fallow.toml --complexity --fail-on-issues"
depends = ["test:coverage"]
run = "fallow health --config .config/fallow.toml --complexity --coverage .var/coverage --fail-on-issues"

[tasks."lint:fallow:fix:preview"]
description = "Preview Fallow's unused-export and dependency autofixes"
Expand Down
26 changes: 22 additions & 4 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ Grepping `test|spec|vitest` inside an entity directory will not find its tests
| What you are looking for | Where to look |
| ------------------------------ | -------------------------------------------------------- |
| Integration (end-to-end) tests | `src/app/integration/*.stories.tsx` |
| Page-level component tests | `src/pages/<page>/ui/*.stories.tsx` |
| Entity model coverage | Integration stories above, or add a new story |
| Mock handlers and fixture data | `src/entities/<entity>/mocks/handlers.ts`, `.../data.ts` |
| Current product test coverage | Integration stories above |
| Reusable page actor helpers | `src/pages/<page>/testing.ts` |
| Mock handlers and fixture data | `src/entities/<entity>/mocks/handlers.ts`, `.../data.ts` |
| Actor/helper self-tests | `src/shared/test/actor.test.stories.tsx` |

To find tests for an entity, search for `.test(` in `src/app/integration/` or look for the entity name in story file names.

Expand Down Expand Up @@ -184,10 +184,28 @@ Common valid cases:

- Asserting loading UI appears: `await I.see(role('status', 'Loading ...').wait())`
- Local async transitions without a stable status-exit contract
- Targeted component stories that intentionally wait for post-interaction async recalculation (for example, price list extraction in `src/pages/items/ui/ItemsPage.stories.tsx`)
- Targeted interaction assertions where no stable status-exit contract exists yet (for example, short timer/sidebar countdown checks or other local async UI transitions)

If a loaded-state integration story can be stabilized with `play: () => I.waitExit(role('status'))`, prefer that over locator `.wait()` calls.

### Refactoring opportunity: `src/shared/test/loc.ts`

`src/shared/test/loc.ts` is a useful testing DSL, but it currently carries more factory and dispatch complexity than today's integration stories seem to need.

Current audit notes:

- `.within()`, `.all()`, and targeted `.wait()` usage are actively used by integration stories and should remain first-class.
- `.options()` is valid but lightly used, mostly for accessibility-state assertions such as `current: 'page'` and a few selector escape hatches.
- `.maybe()` appears to be mostly an actor-internal capability used to implement `I.dontSee(...)` and `I.waitExit(...)`, rather than a commonly needed story author API.

Future cleanup should review whether:

- `.maybe()` can become an internal helper instead of a public fluent transition,
- role/text dispatch and option merging can be unified to reduce duplicated branches,
- the public locator API can stay small and biased toward integration-style assertions.

Do not change this API casually: it is shared test infrastructure. Prefer source-backed simplification driven by actual story usage rather than purely aesthetic refactors.

## MSW Structure

Each entity exposes handlers in `src/entities/<entity>/mocks/handlers.ts`:
Expand Down
19 changes: 11 additions & 8 deletions docs/tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ This doc follows the source-first approach in `docs/README.md`.

The project uses Vite+ for frontend tooling, mise for project workflows, and hk for git-hook/file-scoped quality orchestration.

For anything related to format, lint, test execution, or Vite+ behavior, start with `vite.config.ts`. In this repo, that file is the primary source of truth for those tool settings and is meant to be easy for any agent harness to discover.

## Read Source First

| File | Why read it |
| ---------------------------------------- | ---------------------------------------------------- |
| `vite.config.ts` | Vite+, build, format, lint, and test configuration |
| `.config/mise/conf.d/_config.toml` | Tool versions, shared environment, mise defaults |
| `.config/mise/conf.d/tasks-quality.toml` | Quality task wrappers and full validation pipeline |
| `.config/mise/conf.d/tasks-prepare.toml` | Code generation and local setup tasks |
| `.config/hk.pkl` | hk hook/check/fix orchestration |
| `package.json` | Package-manager scripts and Vite+ dependency aliases |
| File | Why read it |
| ---------------------------------------- | ------------------------------------------------------------------------------ |
| `vite.config.ts` | Primary source of truth for Vite+, build, format, lint, and test configuration |
| `.config/mise/conf.d/_config.toml` | Tool versions, shared environment, mise defaults |
| `.config/mise/conf.d/tasks-quality.toml` | Quality task wrappers and full validation pipeline |
| `.config/mise/conf.d/tasks-prepare.toml` | Code generation and local setup tasks |
| `.config/hk.pkl` | hk hook/check/fix orchestration |
| `package.json` | Package-manager scripts and Vite+ dependency aliases |

## Responsibility Split

- `vp` owns frontend tool execution: dev server, build, preview, format, lint, check, and test.
- `vite.config.ts` is the first place to inspect when you need formatter rules, lint rules, plugin wiring, or Vite+ testing behavior.
- `mise` owns named project workflows: prepare/codegen, tests, builds, Fallow, architecture checks, and full validation.
- `hk` owns file-scoped quality orchestration for hooks and fast local validation.

Expand Down
105 changes: 33 additions & 72 deletions src/app/integration/Articles.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import preview from '#.storybook/preview'
import { App } from '#app/App'
import { articleDetail, articleList } from '#entities/article/mocks/handlers'
import { articlesActor as I } from '#pages/articles/testing'
import { button, heading, link, role, text } from '#shared/test'
import { heading, link, role, text } from '#shared/test'

const meta = preview.meta({
title: 'Integration/Articles',
Expand All @@ -19,7 +19,7 @@ export const Default = meta.story({
})

Default.test('renders article list with no selection message', async () => {
await I.see(text('No article selected').within(role('main')))
await I.seeNoSelection()
await I.seeArticleList()
await I.seeStatusBadges()
})
Expand All @@ -34,52 +34,35 @@ Default.test('shows article descriptions in list items', async () => {
})

Default.test('shows article detail when article is clicked', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetail('Quarterly report')
})

Default.test('shows all content paragraphs in article detail', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))

await I.scope(role('main'), async () => {
await I.see(heading('Quarterly report'))
await I.see(text(/Regional performance remained strongest/))
await I.see(text(/EMEA showed stable retention/))
await I.see(text(/APAC growth accelerated/))
await I.see(text(/Gross margin improved/))
await I.see(text(/next planning cycle should prioritize/))
})
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetail('Quarterly report')
await I.seeArticleDetailContent()
})

Default.test('shows edit button and status badge in article detail', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))

await I.scope(role('main'), async () => {
await I.see(button('Edit'))
await I.see(text('Done'))
})
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetail('Quarterly report')
await I.seeArticleDetailStatus('Done')
})

Default.test('shows article description in detail view', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))

await I.scope(role('main'), async () => {
await I.see(text('Revenue overview and growth metrics for Q3 across all regions.'))
})
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetailDescription(
'Revenue overview and growth metrics for Q3 across all regions.',
)
})

Default.test('can select different articles', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.see(heading('Quarterly report'))
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetail('Quarterly report')

await I.click(link(/Hiring plan/i))
await I.waitExit(role('status'))
await I.see(heading('Hiring plan'))
await I.openArticle(/Hiring plan/i)
await I.seeArticleDetail('Hiring plan')
})

export const DirectUrlNavigation = meta.story({
Expand All @@ -90,10 +73,7 @@ export const DirectUrlNavigation = meta.story({

DirectUrlNavigation.test('loads article detail directly from URL', async () => {
await I.seeArticleDetail('Quarterly report')

await I.scope(role('main'), async () => {
await I.see(text(/Regional performance remained strongest/))
})
await I.seeArticleDetailContent()
})

export const DirectUrlNotFound = meta.story({
Expand Down Expand Up @@ -134,39 +114,28 @@ DefaultMobile.test('[mobile] shows search toolbar with new article button', asyn
})

DefaultMobile.test('[mobile] shows article detail when article is clicked', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.see(heading('Quarterly report'))
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetail('Quarterly report')
})

DefaultMobile.test('[mobile] shows all content paragraphs in article detail', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))

await I.scope(role('main'), async () => {
await I.see(heading('Quarterly report'))
await I.see(text(/Regional performance remained strongest/))
await I.see(text(/EMEA showed stable retention/))
await I.see(text(/APAC growth accelerated/))
await I.see(text(/Gross margin improved/))
await I.see(text(/next planning cycle should prioritize/))
})
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetail('Quarterly report')
await I.seeArticleDetailContent()
})

DefaultMobile.test('[mobile] displays correct status badges for different statuses', async () => {
await I.seeStatusBadges()
})

DefaultMobile.test('[mobile] can select different articles', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.see(heading('Quarterly report'))
await I.openArticle(/Quarterly report/i)
await I.seeArticleDetail('Quarterly report')

await I.goBack()

await I.click(link(/Hiring plan/i))
await I.waitExit(role('status'))
await I.see(heading('Hiring plan'))
await I.openArticle(/Hiring plan/i)
await I.seeArticleDetail('Hiring plan')
})

export const HandlesArticlesLoadServerError = meta.story({
Expand Down Expand Up @@ -266,8 +235,7 @@ export const HandlesArticleDetailServerError = meta.story({
HandlesArticleDetailServerError.test(
'shows error state when article detail request fails',
async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.openArticle(/Quarterly report/i)

await I.scope(role('main'), async () => {
await I.seeDetailError()
Expand All @@ -276,8 +244,7 @@ HandlesArticleDetailServerError.test(
)

HandlesArticleDetailServerError.test('keeps detail error state when retry also fails', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.openArticle(/Quarterly report/i)

await I.scope(role('main'), async () => {
await I.seeDetailError()
Expand All @@ -298,8 +265,7 @@ export const RecoversAfterArticleDetailRetry = meta.story({
})

RecoversAfterArticleDetailRetry.test('loads article detail after retry succeeds', async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.openArticle(/Quarterly report/i)

await I.scope(role('main'), async () => {
await I.seeDetailError()
Expand All @@ -320,8 +286,7 @@ export const HandlesArticleDetailServerErrorMobile = meta.story({
HandlesArticleDetailServerErrorMobile.test(
'[mobile] shows error state when article detail request fails',
async () => {
await I.click(link(/Quarterly report/i))
await I.waitExit(role('status'))
await I.openArticle(/Quarterly report/i)

await I.scope(role('main'), async () => {
await I.seeDetailError()
Expand All @@ -345,9 +310,7 @@ KeepsLoadingWhenArticleDetailNeverResolves.test(
await I.click(link(/Quarterly report/i))

const detail = await I.see(role('main'))
await I.see(role('status', 'Loading article detail').within(detail))
await I.dontSee(heading('Quarterly report').within(detail))
await I.dontSee(text('Article not found').within(detail))
await I.seeDetailLoading(detail)
},
)

Expand All @@ -364,8 +327,6 @@ KeepsLoadingWhenArticleDetailNeverResolvesMobile.test(
await I.click(link(/Quarterly report/i))

const detail = await I.see(role('main'))
await I.see(role('status', 'Loading article detail').within(detail))
await I.dontSee(heading('Quarterly report').within(detail))
await I.dontSee(text('Article not found').within(detail))
await I.seeDetailLoading(detail)
},
)
74 changes: 74 additions & 0 deletions src/app/integration/Calculator.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,80 @@ Default.test('renders calculator buttons', async () => {
await I.seeCalculatorContent()
})

Default.test('performs basic addition: 7 + 5 = 12', async () => {
await I.press('7')
await I.seeDisplay('7')
await I.pressMany('+', '5', '=')
await I.seeDisplay('12')
})

Default.test('performs basic subtraction: 9 − 4 = 5', async () => {
await I.pressMany('9', '−', '4', '=')
await I.seeDisplay('5')
})

Default.test('performs basic multiplication: 6 × 3 = 18', async () => {
await I.pressMany('6', '×', '3', '=')
await I.seeDisplay('18')
})

Default.test('performs basic division: 8 ÷ 2 = 4', async () => {
await I.pressMany('8', '÷', '2', '=')
await I.seeDisplay('4')
})

Default.test('handles decimal point', async () => {
await I.pressMany('1', '.', '5')
await I.seeDisplay('1.5')
await I.press('.') // Should do nothing
await I.seeDisplay('1.5')
await I.pressMany('×', '2', '=')
await I.seeDisplay('3')
})

Default.test('handles decimal point after operator', async () => {
await I.pressMany('5', '+', '.')
await I.seeDisplay('0.')
await I.pressMany('2', '=')
await I.seeDisplay('5.2')
})

Default.test('toggles sign: 5 to -5', async () => {
await I.pressMany('5', '+/−')
await I.seeDisplay('-5')
await I.press('+/−')
await I.seeDisplay('5')
})

Default.test('calculates percentage: 50% = 0.5', async () => {
await I.pressMany('5', '0', '%')
await I.seeDisplay('0.5')
})

Default.test('clears display with AC', async () => {
await I.pressMany('1', '2', '3')
await I.seeDisplay('123')
await I.press('AC')
await I.seeDisplay('0')
})

Default.test('handles consecutive operations', async () => {
await I.pressMany('5', '+', '5', '+')
await I.seeDisplay('10')
await I.pressMany('2', '=')
await I.seeDisplay('12')
})

Default.test('division by zero shows Error', async () => {
await I.pressMany('5', '÷', '0', '=')
await I.seeDisplay('Error')
})

Default.test('error persists when operator is pressed after division by zero', async () => {
await I.pressMany('5', '÷', '0', '+')
await I.seeDisplay('Error')
})

export const DefaultMobile = meta.story({
name: 'Default (Mobile)',
globals: { viewport: { value: 'sm', isRotated: false } },
Expand Down
Loading
Loading