From 2452d6be3be93880076f2c66ca6a6b460145aa9f Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Thu, 28 May 2026 05:12:21 +0300 Subject: [PATCH 1/3] Refactor timer tests to use new duration checking methods and improve readability - Updated timer tests to replace direct text checks with `seeDuration` for consistency. - Refactored custom duration input tests to use dedicated methods for entering values. - Simplified preset selection tests by introducing `choosePreset` method. - Enhanced timer actor with new methods for starting, pausing, and resetting the timer. - Removed deprecated story files for articles and calculator components. - Added new utility methods in items testing for filtering and checking item visibility. - Improved the TimerPage component to manage button states based on timer status. --- docs/testing.md | 26 +++- src/app/integration/Articles.stories.tsx | 105 +++++--------- src/app/integration/Calculator.stories.tsx | 74 ++++++++++ src/app/integration/Items.stories.tsx | 88 ++++++++++++ src/app/integration/Timer.stories.tsx | 86 +++++------ src/pages/articles/testing.ts | 31 ++++ .../ui/ArticleStatusBadge.stories.tsx | 42 ------ .../ui/ArticlesPageLoading.stories.tsx | 53 ------- .../ui/detail/ArticleDetail.stories.tsx | 99 ------------- .../ArticleDetailLoadingState.stories.tsx | 21 --- .../ui/detail/ArticleNoSelection.stories.tsx | 22 --- .../ui/detail/ArticleNotFound.stories.tsx | 25 ---- .../articles/ui/list/ArticleList.stories.tsx | 81 ----------- .../ui/list/ArticleListItem.stories.tsx | 97 ------------- .../ui/list/ArticleListLoading.stories.tsx | 13 -- src/pages/calculator/testing.ts | 15 +- .../calculator/ui/CalculatorPage.stories.tsx | 135 ------------------ src/pages/items/testing.ts | 28 ++++ src/pages/items/ui/ItemsPage.stories.tsx | 132 ----------------- src/pages/timer/testing.ts | 44 +++++- src/pages/timer/ui/TimerPage.stories.tsx | 65 --------- src/pages/timer/ui/TimerPage.tsx | 17 ++- 22 files changed, 379 insertions(+), 920 deletions(-) delete mode 100644 src/pages/articles/ui/ArticleStatusBadge.stories.tsx delete mode 100644 src/pages/articles/ui/ArticlesPageLoading.stories.tsx delete mode 100644 src/pages/articles/ui/detail/ArticleDetail.stories.tsx delete mode 100644 src/pages/articles/ui/detail/ArticleDetailLoadingState.stories.tsx delete mode 100644 src/pages/articles/ui/detail/ArticleNoSelection.stories.tsx delete mode 100644 src/pages/articles/ui/detail/ArticleNotFound.stories.tsx delete mode 100644 src/pages/articles/ui/list/ArticleList.stories.tsx delete mode 100644 src/pages/articles/ui/list/ArticleListItem.stories.tsx delete mode 100644 src/pages/articles/ui/list/ArticleListLoading.stories.tsx delete mode 100644 src/pages/calculator/ui/CalculatorPage.stories.tsx delete mode 100644 src/pages/items/ui/ItemsPage.stories.tsx delete mode 100644 src/pages/timer/ui/TimerPage.stories.tsx diff --git a/docs/testing.md b/docs/testing.md index bec0650..d019ea7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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//ui/*.stories.tsx` | -| Entity model coverage | Integration stories above, or add a new story | -| Mock handlers and fixture data | `src/entities//mocks/handlers.ts`, `.../data.ts` | +| Current product test coverage | Integration stories above | | Reusable page actor helpers | `src/pages//testing.ts` | +| Mock handlers and fixture data | `src/entities//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. @@ -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//mocks/handlers.ts`: diff --git a/src/app/integration/Articles.stories.tsx b/src/app/integration/Articles.stories.tsx index 476d883..54deb86 100644 --- a/src/app/integration/Articles.stories.tsx +++ b/src/app/integration/Articles.stories.tsx @@ -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', @@ -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() }) @@ -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({ @@ -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({ @@ -134,23 +114,14 @@ 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 () => { @@ -158,15 +129,13 @@ DefaultMobile.test('[mobile] displays correct status badges for different status }) 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({ @@ -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() @@ -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() @@ -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() @@ -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() @@ -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) }, ) @@ -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) }, ) diff --git a/src/app/integration/Calculator.stories.tsx b/src/app/integration/Calculator.stories.tsx index d5ad2cf..9f5d7e5 100644 --- a/src/app/integration/Calculator.stories.tsx +++ b/src/app/integration/Calculator.stories.tsx @@ -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 } }, diff --git a/src/app/integration/Items.stories.tsx b/src/app/integration/Items.stories.tsx index 6f7e398..f9e5686 100644 --- a/src/app/integration/Items.stories.tsx +++ b/src/app/integration/Items.stories.tsx @@ -1,3 +1,5 @@ +import { expect } from 'storybook/test' + import preview from '#.storybook/preview' import { App } from '#app/App' import { itemDetail, itemList } from '#entities/item/mocks/handlers' @@ -213,3 +215,89 @@ KeepsLoadingWhenItemsRequestNeverResolvesMobile.test( await I.seeLoading() }, ) + +export const FilteredByCategory = meta.story({ + name: 'Filtered by Category', + play: () => I.waitExit(role('status')), +}) + +FilteredByCategory.test('shows only electronics items', async () => { + await I.applyCategoryFilter('Electronics') + await I.seeOnlyItems('Wireless Headphones', 'Mechanical Keyboard') + await I.dontSeeItem('Standing Desk') + await I.dontSeeItem('Merino Wool Sweater') +}) + +FilteredByCategory.test('shows only food items', async () => { + await I.applyCategoryFilter('Food') + await I.seeOnlyItems('Organic Coffee Beans') + await I.dontSeeItem('Wireless Headphones') +}) + +FilteredByCategory.test('item availability changes with category filter', async () => { + expect(await I.tryTo(() => I.seeItem('Wireless Headphones'))).toBe(true) + expect(await I.tryTo(() => I.seeItem('Non-existent Product'))).toBe(false) + + await I.applyCategoryFilter('Food') + expect(await I.tryTo(() => I.seeItem('Wireless Headphones'))).toBe(false) + expect(await I.tryTo(() => I.seeItem('Organic Coffee Beans'))).toBe(true) +}) + +export const FilteredByStock = meta.story({ + name: 'Filtered by Stock', + play: () => I.waitExit(role('status')), +}) + +FilteredByStock.test('shows only in-stock items', async () => { + await I.applyStockFilter('In Stock') + await I.seeOnlyItems('Wireless Headphones', 'Standing Desk') + await I.dontSeeItem('Merino Wool Sweater') +}) + +FilteredByStock.test('shows only out-of-stock items', async () => { + await I.applyStockFilter('Out of Stock') + await I.seeOnlyItems('Merino Wool Sweater', 'Ergonomic Chair') + await I.dontSeeItem('Wireless Headphones') +}) + +export const FilteredToEmpty = meta.story({ + name: 'No Matching Items', + play: () => I.waitExit(role('status')), +}) + +FilteredToEmpty.test('shows empty state message', async () => { + await I.applyCategoryFilter('Electronics') + await I.applyStockFilter('Out of Stock') + await I.see(text('No items match the current filters.')) +}) + +FilteredToEmpty.test('verifies multiple items across filter states', async () => { + expect(await I.hopeThat(() => I.seeItem('Wireless Headphones'))).toBe(true) + expect(await I.hopeThat(() => I.seeItem('Standing Desk'))).toBe(true) + I.hopeThat.noErrors() + + await I.applyCategoryFilter('Electronics') + await I.applyStockFilter('Out of Stock') + expect(await I.hopeThat(() => I.seeItem('Wireless Headphones'))).toBe(false) + expect(() => I.hopeThat.noErrors()).toThrow(/soft assertion/) +}) + +export const SortedByPrice = meta.story({ + name: 'Sorted by Price', + play: () => I.waitExit(role('status')), +}) + +SortedByPrice.test('sorts ascending by default', async () => { + await I.applyPriceSort() + const prices = await I.grabVisiblePrices() + const sortedPrices = [...prices].sort((a, b) => a - b) + expect(prices).toEqual(sortedPrices) +}) + +SortedByPrice.test('sorts descending after toggle', async () => { + await I.applyPriceSort() + await I.toggleSortDirection() + const prices = await I.grabVisiblePrices() + const sortedPrices = [...prices].sort((a, b) => b - a) + expect(prices).toEqual(sortedPrices) +}) diff --git a/src/app/integration/Timer.stories.tsx b/src/app/integration/Timer.stories.tsx index 05e533f..142b2f5 100644 --- a/src/app/integration/Timer.stories.tsx +++ b/src/app/integration/Timer.stories.tsx @@ -19,7 +19,7 @@ Default.test('renders timer heading', async () => { }) Default.test('renders default timer display', async () => { - await I.see(text('05:00')) + await I.seeDuration('05:00') }) Default.test('renders start and reset buttons', async () => { @@ -32,20 +32,20 @@ Default.test('renders preset buttons', async () => { }) Default.test('renders custom duration input', async () => { - await I.see(role('textbox')) + await I.see(loc.customInput) }) export const StartPauseReset = meta.story({ name: 'Start Pause Reset' }) StartPauseReset.test('starts, pauses, and resets the timer', async () => { - await I.see(text('05:00')) + await I.seeDuration('05:00') - await I.click(loc.startButton) + await I.start() await I.see(loc.pauseButton) await I.dontSee(button('Start')) - await I.click(loc.resetButton) - await I.see(text('05:00')) + await I.reset() + await I.seeDuration('05:00') await I.see(loc.startButton) await I.dontSee(button('Pause')) }) @@ -53,50 +53,47 @@ StartPauseReset.test('starts, pauses, and resets the timer', async () => { export const PresetChangesDuration = meta.story({ name: 'Preset Changes Duration' }) PresetChangesDuration.test('clicking a preset changes the displayed duration', async () => { - await I.click(loc.preset10s) - await I.see(text('00:10')) + await I.choosePreset('10s') + await I.seeDuration('00:10') - await I.click(loc.preset1m) - await I.see(text('01:00')) + await I.choosePreset('1m') + await I.seeDuration('01:00') - await I.click(loc.preset5m) - await I.see(text('05:00')) + await I.choosePreset('5m') + await I.seeDuration('05:00') - await I.click(loc.preset10m) - await I.see(text('10:00')) + await I.choosePreset('10m') + await I.seeDuration('10:00') - await I.click(loc.preset25m) - await I.see(text('25:00')) + await I.choosePreset('25m') + await I.seeDuration('25:00') }) export const CustomDurationInput = meta.story({ name: 'Custom Duration Input' }) CustomDurationInput.test('entering MM:SS commits via blur', async () => { - await I.fill(role('textbox'), '00:15') - await I.see(text('00:15')) + await I.enterCustomDurationByBlur('00:15') + await I.seeDuration('00:15') }) CustomDurationInput.test('entering MM:SS commits via Enter key', async () => { - const input = role('textbox') - await I.click(input) - await I.press('00:20') - await I.press('[Enter]') - await I.see(text('00:20')) + await I.enterCustomDurationByEnter('00:20') + await I.seeDuration('00:20') }) CustomDurationInput.test('entering invalid input does not change duration', async () => { - await I.click(loc.preset10s) - await I.see(text('00:10')) - await I.clear(role('textbox')) - await I.fill(role('textbox'), '00:00') + await I.choosePreset('10s') + await I.seeDuration('00:10') + await I.clearCustomDuration() + await I.enterCustomDurationByBlur('00:00') // Duration should not change — still 00:10 from the preset - await I.see(text('00:10')) + await I.seeDuration('00:10') }) export const PresetsDisabledWhileRunning = meta.story({ name: 'Presets Disabled While Running' }) PresetsDisabledWhileRunning.test('preset buttons are disabled while timer is running', async () => { - await I.click(loc.startButton) + await I.start() await I.seeDisabled(loc.preset10s) }) @@ -104,21 +101,12 @@ PresetsDisabledWhileRunning.test('preset buttons are disabled while timer is run export const TimerReachesZero = meta.story({ name: 'Timer Reaches Zero' }) TimerReachesZero.test('start button is disabled after timer reaches zero', async () => { - await I.fill(role('textbox'), '00:01') - await I.see(text('00:01')) + await I.enterCustomDurationByBlur('00:01') + await I.seeDuration('00:01') - await I.click(loc.startButton) + await I.start() await I.see(loc.pauseButton) - - await I.retryTo( - async () => { - const found = await I.tryTo(() => I.see(text('00:00'))) - if (!found) throw new Error('waiting for zero') - }, - 5, - 500, - ) - + await I.waitForDuration('00:00') await I.seeDisabled(loc.startButton) }) @@ -129,9 +117,9 @@ export const TimerTicksInSidebarOnOtherRoute = meta.story({ TimerTicksInSidebarOnOtherRoute.test( 'timer counts down in sidebar after navigating away', async () => { - await I.click(loc.preset10s) - await I.see(text('00:10')) - await I.click(loc.startButton) + await I.choosePreset('10s') + await I.seeDuration('00:10') + await I.start() await I.see(loc.pauseButton) await I.click(link('Dashboard')) @@ -164,12 +152,12 @@ export const StartPauseResetMobile = meta.story({ }) StartPauseResetMobile.test('[mobile] starts, pauses, and resets the timer', async () => { - await I.see(text('05:00')) + await I.seeDuration('05:00') - await I.click(loc.startButton) + await I.start() await I.see(loc.pauseButton) - await I.click(loc.resetButton) - await I.see(text('05:00')) + await I.reset() + await I.seeDuration('05:00') await I.see(loc.startButton) }) diff --git a/src/pages/articles/testing.ts b/src/pages/articles/testing.ts index 366ba1b..a33ba77 100644 --- a/src/pages/articles/testing.ts +++ b/src/pages/articles/testing.ts @@ -37,6 +37,13 @@ export const articlesActor = createActor() goBack: async () => { await I.click((canvas) => canvas.findByLabelText('Back to articles')) }, + openArticle: async (name: string | RegExp) => { + await I.click(link(name)) + await I.waitExit(role('status')) + }, + seeNoSelection: async () => { + await I.see(text('No article selected').within(role('main'))) + }, seeArticleList: async () => { await I.scope(role('list', 'Articles'), async () => { await Promise.all(ARTICLE_LINKS.map((name) => I.see(link(name)))) @@ -58,6 +65,25 @@ export const articlesActor = createActor() await I.see(heading(title)) await I.see(button('Edit')) }, + seeArticleDetailContent: async () => { + await I.scope(role('main'), async () => { + 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/)) + }) + }, + seeArticleDetailDescription: async (value: string | RegExp) => { + await I.scope(role('main'), async () => { + await I.see(text(value)) + }) + }, + seeArticleDetailStatus: async (status: string) => { + await I.scope(role('main'), async () => { + await I.see(text(status)) + }) + }, seeArticleNotFound: async (articleId: string) => { await I.see(heading('Article not found')) await I.see(text(`No article exists for id "${articleId}".`)) @@ -65,4 +91,9 @@ export const articlesActor = createActor() seeArticleDescription: async (pattern: RegExp) => { await I.see(text(pattern)) }, + seeDetailLoading: async (detail: HTMLElement) => { + 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)) + }, })) diff --git a/src/pages/articles/ui/ArticleStatusBadge.stories.tsx b/src/pages/articles/ui/ArticleStatusBadge.stories.tsx deleted file mode 100644 index 2334d02..0000000 --- a/src/pages/articles/ui/ArticleStatusBadge.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import preview from '#.storybook/preview' -import { createActor, text } from '#shared/test' - -import { ArticleStatusBadge } from './ArticleStatusBadge' - -const I = createActor() - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleStatusBadge', - component: ArticleStatusBadge, - parameters: { layout: 'centered' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Draft = meta.story({ - name: 'Draft', - args: { status: 'draft' }, -}) - -Draft.test('renders draft badge', async () => { - await I.see(text('Draft')) -}) - -export const InProgress = meta.story({ - name: 'In Progress', - args: { status: 'in-progress' }, -}) - -InProgress.test('renders in-progress badge', async () => { - await I.see(text('In Progress')) -}) - -export const Done = meta.story({ - name: 'Done', - args: { status: 'done' }, -}) - -Done.test('renders done badge', async () => { - await I.see(text('Done')) -}) diff --git a/src/pages/articles/ui/ArticlesPageLoading.stories.tsx b/src/pages/articles/ui/ArticlesPageLoading.stories.tsx deleted file mode 100644 index c5722ba..0000000 --- a/src/pages/articles/ui/ArticlesPageLoading.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import preview from '#.storybook/preview' -import { createActor, role } from '#shared/test' - -import { ArticlesPageLoading } from './ArticlesPageLoading' - -const I = createActor() - -const meta = preview.meta({ - title: 'Pages/Articles/ArticlesPageLoading', - component: ArticlesPageLoading, - parameters: { layout: 'fullscreen' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const WithoutDetail = meta.story({ - name: 'Without Detail', - args: { showDetail: false }, -}) - -WithoutDetail.test('renders page loading skeleton', async () => { - await I.see(role('status', 'Loading articles page')) -}) - -export const WithDetail = meta.story({ - name: 'With Detail', - args: { showDetail: true }, -}) - -WithDetail.test('renders page loading skeleton with detail panel', async () => { - await I.see(role('status', 'Loading articles page')) -}) - -export const WithoutDetailMobile = meta.story({ - name: 'Without Detail (Mobile)', - args: { showDetail: false }, - globals: { viewport: { value: 'sm', isRotated: false } }, -}) - -WithoutDetailMobile.test('[mobile] renders page loading skeleton', async () => { - await I.see(role('status', 'Loading articles page')) -}) - -export const WithDetailMobile = meta.story({ - name: 'With Detail (Mobile)', - args: { showDetail: true }, - globals: { viewport: { value: 'sm', isRotated: false } }, -}) - -WithDetailMobile.test('[mobile] renders page loading skeleton with detail', async () => { - await I.see(role('status', 'Loading articles page')) -}) diff --git a/src/pages/articles/ui/detail/ArticleDetail.stories.tsx b/src/pages/articles/ui/detail/ArticleDetail.stories.tsx deleted file mode 100644 index c13bffd..0000000 --- a/src/pages/articles/ui/detail/ArticleDetail.stories.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { Article } from '#entities/article' - -import preview from '#.storybook/preview' -import { button, createActor, heading, text } from '#shared/test' - -import { ArticleDetail } from './ArticleDetail' - -const I = createActor() - -const doneArticle = { - id: '1', - title: 'Quarterly report', - description: 'Revenue overview and growth metrics for Q3 across all regions.', - status: 'done', - content: [ - 'Regional performance remained strongest in North America, where subscription renewals outpaced forecast by 6%.', - 'EMEA showed stable retention but slower new-customer acquisition in mid-market accounts due to longer procurement cycles.', - 'APAC growth accelerated in the second half of the quarter after onboarding two strategic channel partners.', - 'Gross margin improved as infrastructure costs declined after database workload rebalancing in production.', - 'The next planning cycle should prioritize conversion optimization in self-serve and pricing tests for annual plans.', - ], -} satisfies Article - -const inProgressArticle = { - id: '2', - title: 'Hiring plan', - description: 'Engineering headcount proposal for the next two quarters.', - status: 'in-progress', - content: [ - 'The proposal focuses on backend platform capacity first, then a second wave for product-facing full-stack teams.', - 'Staffing assumptions are based on current attrition trends and expected onboarding throughput from the recruiting team.', - ], -} satisfies Article - -const draftArticle = { - id: '3', - title: 'Roadmap draft', - description: 'Feature priorities and timeline estimates for the next product cycle.', - status: 'draft', - content: [ - 'The roadmap draft groups work into reliability, onboarding, and collaboration themes to reduce parallel complexity.', - ], -} satisfies Article - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleDetail', - component: ArticleDetail, - parameters: { layout: 'padded' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Done = meta.story({ - name: 'Done Article', - args: { article: doneArticle }, -}) - -Done.test('renders article title and status', async () => { - await I.see(heading('Quarterly report')) - await I.see(text('Done')) -}) - -Done.test('renders article description', async () => { - await I.see(text('Revenue overview and growth metrics for Q3 across all regions.')) -}) - -Done.test('renders all content paragraphs', async () => { - 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/)) -}) - -Done.test('shows edit button', async () => { - await I.see(button('Edit')) -}) - -export const InProgress = meta.story({ - name: 'In Progress Article', - args: { article: inProgressArticle }, -}) - -InProgress.test('renders in-progress article with badge', async () => { - await I.see(heading('Hiring plan')) - await I.see(text('In Progress')) - await I.see(text('Engineering headcount proposal for the next two quarters.')) -}) - -export const Draft = meta.story({ - name: 'Draft Article', - args: { article: draftArticle }, -}) - -Draft.test('renders draft article with badge', async () => { - await I.see(heading('Roadmap draft')) - await I.see(text('Draft')) -}) diff --git a/src/pages/articles/ui/detail/ArticleDetailLoadingState.stories.tsx b/src/pages/articles/ui/detail/ArticleDetailLoadingState.stories.tsx deleted file mode 100644 index f178c79..0000000 --- a/src/pages/articles/ui/detail/ArticleDetailLoadingState.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import preview from '#.storybook/preview' -import { createActor, role } from '#shared/test' - -import { ArticleDetailLoadingState } from './ArticleDetailLoadingState' - -const I = createActor() - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleDetailLoadingState', - component: ArticleDetailLoadingState, - parameters: { layout: 'padded' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ name: 'Default' }) - -Default.test('renders loading skeleton with accessible status', async () => { - await I.see(role('status', 'Loading article detail')) -}) diff --git a/src/pages/articles/ui/detail/ArticleNoSelection.stories.tsx b/src/pages/articles/ui/detail/ArticleNoSelection.stories.tsx deleted file mode 100644 index ca38c32..0000000 --- a/src/pages/articles/ui/detail/ArticleNoSelection.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import preview from '#.storybook/preview' -import { createActor, text } from '#shared/test' - -import { ArticleNoSelection } from './ArticleNoSelection' - -const I = createActor() - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleNoSelection', - component: ArticleNoSelection, - parameters: { layout: 'centered' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ name: 'Default' }) - -Default.test('renders no-selection state', async () => { - await I.see(text('No article selected')) - await I.see(text('Choose an article from the list to view its content.')) -}) diff --git a/src/pages/articles/ui/detail/ArticleNotFound.stories.tsx b/src/pages/articles/ui/detail/ArticleNotFound.stories.tsx deleted file mode 100644 index 261b5ae..0000000 --- a/src/pages/articles/ui/detail/ArticleNotFound.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import preview from '#.storybook/preview' -import { createActor, heading, text } from '#shared/test' - -import { ArticleNotFound } from './ArticleNotFound' - -const I = createActor() - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleNotFound', - component: ArticleNotFound, - parameters: { layout: 'centered' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ - name: 'Default', - args: { articleId: 'nonexistent-42' }, -}) - -Default.test('renders not found state with article id', async () => { - await I.see(heading('Article not found')) - await I.see(text(/No article exists for id "nonexistent-42"/)) -}) diff --git a/src/pages/articles/ui/list/ArticleList.stories.tsx b/src/pages/articles/ui/list/ArticleList.stories.tsx deleted file mode 100644 index 58a7d1b..0000000 --- a/src/pages/articles/ui/list/ArticleList.stories.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { expect } from 'storybook/test' - -import preview from '#.storybook/preview' -import { articlesMockData } from '#entities/article/mocks/data' -import { m } from '#paraglide/messages.js' -import { button, createActor, link, role, text } from '#shared/test' - -import { ArticleList } from './ArticleList' - -const I = createActor() - -const articles = articlesMockData.map((article) => ({ - article, - href: `/articles/${article.id}`, -})) - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleList', - component: ArticleList, - args: { - articles, - selectedId: undefined, - }, - parameters: { layout: 'padded' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ name: 'Default' }) - -Default.test('renders all articles with correct titles', async () => { - await I.seeNumberOfElements(role('listitem').all(), 20) - - await I.scope(role('list', 'Articles'), async () => { - await I.see(link(/Quarterly report/i)) - await I.see(link(/Hiring plan/i)) - await I.see(link(/Roadmap draft/i)) - await I.see(link(/Security audit/i)) - await I.see(link(/Design system update/i)) - - const titles = await I.grabTextFromAll(link().all()) - expect(titles).toHaveLength(20) - expect(titles[0]).toContain('Quarterly report') - }) -}) - -Default.test('renders toolbar with search, filters, and new article button', async () => { - await I.see((canvas) => canvas.getByPlaceholderText(m.article_search_placeholder())) - await I.see(button('Filters')) - await I.see(button('New article')) -}) - -Default.test('displays status badges across articles', async () => { - await I.see(text('Done').all()) - await I.see(text('In Progress').all()) - await I.see(text('Draft').all()) -}) - -Default.test('displays article descriptions', async () => { - await I.see(text(/Revenue overview and growth metrics/)) - await I.see(text(/Engineering headcount proposal/)) -}) - -Default.test('article exists only when present in list', async () => { - expect(await I.tryTo(() => I.see(link(/Quarterly report/i)))).toBe(true) - expect(await I.tryTo(() => I.see(link(/Non-existent Article/i)))).toBe(false) -}) - -export const WithSelection = meta.story({ - name: 'With Selection', - args: { - articles, - selectedId: '1', - }, -}) - -WithSelection.test('highlights selected article', async () => { - await I.see(link(/Quarterly report/i).options({ current: 'page' })) - await I.dontSee(link(/Hiring plan/i).options({ current: 'page' })) -}) diff --git a/src/pages/articles/ui/list/ArticleListItem.stories.tsx b/src/pages/articles/ui/list/ArticleListItem.stories.tsx deleted file mode 100644 index 413e587..0000000 --- a/src/pages/articles/ui/list/ArticleListItem.stories.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { Article } from '#entities/article' - -import preview from '#.storybook/preview' -import { createActor, link, text } from '#shared/test' - -import { ArticleListItem } from './ArticleListItem' - -const I = createActor() - -const doneArticle = { - id: '1', - title: 'Quarterly report', - description: 'Revenue overview and growth metrics for Q3 across all regions.', - status: 'done', - content: ['First paragraph.'], -} satisfies Article - -const inProgressArticle = { - id: '2', - title: 'Hiring plan', - description: 'Engineering headcount proposal for the next two quarters.', - status: 'in-progress', - content: ['First paragraph.'], -} satisfies Article - -const draftArticle = { - id: '3', - title: 'Roadmap draft', - description: 'Feature priorities and timeline estimates for the next product cycle.', - status: 'draft', - content: ['First paragraph.'], -} satisfies Article - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleListItem', - component: ArticleListItem, - parameters: { layout: 'padded' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ - name: 'Default', - args: { - article: doneArticle, - href: '/articles/1', - isSelected: false, - }, -}) - -Default.test('renders article title and description', async () => { - await I.see(link(/Quarterly report/i)) - await I.see(text(/Revenue overview and growth metrics/)) - await I.see(text('Done')) -}) - -export const Selected = meta.story({ - name: 'Selected', - args: { - article: doneArticle, - href: '/articles/1', - isSelected: true, - }, -}) - -Selected.test('renders selected item with aria-current', async () => { - await I.see(link(/Quarterly report/i).options({ current: 'page' })) -}) - -export const InProgressStatus = meta.story({ - name: 'In Progress Status', - args: { - article: inProgressArticle, - href: '/articles/2', - isSelected: false, - }, -}) - -InProgressStatus.test('renders in-progress status badge', async () => { - await I.see(link(/Hiring plan/i)) - await I.see(text('In Progress')) -}) - -export const DraftStatus = meta.story({ - name: 'Draft Status', - args: { - article: draftArticle, - href: '/articles/3', - isSelected: false, - }, -}) - -DraftStatus.test('renders draft status badge', async () => { - await I.see(link(/Roadmap draft/i)) - await I.see(text('Draft')) -}) diff --git a/src/pages/articles/ui/list/ArticleListLoading.stories.tsx b/src/pages/articles/ui/list/ArticleListLoading.stories.tsx deleted file mode 100644 index c966167..0000000 --- a/src/pages/articles/ui/list/ArticleListLoading.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import preview from '#.storybook/preview' - -import { ArticleListLoading } from './ArticleListLoading' - -const meta = preview.meta({ - title: 'Pages/Articles/ArticleListLoading', - component: ArticleListLoading, - parameters: { layout: 'padded' }, -}) - -export default meta - -export const Default = meta.story({ name: 'Default' }) diff --git a/src/pages/calculator/testing.ts b/src/pages/calculator/testing.ts index 20ae35b..5889a3f 100644 --- a/src/pages/calculator/testing.ts +++ b/src/pages/calculator/testing.ts @@ -1,13 +1,26 @@ -import { button, createActor, heading } from '#shared/test' +import { button, createActor, heading, text } from '#shared/test' export const calculatorLoc = { heading: heading('Calculator'), acButton: button('AC'), equalsButton: button('='), zeroButton: button('0'), + display: (value: string) => text(value).options({ selector: 'span' }), + key: (label: string | RegExp) => button(label), } export const calculatorActor = createActor().extend((I) => ({ + press: async (label: string | RegExp) => { + await I.click(calculatorLoc.key(label)) + }, + pressMany: async (...labels: Array) => { + for (const label of labels) { + await I.click(calculatorLoc.key(label)) + } + }, + seeDisplay: async (value: string) => { + await I.see(calculatorLoc.display(value)) + }, seeCalculatorContent: async () => { await I.see(calculatorLoc.heading) await I.see(calculatorLoc.acButton) diff --git a/src/pages/calculator/ui/CalculatorPage.stories.tsx b/src/pages/calculator/ui/CalculatorPage.stories.tsx deleted file mode 100644 index dd3d17e..0000000 --- a/src/pages/calculator/ui/CalculatorPage.stories.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import preview from '#.storybook/preview' -import { button, createActor, text } from '#shared/test' - -import { CalculatorPage } from './CalculatorPage' - -const btn = (name: string | RegExp) => button(name) -const display = (value: string) => text(value).options({ selector: 'span' }) - -const I = createActor().extend((targetI) => ({ - tap: async (label: string | RegExp) => { - await targetI.click(btn(label)) - }, - seeDisplay: async (value: string) => { - await targetI.see(display(value)) - }, -})) - -const meta = preview.meta({ - title: 'Pages/Calculator', - component: CalculatorPage, - parameters: { layout: 'centered' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ name: 'Default' }) - -Default.test('performs basic addition: 7 + 5 = 12', async () => { - await I.tap('7') - await I.seeDisplay('7') - await I.tap('+') - await I.tap('5') - await I.seeDisplay('5') - await I.tap('=') - await I.seeDisplay('12') -}) - -Default.test('performs basic subtraction: 9 − 4 = 5', async () => { - await I.tap('9') - await I.tap('−') - await I.tap('4') - await I.tap('=') - await I.seeDisplay('5') -}) - -Default.test('performs basic multiplication: 6 × 3 = 18', async () => { - await I.tap('6') - await I.tap('×') - await I.tap('3') - await I.tap('=') - await I.seeDisplay('18') -}) - -Default.test('performs basic division: 8 ÷ 2 = 4', async () => { - await I.tap('8') - await I.tap('÷') - await I.tap('2') - await I.tap('=') - await I.seeDisplay('4') -}) - -Default.test('handles decimal point', async () => { - await I.tap('1') - await I.tap('.') - await I.tap('5') - await I.seeDisplay('1.5') - await I.tap('.') // Should do nothing - await I.seeDisplay('1.5') - await I.tap('×') - await I.tap('2') - await I.tap('=') - await I.seeDisplay('3') -}) - -Default.test('handles decimal point after operator', async () => { - await I.tap('5') - await I.tap('+') - await I.tap('.') - await I.seeDisplay('0.') - await I.tap('2') - await I.tap('=') - await I.seeDisplay('5.2') -}) - -Default.test('toggles sign: 5 to -5', async () => { - await I.tap('5') - await I.tap('+/−') - await I.seeDisplay('-5') - await I.tap('+/−') - await I.seeDisplay('5') -}) - -Default.test('calculates percentage: 50% = 0.5', async () => { - await I.tap('5') - await I.tap('0') - await I.tap('%') - await I.seeDisplay('0.5') -}) - -Default.test('clears display with AC', async () => { - await I.tap('1') - await I.tap('2') - await I.tap('3') - await I.seeDisplay('123') - await I.tap('AC') - await I.seeDisplay('0') -}) - -Default.test('handles consecutive operations', async () => { - await I.tap('5') - await I.tap('+') - await I.tap('5') - await I.tap('+') // Should calculate intermediate result 10 - await I.seeDisplay('10') - await I.tap('2') - await I.tap('=') - await I.seeDisplay('12') -}) - -Default.test('division by zero shows Error', async () => { - await I.tap('5') - await I.tap('÷') - await I.tap('0') - await I.tap('=') - await I.seeDisplay('Error') -}) - -Default.test('error persists when operator is pressed after division by zero', async () => { - await I.tap('5') - await I.tap('÷') - await I.tap('0') - await I.tap('+') - await I.seeDisplay('Error') -}) diff --git a/src/pages/items/testing.ts b/src/pages/items/testing.ts index c5e54e5..c7a4c2a 100644 --- a/src/pages/items/testing.ts +++ b/src/pages/items/testing.ts @@ -1,6 +1,7 @@ import { createActor, heading, + role, text, withDetailError, withPageError, @@ -44,4 +45,31 @@ export const itemsActor = createActor() seeOutOfStockBadge: async () => { await I.see(text('Out of Stock').all()) }, + seeItem: async (name: string) => { + await I.see(text(name)) + }, + dontSeeItem: async (name: string) => { + await I.dontSee(text(name)) + }, + seeOnlyItems: async (...names: string[]) => { + for (const name of names) { + await I.see(text(name)) + } + }, + applyCategoryFilter: async (option: string) => { + await I.selectOption(role('combobox', /Category/i), option) + }, + applyStockFilter: async (option: string) => { + await I.selectOption(role('combobox', /Stock/i), option) + }, + applyPriceSort: async () => { + await I.selectOption(role('combobox', /Sort by/i), 'Price') + }, + toggleSortDirection: async () => { + await I.click(role('button', /Asc|Desc/)) + }, + grabVisiblePrices: async () => { + const prices = await I.grabTextFromAll(text(/^\$/).all()) + return prices.map((price) => Number(price.replace('$', ''))) + }, })) diff --git a/src/pages/items/ui/ItemsPage.stories.tsx b/src/pages/items/ui/ItemsPage.stories.tsx deleted file mode 100644 index e78e4dc..0000000 --- a/src/pages/items/ui/ItemsPage.stories.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { expect } from 'storybook/test' - -import preview from '#.storybook/preview' -import { itemsMockData } from '#entities/item/mocks/data' -import { createActor, heading, role, text } from '#shared/test' - -import { ItemsPage } from './ItemsPage' - -const I = createActor().extend((targetI) => ({ - checkPrices: async () => { - const prices = await targetI.grabTextFromAll(text(/^\$/).all()) - return prices.map((price) => Number(price.replace('$', ''))) - }, - seeItem: async (name: string) => { - await targetI.see(text(name)) - }, - dontSeeItem: async (name: string) => { - await targetI.dontSee(text(name)) - }, - selectSort: async (option: string) => { - await targetI.selectOption(role('combobox', /Sort by/i), option) - }, - selectCategory: async (option: string) => { - await targetI.selectOption(role('combobox', /Category/i), option) - }, - selectStock: async (option: string) => { - await targetI.selectOption(role('combobox', /Stock/i), option) - }, - toggleSortDirection: async () => { - await targetI.click(role('button', /Asc|Desc/)) - }, -})) - -const meta = preview.meta({ - title: 'Pages/Items', - component: ItemsPage, - args: { - items: itemsMockData.map((item) => ({ item, href: `/items/${item.id}` })), - }, - parameters: { layout: 'fullscreen' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ name: 'Default' }) - -Default.test('renders page heading and sample items', async () => { - await I.see(heading('Items')) - await I.seeNumberOfElements(role('link').all(), 12) - await I.seeItem('Wireless Headphones') - await I.seeItem('Standing Desk') -}) - -export const FilteredByCategory = meta.story({ name: 'Filtered by Category' }) - -FilteredByCategory.test('shows only electronics items', async () => { - await I.selectCategory('Electronics') - await I.seeNumberOfElements(role('link').all(), 3) - await I.seeItem('Wireless Headphones') - await I.seeItem('Mechanical Keyboard') - await I.dontSeeItem('Standing Desk') - await I.dontSeeItem('Merino Wool Sweater') -}) - -FilteredByCategory.test('shows only food items', async () => { - await I.selectCategory('Food') - await I.seeNumberOfElements(role('link').all(), 3) - await I.seeItem('Organic Coffee Beans') - await I.dontSeeItem('Wireless Headphones') -}) - -FilteredByCategory.test('item availability changes with category filter', async () => { - expect(await I.tryTo(() => I.seeItem('Wireless Headphones'))).toBe(true) - expect(await I.tryTo(() => I.seeItem('Non-existent Product'))).toBe(false) - - await I.selectCategory('Food') - expect(await I.tryTo(() => I.seeItem('Wireless Headphones'))).toBe(false) - expect(await I.tryTo(() => I.seeItem('Organic Coffee Beans'))).toBe(true) -}) - -export const FilteredByStock = meta.story({ name: 'Filtered by Stock' }) - -FilteredByStock.test('shows only in-stock items', async () => { - await I.selectStock('In Stock') - await I.seeItem('Wireless Headphones') - await I.seeItem('Standing Desk') - await I.dontSeeItem('Merino Wool Sweater') -}) - -FilteredByStock.test('shows only out-of-stock items', async () => { - await I.selectStock('Out of Stock') - await I.seeItem('Merino Wool Sweater') - await I.seeItem('Ergonomic Chair') - await I.dontSeeItem('Wireless Headphones') -}) - -export const FilteredToEmpty = meta.story({ name: 'No Matching Items' }) - -FilteredToEmpty.test('shows empty state message', async () => { - await I.selectCategory('Electronics') - await I.selectStock('Out of Stock') - await I.see(text('No items match the current filters.')) -}) - -FilteredToEmpty.test('verifies multiple items across filter states', async () => { - expect(await I.hopeThat(() => I.seeItem('Wireless Headphones'))).toBe(true) - expect(await I.hopeThat(() => I.seeItem('Standing Desk'))).toBe(true) - I.hopeThat.noErrors() - - await I.selectCategory('Electronics') - await I.selectStock('Out of Stock') - expect(await I.hopeThat(() => I.seeItem('Wireless Headphones'))).toBe(false) - expect(() => I.hopeThat.noErrors()).toThrow(/soft assertion/) -}) - -export const SortedByPrice = meta.story({ name: 'Sorted by Price' }) - -SortedByPrice.test('sorts ascending by default', async () => { - await I.selectSort('Price') - const prices = await I.checkPrices() - const sortedPrices = [...prices].sort((a, b) => a - b) - expect(prices).toEqual(sortedPrices) -}) - -SortedByPrice.test('sorts descending after toggle', async () => { - await I.selectSort('Price') - await I.toggleSortDirection() - const prices = await I.checkPrices() - const sortedPrices = [...prices].sort((a, b) => b - a) - expect(prices).toEqual(sortedPrices) -}) diff --git a/src/pages/timer/testing.ts b/src/pages/timer/testing.ts index ca94b07..f16a18c 100644 --- a/src/pages/timer/testing.ts +++ b/src/pages/timer/testing.ts @@ -1,8 +1,9 @@ -import { button, createActor, heading, text } from '#shared/test' +import { button, createActor, heading, role, text } from '#shared/test' export const timerLoc = { heading: heading('Timer'), - display: text('05:00'), + display: (value: string | RegExp) => text(value), + customInput: role('textbox'), startButton: button('Start'), pauseButton: button('Pause'), resetButton: button('Reset'), @@ -11,12 +12,16 @@ export const timerLoc = { preset5m: button('5m'), preset10m: button('10m'), preset25m: button('25m'), + preset: (label: '10s' | '1m' | '5m' | '10m' | '25m') => button(label), } export const timerActor = createActor().extend((I) => ({ + seeDuration: async (value: string | RegExp) => { + await I.see(timerLoc.display(value)) + }, seeTimerContent: async () => { await I.see(timerLoc.heading) - await I.see(timerLoc.display) + await I.see(timerLoc.display('05:00')) await I.see(timerLoc.startButton) await I.see(timerLoc.resetButton) }, @@ -27,4 +32,37 @@ export const timerActor = createActor().extend((I) => ({ await I.see(timerLoc.preset10m) await I.see(timerLoc.preset25m) }, + choosePreset: async (label: '10s' | '1m' | '5m' | '10m' | '25m') => { + await I.click(timerLoc.preset(label)) + }, + start: async () => { + await I.click(timerLoc.startButton) + }, + pause: async () => { + await I.click(timerLoc.pauseButton) + }, + reset: async () => { + await I.click(timerLoc.resetButton) + }, + enterCustomDurationByBlur: async (value: string) => { + await I.fill(timerLoc.customInput, value) + }, + enterCustomDurationByEnter: async (value: string) => { + await I.click(timerLoc.customInput) + await I.press(value) + await I.press('[Enter]') + }, + clearCustomDuration: async () => { + await I.clear(timerLoc.customInput) + }, + waitForDuration: async (value: string | RegExp) => { + await I.retryTo( + async () => { + const found = await I.tryTo(() => I.see(timerLoc.display(value))) + if (!found) throw new Error(`waiting for duration ${String(value)}`) + }, + 5, + 500, + ) + }, })) diff --git a/src/pages/timer/ui/TimerPage.stories.tsx b/src/pages/timer/ui/TimerPage.stories.tsx deleted file mode 100644 index 289c443..0000000 --- a/src/pages/timer/ui/TimerPage.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { expect } from 'storybook/test' - -import preview from '#.storybook/preview' -import { button, createActor, heading, role, text } from '#shared/test' - -import { TimerPage } from './TimerPage' - -const timerHeading = heading('Timer') -const startBtn = button('Start') -const pauseBtn = button('Pause') -const resetBtn = button('Reset') -const durationBtn = (label: string) => button(label) - -const I = createActor() - -const meta = preview.meta({ - title: 'Pages/Timer', - component: TimerPage, - parameters: { layout: 'centered' }, - loaders: [(ctx) => I.init(ctx)], -}) - -export default meta - -export const Default = meta.story({ name: 'Default' }) - -Default.test('renders timer heading and initial duration', async () => { - await I.see(timerHeading) - await I.see(text('05:00')) -}) - -export const TimerControls = meta.story({ name: 'Timer Controls' }) - -TimerControls.test('starts, pauses, and resets', async () => { - await I.click(startBtn) - await I.see(pauseBtn) - - await I.click(pauseBtn) - await I.see(startBtn) - - await I.click(resetBtn) - await I.see(text('05:00')) -}) - -export const DurationPresets = meta.story({ name: 'Duration Presets' }) - -DurationPresets.test('changes duration via preset buttons', async () => { - await I.click(durationBtn('1m')) - await I.see(text('01:00')) - - await I.click(durationBtn('10m')) - await I.see(text('10:00')) - - await I.click(durationBtn('5m')) - await I.see(text('05:00')) -}) - -export const CustomDuration = meta.story({ name: 'Custom Duration' }) - -CustomDuration.test('custom time input starts empty', async () => { - const customInput = role('textbox') - - expect(await I.grabValueFrom(customInput)).toBe('') - await I.dontSeeInField(customInput, '05:00') -}) diff --git a/src/pages/timer/ui/TimerPage.tsx b/src/pages/timer/ui/TimerPage.tsx index 9a4dae8..94ca8d3 100644 --- a/src/pages/timer/ui/TimerPage.tsx +++ b/src/pages/timer/ui/TimerPage.tsx @@ -17,11 +17,16 @@ const PRESETS = [ export const TimerPage = reatomComponent(() => { const [customInput, setCustomInput] = useAtom('') + const isRunning = timer.running() + const isStartDisabled = timer.remaining() <= 0 const handleCustomTimeCommit = wrap(() => { timer.commitCustomDuration(customInput) setCustomInput('') }) + const handleStart = wrap(() => timer.running.setTrue()) + const handlePause = wrap(() => timer.running.setFalse()) + const handleReset = wrap(() => timer.reset()) return ( { key={label} variant="outline" size="sm" - disabled={timer.running()} + disabled={isRunning} onClick={wrap(() => timer.setDuration(seconds))} > {label} @@ -62,7 +67,7 @@ export const TimerPage = reatomComponent(() => { size="sm" w="20" value={customInput} - disabled={timer.running()} + disabled={isRunning} onChange={(e) => setCustomInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleCustomTimeCommit() @@ -71,16 +76,16 @@ export const TimerPage = reatomComponent(() => { /> - {timer.running() ? ( - ) : ( - )} - From ff503aed02e3e758e2eff4716cc58f1e058bdcf6 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Thu, 28 May 2026 05:19:01 +0300 Subject: [PATCH 2/3] fix: update lint:fallow:health task to include coverage in the run command --- .config/mise/conf.d/tasks-quality.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.config/mise/conf.d/tasks-quality.toml b/.config/mise/conf.d/tasks-quality.toml index d4f3ec3..fdb941b 100644 --- a/.config/mise/conf.d/tasks-quality.toml +++ b/.config/mise/conf.d/tasks-quality.toml @@ -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" From f66a64fd4751f36babd8b32ca2a26d4a42963a65 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Thu, 28 May 2026 05:22:48 +0300 Subject: [PATCH 3/3] docs: enhance tooling documentation for clarity on Vite+ configuration and responsibilities --- docs/tooling.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/tooling.md b/docs/tooling.md index 7ac62aa..8811e6a 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -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.