diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11897e..184b2ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,44 @@ jobs: - name: Lint markdown run: pnpm lint:md - - name: Test - run: pnpm test + - name: Test with coverage + run: pnpm test:coverage - name: Build run: pnpm build + + e2e: + name: End-to-end tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright's Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Build + run: pnpm build + + - name: Run Playwright tests + run: pnpm test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c819b5a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contributing + +## Getting started + +```sh +pnpm install +pnpm dev +``` + +Bun works too, if you'd rather not install pnpm: `bun install && bunx --bun run dev`. + +## TDD + +Domain code (`src/domain/**`) is written test-first: red, green, refactor. A behavior +change starts with a failing spec next to the class it exercises (`__spec__/`), then the +smallest change that passes it, then a cleanup pass with the tests as a safety net. Two +concrete examples from this codebase's own history: + +- `LocalStorageWelcomeSurveyRepository.getSurvey()` had a cache bug that made it return + `null` forever. The regression spec (`should read a survey that was written to storage + by another instance`) fails against the old code and passes against the fix, so it + cannot silently come back. +- `Product.getLoreLink`/`getVideoUrl` used to have three branches that all returned the + same expression. Giving each knowledge level distinct fixture data in the spec, instead + of one profile reused three times, is what made the dead branches visible. + +## The testing trophy + +Effort is weighted toward the middle, not the extremes: + +```mermaid +flowchart TD + A["Static analysis: TypeScript, Biome"] --> B["Unit: src/domain/**"] + B --> C["Integration: src/ui/**, real components, mocked storage boundaries"] + C --> D["End-to-end: 2 Playwright smoke specs"] +``` + +- **Static**: `pnpm typecheck` and `pnpm check` catch a whole class of bugs before a test + even runs. +- **Unit** (`vitest --project unit`, node environment): every domain class, one behavior + per test. This is most of the suite, domain logic has no framework to fight. +- **Integration** (`vitest --project integration`, jsdom): renders real components with + `@testing-library/react` and exercises real user flows: add to cart, change quantity, + the welcome survey save/restore. Framework wiring (React, Radix) is real; only the + storage boundary gets mocked when a scenario the real data can't reach needs it (see + `ShoppingCart.freeShipping.spec.tsx`). +- **End-to-end** (`pnpm test:e2e`, Playwright against a built `vite preview`): two smoke + tests, home to shop and add-to-cart to checkout. Enough to catch a broken build, not a + replacement for the layers below. + +## Commands + +| Command | What it does | +| ----------------------- | ------------------------------------------------ | +| `pnpm test` | Runs the unit and integration Vitest projects | +| `pnpm test:watch` | Same, in watch mode | +| `pnpm test:coverage` | Runs tests with coverage thresholds enforced | +| `pnpm test:e2e` | Builds nothing itself, run `pnpm build` first, then the 2 Playwright specs | +| `pnpm typecheck` | `tsc --noEmit` across the app, tooling, and tests | +| `pnpm check` | Biome lint + format check | +| `pnpm check:fix` | Biome lint + format, writing fixes | + +## Object Calisthenics + +The rules this repo tries to hold itself to, and where to see them: + +1. One level of indentation per method — early returns throughout `src/domain` +2. Max ~50 lines per class — `Cart`, `CartItem`, `CartItems` +3. Wrap primitives — `Money`, `Quantity`, `ProductId`, `ProductName` +4. First-class collections — `CartItems` wraps the cart's `CartItem[]` +5. One dot per line (Law of Demeter) — `Cart.increaseQuantity(itemId)` instead of pulling + a `Product` back out of a `CartItem` to hand it to `Cart` +6. No getters/setters unless necessary — `CartItem.id()`/`name()`/`quantity()` are + necessary, `getProduct()` wasn't and is gone +7. No else — grep the repo, there isn't one +8. Descriptive names, single responsibility, one thing per method — the rest of it + +None of this is free: it costs more classes and more indirection than the equivalent +procedural code. The trade is code whose pieces can be understood, tested, and changed +one at a time. + +## Commits + +Conventional Commits, scope required: `type(scope): subject`. Enforced by commitlint via +a pre-commit hook (Lefthook), not just convention. diff --git a/e2e/add-to-cart.spec.ts b/e2e/add-to-cart.spec.ts new file mode 100644 index 0000000..4797f7b --- /dev/null +++ b/e2e/add-to-cart.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +test("visitor can add a product to the cart and reach checkout", async ({ page }) => { + await page.goto("shopping-cart/"); + + // First visit: the knowledge-level survey opens automatically. + await expect(page.getByText("Choose Your Knowledge Level")).toBeVisible(); + await page.getByRole("combobox").selectOption("beginner"); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("Choose Your Knowledge Level")).not.toBeVisible(); + + await page.getByRole("button", { name: "Add to Cart" }).first().click(); + await expect(page.getByText("Your inventory is empty")).not.toBeVisible(); + + await page.getByRole("button", { name: "Complete Purchase" }).click(); + + await expect(page.getByText("End of the Demo")).toBeVisible(); +}); diff --git a/e2e/home-to-shop.spec.ts b/e2e/home-to-shop.spec.ts new file mode 100644 index 0000000..573c8c0 --- /dev/null +++ b/e2e/home-to-shop.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; + +test("visitor can get from the home page to the shop", async ({ page }) => { + await page.goto("./"); + + await expect( + page.getByRole("heading", { name: "Maintainable Frontend Architecture with React" }), + ).toBeVisible(); + + await page.getByRole("link", { name: /Try the Shopping Cart Experience/ }).click(); + await expect(page).toHaveURL(/\/shopping-cart\/?$/); + + // The knowledge-level survey opens automatically on a first visit and hides the rest + // of the page from assistive tech (and from role-based locators) while it is open. + await page.getByRole("combobox").selectOption("beginner"); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.getByRole("heading", { name: "Mystical Inventory" })).toBeVisible(); +}); diff --git a/package.json b/package.json index 0ce05f1..cd6190f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "check:fix": "biome check --write .", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", "prepare": "lefthook install", "deploy": "pnpm build && npx gh-pages -d dist" }, @@ -66,12 +68,15 @@ "@biomejs/biome": "2.5.11", "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", + "@playwright/test": "1.62.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", "jsdom": "^30.0.1", "lefthook": "2.1.12", "remark-cli": "12.0.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..572109e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = 4173; +const BASE_PATH = "/react-object-calisthenics/"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: "html", + use: { + baseURL: `http://localhost:${PORT}${BASE_PATH}`, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "pnpm run preview", + url: `http://localhost:${PORT}${BASE_PATH}`, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73269aa..428d257 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,15 +93,21 @@ importers: '@commitlint/config-conventional': specifier: 21.2.2 version: 21.2.2 + '@playwright/test': + specifier: 1.62.1 + version: 1.62.1 '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 '@testing-library/jest-dom': specifier: ^7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11) '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.6 + version: 14.6.6(@testing-library/dom@10.4.1) '@types/react': specifier: ^19.2.18 version: 19.2.18 @@ -111,6 +117,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.1.1 version: 6.1.1(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) jsdom: specifier: ^30.0.1 version: 30.0.1 @@ -137,7 +146,7 @@ importers: version: 8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -286,6 +295,10 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.5.11': resolution: {integrity: sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA==} engines: {node: '>=14.21.3'} @@ -530,6 +543,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -1180,6 +1198,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.6': + resolution: {integrity: sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1252,6 +1276,15 @@ packages: oxc-transform-react: optional: true + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -1338,6 +1371,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -1635,6 +1671,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1677,6 +1718,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -1685,6 +1730,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + ignore@6.0.2: resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==} engines: {node: '>= 4'} @@ -1772,6 +1820,18 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -1783,6 +1843,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2048,6 +2111,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -2310,6 +2380,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -2609,6 +2689,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@9.4.0: resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} engines: {node: '>=12'} @@ -3142,6 +3226,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.5.11': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.11 @@ -3414,6 +3500,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@radix-ui/primitive@1.1.7': {} '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': @@ -3907,7 +3997,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)))': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.1 @@ -3917,7 +4007,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -3929,6 +4019,10 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@testing-library/user-event@14.6.6(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': @@ -3989,6 +4083,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -4074,6 +4182,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7 @@ -4332,6 +4446,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4366,6 +4483,8 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -4376,6 +4495,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + ignore@6.0.2: {} immutable-instance@0.0.9(react@19.2.8): @@ -4436,6 +4557,19 @@ snapshots: isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -4446,6 +4580,8 @@ snapshots: jiti@2.7.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -4660,6 +4796,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -5123,6 +5269,14 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} postcss@8.5.26: @@ -5572,6 +5726,10 @@ snapshots: dependencies: min-indent: 1.0.1 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@9.4.0: {} symbol-tree@3.2.4: {} @@ -5821,7 +5979,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -5845,6 +6003,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) jsdom: 30.0.1 transitivePeerDependencies: - msw diff --git a/src/config/analytics.config.ts b/src/config/analytics.config.ts index c588188..571b98c 100644 --- a/src/config/analytics.config.ts +++ b/src/config/analytics.config.ts @@ -13,6 +13,12 @@ export const ANALYTICS_CONFIG = { homeArticle: "home.article", homeGithub: "home.github", completePurchase: "shopping-cart.complete-purchase", + increaseQuantity: "shopping-cart.increase-quantity", + decreaseQuantity: "shopping-cart.decrease-quantity", + removeItem: "shopping-cart.remove-item", contactLinkClick: "contact.link-click", + addToCart: "product.add-to-cart", + watchVideo: "product.watch-video", + readLore: "product.read-lore", }, } as const; diff --git a/src/config/storage.config.ts b/src/config/storage.config.ts index 83b2427..0a83f13 100644 --- a/src/config/storage.config.ts +++ b/src/config/storage.config.ts @@ -1,5 +1,5 @@ +import { TanStackStoreWelcomeSurveyRepository } from "@/domain/welcomeSurvey/infrastructure/TanstackStoreWelcomeSurvey.repository"; import type { WelcomeStorageRepository } from "@/domain/welcomeSurvey/WelcomeSurveyStorage.repository"; -import { TanStackStoreWelcomeSurveyRepository } from "@/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository"; /** * The active WelcomeSurvey storage strategy. LocalStorageWelcomeSurveyRepository and diff --git a/src/data/products.ts b/src/data/products.ts index 923c7b6..33299dd 100644 --- a/src/data/products.ts +++ b/src/data/products.ts @@ -1,5 +1,6 @@ import { Money } from "@/domain/cart/value-objects/Money"; import { + ExtraResources, ImageUrl, KnowledgeContent, Product, @@ -8,220 +9,233 @@ import { } from "@/domain/cart/value-objects/Product/Product"; import { Effect, - ExtraResources, ProfileDetails, Stats, } from "@/domain/cart/value-objects/Product/ProductDetails"; -export const PRODUCTS: Product[] = [ - new Product( - new ProductId("thunderfury"), - new ProductName("Thunderfury, Blessed Blade of the Windseeker"), - new Money(115105), - new ImageUrl( +interface StatBlock { + power: number; + durability: number; + manaBoost: number; +} + +interface ProfileBlueprint { + description: string; + stats: StatBlock; + effects: string[]; +} + +interface ProductBlueprint { + id: string; + name: string; + priceInCopper: number; + imageUrl: string; + loreLink: string; + videoUrl: string; + beginner: ProfileBlueprint; + intermediate: ProfileBlueprint; + expert: ProfileBlueprint; +} + +function buildProfile(blueprint: ProfileBlueprint): ProfileDetails { + const { power, durability, manaBoost } = blueprint.stats; + + return new ProfileDetails( + blueprint.description, + new Stats(power, durability, manaBoost), + blueprint.effects.map((effect) => new Effect(effect)), + ); +} + +function buildProduct(blueprint: ProductBlueprint): Product { + const knowledge = new KnowledgeContent( + buildProfile(blueprint.beginner), + buildProfile(blueprint.intermediate), + buildProfile(blueprint.expert), + ); + + return new Product( + new ProductId(blueprint.id), + new ProductName(blueprint.name), + new Money(blueprint.priceInCopper), + new ImageUrl(blueprint.imageUrl), + knowledge, + new ExtraResources(blueprint.loreLink, blueprint.videoUrl), + ); +} + +/** + * First-class collection wrapping the shop's Product[], instead of a bare exported array. + */ +export class ProductCatalog { + private constructor(private readonly products: readonly Product[]) {} + + static of(blueprints: ProductBlueprint[]): ProductCatalog { + return new ProductCatalog(blueprints.map(buildProduct)); + } + + all(): readonly Product[] { + return this.products; + } + + findById(productId: string): Product | undefined { + return this.products.find((product) => product.displayId() === productId); + } +} + +export const PRODUCT_CATALOG = ProductCatalog.of([ + { + id: "thunderfury", + name: "Thunderfury, Blessed Blade of the Windseeker", + priceInCopper: 115105, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ), - new KnowledgeContent( - new ProfileDetails( + loreLink: + "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", + videoUrl: "https://www.youtube.com/embed/2TGRpvb5Nos", + beginner: { + description: "A legendary sword that channels the power of storms. Wield it to summon lightning upon your foes.", - new Stats(150, 50, 0), - [new Effect("Chance to strike enemies with lightning.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( + stats: { power: 150, durability: 50, manaBoost: 0 }, + effects: ["Chance to strike enemies with lightning."], + }, + intermediate: { + description: "Thunderfury is forged with elemental fury. Great for battle, with bonus lightning damage and attack speed.", - new Stats(300, 100, 10), - [ - new Effect("Increases attack speed by 15%"), - new Effect("Lightning damage with each hit"), - new Effect("Chance to slow enemies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( + stats: { power: 300, durability: 100, manaBoost: 10 }, + effects: [ + "Increases attack speed by 15%", + "Lightning damage with each hit", + "Chance to slow enemies", + ], + }, + expert: { + description: "Thunderfury, Blessed Blade of the Windseeker, is a legendary weapon of immense power. It channels elemental air, unleashing lightning with each strike. Its artifact status is confirmed by its rarity and crafting requirements.", - new Stats(500, 150, 25), - [ - new Effect("Attack speed +25%"), - new Effect("Unleashes chain lightning on critical strikes"), - new Effect("Chance to silence enemies"), - new Effect("Grants immunity to silence for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - ), - ), - new Product( - new ProductId("ashbringer"), - new ProductName("Ashbringer"), - new Money(288527), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/a/a6/Ashbringer_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( + stats: { power: 500, durability: 150, manaBoost: 25 }, + effects: [ + "Attack speed +25%", + "Unleashes chain lightning on critical strikes", + "Chance to silence enemies", + "Grants immunity to silence for 5 seconds", + ], + }, + }, + { + id: "ashbringer", + name: "Ashbringer", + priceInCopper: 288527, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/a/a6/Ashbringer_TCG.jpg", + loreLink: "https://wowpedia.fandom.com/wiki/Ashbringer", + videoUrl: "https://www.youtube.com/embed/lso8Ygk4rKc", + beginner: { + description: "A holy sword blessed to purge the undead. Known for its radiant power.", - new Stats(200, 70, 0), - [new Effect("Bonus against undead enemies.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( + stats: { power: 200, durability: 70, manaBoost: 0 }, + effects: ["Bonus against undead enemies."], + }, + intermediate: { + description: "Ashbringer channels holy energy to smite foes, cleansing corruption and undead.", - new Stats(350, 140, 20), - [new Effect("Holy damage +25%"), new Effect("Undead vulnerability.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( + stats: { power: 350, durability: 140, manaBoost: 20 }, + effects: ["Holy damage +25%", "Undead vulnerability."], + }, + expert: { + description: "Ashbringer is the embodiment of righteousness. It delivers devastating holy strikes and vanquishes evil.", - new Stats(600, 220, 40), - [ - new Effect("Holy damage +50%"), - new Effect("Instant kill chance on undead"), - new Effect("Grant blessing to allies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - ), - ), - new Product( - new ProductId("sulfuras"), - new ProductName("Sulfuras, Hand of Ragnaros"), - new Money(31244), - new ImageUrl( + stats: { power: 600, durability: 220, manaBoost: 40 }, + effects: [ + "Holy damage +50%", + "Instant kill chance on undead", + "Grant blessing to allies", + ], + }, + }, + { + id: "sulfuras", + name: "Sulfuras, Hand of Ragnaros", + priceInCopper: 31244, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/6/6e/Sulfuras_Hand_of_Ragnaros_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A molten hammer forged in elemental fire, granting immense strength.", - new Stats(220, 80, 0), - [new Effect("Fire damage over time.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", - "https://www.youtube.com/embed/crNEJBci-Es", - ), - ), - new ProfileDetails( + loreLink: "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", + videoUrl: "https://www.youtube.com/embed/crNEJBci-Es", + beginner: { + description: "A molten hammer forged in elemental fire, granting immense strength.", + stats: { power: 220, durability: 80, manaBoost: 0 }, + effects: ["Fire damage over time."], + }, + intermediate: { + description: "Sulfuras channels molten fire into devastating blows that incinerate enemies.", - new Stats(400, 160, 0), - [new Effect("Fire blast chance"), new Effect("Burn enemies for 10s")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", - "https://www.youtube.com/embed/crNEJBci-Es", - ), - ), - new ProfileDetails( + stats: { power: 400, durability: 160, manaBoost: 0 }, + effects: ["Fire blast chance", "Burn enemies for 10s"], + }, + expert: { + description: "Sulfuras is a weapon of pure elemental fury, capable of burning armies with a single strike.", - new Stats(700, 300, 0), - [ - new Effect("Fire damage +70%"), - new Effect("Area of effect burn"), - new Effect("Ignite enemies on critical strike"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", - "https://www.youtube.com/embed/crNEJBci-Es", - ), - ), - ), - ), - new Product( - new ProductId("valanyr"), - new ProductName("Val'anyr, Hammer of Ancient Kings"), - new Money(363651), - new ImageUrl( + stats: { power: 700, durability: 300, manaBoost: 0 }, + effects: [ + "Fire damage +70%", + "Area of effect burn", + "Ignite enemies on critical strike", + ], + }, + }, + { + id: "valanyr", + name: "Val'anyr, Hammer of Ancient Kings", + priceInCopper: 363651, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/7/76/Val%27anyr%2C_Hammer_of_Ancient_Kings_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A holy hammer that heals allies with each strike.", - new Stats(160, 60, 20), - [new Effect("Heals allies on hit.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", - "https://www.youtube.com/embed/u5GX0RHW6KM", - ), - ), - new ProfileDetails( - "Val'anyr channels holy energy to protect allies in battle.", - new Stats(320, 140, 40), - [new Effect("Heal over time for allies."), new Effect("Shield chance")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", - "https://www.youtube.com/embed/u5GX0RHW6KM", - ), - ), - new ProfileDetails( + loreLink: "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", + videoUrl: "https://www.youtube.com/embed/u5GX0RHW6KM", + beginner: { + description: "A holy hammer that heals allies with each strike.", + stats: { power: 160, durability: 60, manaBoost: 20 }, + effects: ["Heals allies on hit."], + }, + intermediate: { + description: "Val'anyr channels holy energy to protect allies in battle.", + stats: { power: 320, durability: 140, manaBoost: 40 }, + effects: ["Heal over time for allies.", "Shield chance"], + }, + expert: { + description: "Val'anyr is a divine relic that grants immense healing and protection to allies.", - new Stats(580, 220, 60), - [ - new Effect("Massive healing over time"), - new Effect("Full shield on critical hits"), - new Effect("Grant immunity to all debuffs for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", - "https://www.youtube.com/embed/u5GX0RHW6KM", - ), - ), - ), - ), - new Product( - new ProductId("doomhammer"), - new ProductName("Doomhammer"), - new Money(363651), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/a/a2/Doomhammer_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A mighty hammer that channels elemental earth power.", - new Stats(170, 80, 10), - [new Effect("Shockwave on impact.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Doomhammer", - "https://www.youtube.com/embed/htTWpEk_XDk", - ), - ), - new ProfileDetails( - "Doomhammer enhances the wielder’s strength and earth-based magic.", - new Stats(340, 160, 20), - [new Effect("Shockwave radius +15%"), new Effect("Stun chance on heavy attacks")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Doomhammer", - "https://www.youtube.com/embed/htTWpEk_XDk", - ), - ), - new ProfileDetails( + stats: { power: 580, durability: 220, manaBoost: 60 }, + effects: [ + "Massive healing over time", + "Full shield on critical hits", + "Grant immunity to all debuffs for 5 seconds", + ], + }, + }, + { + id: "doomhammer", + name: "Doomhammer", + priceInCopper: 363651, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/a/a2/Doomhammer_TCG.jpg", + loreLink: "https://wowpedia.fandom.com/wiki/Doomhammer", + videoUrl: "https://www.youtube.com/embed/htTWpEk_XDk", + beginner: { + description: "A mighty hammer that channels elemental earth power.", + stats: { power: 170, durability: 80, manaBoost: 10 }, + effects: ["Shockwave on impact."], + }, + intermediate: { + description: "Doomhammer enhances the wielder’s strength and earth-based magic.", + stats: { power: 340, durability: 160, manaBoost: 20 }, + effects: ["Shockwave radius +15%", "Stun chance on heavy attacks"], + }, + expert: { + description: "Doomhammer is the ultimate weapon of war, capable of shaking the very earth.", - new Stats(610, 300, 40), - [ - new Effect("Shockwave radius +50%"), - new Effect("Stun all enemies within range"), - new Effect("Damage immunity for 3 seconds after a heavy attack"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Doomhammer", - "https://www.youtube.com/embed/htTWpEk_XDk", - ), - ), - ), - ), -]; + stats: { power: 610, durability: 300, manaBoost: 40 }, + effects: [ + "Shockwave radius +50%", + "Stun all enemies within range", + "Damage immunity for 3 seconds after a heavy attack", + ], + }, + }, +]); diff --git a/src/domain/cart/Cart.ts b/src/domain/cart/Cart.ts index b6c83a7..a8b4ef8 100644 --- a/src/domain/cart/Cart.ts +++ b/src/domain/cart/Cart.ts @@ -1,33 +1,31 @@ +import { CartItems } from "@/domain/cart/CartItems"; +import { ShippingPolicy } from "@/domain/cart/policy/ShippingPolicy"; +import { TaxPolicy } from "@/domain/cart/policy/TaxPolicy"; import { Money } from "@/domain/cart/value-objects/Money"; import type { Product } from "@/domain/cart/value-objects/Product/Product"; -import { Quantity } from "@/domain/cart/value-objects/Quantity"; -import { CartItem } from "./CartItem"; -import { ShippingPolicy } from "./policy/ShippingPolicy"; -import { TaxPolicy } from "./policy/TaxPolicy"; +import type { Quantity } from "@/domain/cart/value-objects/Quantity"; +import type { CartItem } from "./CartItem"; /** * Immutable shopping cart that encapsulates its behavior and state. */ export class Cart { - private items: CartItem[] = []; - - constructor(items: CartItem[] = []) { - this.items = items; - } + constructor(private readonly items: CartItems = CartItems.empty()) {} isEmpty(): boolean { - return this.items.length === 0; + return this.items.isEmpty(); } totalItems(): Quantity { - return this.items.reduce( - (total, item) => total.add(item.getQuantity()), - new Quantity(0), - ); + return this.items.totalQuantity(); + } + + listItems(): readonly CartItem[] { + return this.items.toArray(); } calculateSubtotal(): Money { - return this.items.reduce((total, item) => total.add(item.totalPrice()), new Money(0)); + return this.items.totalPrice(); } calculateShipping(): Money { @@ -39,49 +37,33 @@ export class Cart { } calculateTotal(): Money { - const subtotal = this.calculateSubtotal(); - const tax = TaxPolicy.calculate(subtotal); - const shipping = ShippingPolicy.calculate(subtotal); - return subtotal.add(tax).add(shipping); - } - - getItemsCopy(): readonly CartItem[] { - return [...this.items]; + return this.calculateSubtotal() + .add(this.calculateTax()) + .add(this.calculateShipping()); } - addItem(product: Product): Cart { - const existing = this.items.find((item) => item.getId() === product.displayId()); + remainingForFreeShipping(): Money { + const subtotal = this.calculateSubtotal(); - if (existing) { - const updatedItem = existing.increaseQuantity(); - return new Cart( - this.items.map((item) => - item.getId() === existing.getId() ? updatedItem : item, - ), - ); + if (subtotal.isAtLeast(ShippingPolicy.SHIPPING_THRESHOLD)) { + return new Money(0); } - - const newItem = new CartItem(product, new Quantity(1)); - return new Cart([...this.items, newItem]); + return ShippingPolicy.SHIPPING_THRESHOLD.subtract(subtotal); } - decrementItem(product: Product): Cart { - const existing = this.items.find((item) => item.getId() === product.displayId()); - - if (!existing) return this; - - const updatedItem = existing.decreaseQuantity(); - - return new Cart( - this.items.map((item) => (item.getId() === existing.getId() ? updatedItem : item)), - ); + addItem(product: Product): Cart { + return new Cart(this.items.addOrIncrement(product)); } - deleteItem(product: Product): Cart { - const existing = this.items.find((item) => item.getId() === product.displayId()); + increaseQuantity(itemId: string): Cart { + return new Cart(this.items.increaseQuantity(itemId)); + } - if (!existing) return this; + decreaseQuantity(itemId: string): Cart { + return new Cart(this.items.decreaseQuantity(itemId)); + } - return new Cart(this.items.filter((item) => item.getId() !== product.displayId())); + removeItem(itemId: string): Cart { + return new Cart(this.items.remove(itemId)); } } diff --git a/src/domain/cart/CartItem.ts b/src/domain/cart/CartItem.ts index e60c455..70e7f05 100644 --- a/src/domain/cart/CartItem.ts +++ b/src/domain/cart/CartItem.ts @@ -1,80 +1,56 @@ import { COMMERCE_CONFIG } from "@/config/commerce.config"; -import { Money } from "@/domain/cart/value-objects/Money"; +import type { Money } from "@/domain/cart/value-objects/Money"; import type { Product } from "@/domain/cart/value-objects/Product/Product"; import { Quantity } from "@/domain/cart/value-objects/Quantity"; export class CartItem { - static readonly MAX_QUANTITY = COMMERCE_CONFIG.quantityPerItem.max; - static readonly MIN_QUANTITY = COMMERCE_CONFIG.quantityPerItem.min; + static readonly MAX_QUANTITY = new Quantity(COMMERCE_CONFIG.quantityPerItem.max); + static readonly MIN_QUANTITY = new Quantity(COMMERCE_CONFIG.quantityPerItem.min); constructor( - private readonly product: Product, - private readonly quantity: Quantity, + private readonly _product: Product, + private readonly _quantity: Quantity, ) {} - increaseQuantity(): CartItem { - const currentValue = this.quantity.toValue(); - - if (currentValue >= CartItem.MAX_QUANTITY) { - return this; - } - - const newQuantity = this.quantity.increment(); - return new CartItem(this.product, newQuantity); + id(): string { + return this._product.displayId(); } - decreaseQuantity(): CartItem { - const currentValue = this.quantity.toValue(); - - if (currentValue <= CartItem.MIN_QUANTITY) { - return this; - } - - const newQuantity = this.quantity.decrement(); - return new CartItem(this.product, newQuantity); + name(): string { + return this._product.displayName(); } - updateQuantity(newQuantity: Quantity): CartItem { - let value = newQuantity.toValue(); - - if (value < CartItem.MIN_QUANTITY) { - value = CartItem.MIN_QUANTITY; - } - - if (value > CartItem.MAX_QUANTITY) { - value = CartItem.MAX_QUANTITY; - } - - const clampedQuantity = new Quantity(value); - return new CartItem(this.product, clampedQuantity); - } - - totalPrice(): Money { - const pricePerUnit = new Money(this.product.displayPrice().toAmount()); - return pricePerUnit.multiply(this.quantity.toValue()); + image(): string { + return this._product.displayImage(); } - getId(): string { - return this.product.displayId(); + quantity(): Quantity { + return this._quantity; } - getName(): string { - return this.product.displayName(); - } - - getImage(): string { - return this.product.displayImage(); + totalPrice(): Money { + return this._product.displayPrice().multiply(this._quantity.toValue()); } - getQuantity(): Quantity { - return this.quantity; + increaseQuantity(): CartItem { + if (this._quantity.isAtLeast(CartItem.MAX_QUANTITY)) { + return this; + } + return new CartItem(this._product, this._quantity.increment()); } - getPrice(): Money { - return this.product.displayPrice(); + decreaseQuantity(): CartItem { + if (this._quantity.isAtMost(CartItem.MIN_QUANTITY)) { + return this; + } + return new CartItem(this._product, this._quantity.decrement()); } - getProduct(): Product { - return this.product; + updateQuantity(newQuantity: Quantity): CartItem { + const clamped = newQuantity.clampBetween( + CartItem.MIN_QUANTITY, + CartItem.MAX_QUANTITY, + ); + return new CartItem(this._product, clamped); } } diff --git a/src/domain/cart/CartItems.ts b/src/domain/cart/CartItems.ts new file mode 100644 index 0000000..f527712 --- /dev/null +++ b/src/domain/cart/CartItems.ts @@ -0,0 +1,75 @@ +import { CartItem } from "@/domain/cart/CartItem"; +import { Money } from "@/domain/cart/value-objects/Money"; +import type { Product } from "@/domain/cart/value-objects/Product/Product"; +import { Quantity } from "@/domain/cart/value-objects/Quantity"; + +/** + * First-class collection wrapping the cart's CartItem[]. Cart delegates every + * find/map/filter/reduce over its items here instead of doing them inline. + */ +export class CartItems { + private constructor(private readonly items: readonly CartItem[]) {} + + static empty(): CartItems { + return new CartItems([]); + } + + isEmpty(): boolean { + return this.items.length === 0; + } + + toArray(): readonly CartItem[] { + return this.items; + } + + totalQuantity(): Quantity { + return this.items.reduce( + (total, item) => total.add(item.quantity()), + new Quantity(0), + ); + } + + totalPrice(): Money { + return this.items.reduce((total, item) => total.add(item.totalPrice()), new Money(0)); + } + + addOrIncrement(product: Product): CartItems { + const existing = this.find(product.displayId()); + + if (existing) { + return this.replace(existing.increaseQuantity()); + } + return new CartItems([...this.items, new CartItem(product, new Quantity(1))]); + } + + increaseQuantity(itemId: string): CartItems { + return this.updateItem(itemId, (item) => item.increaseQuantity()); + } + + decreaseQuantity(itemId: string): CartItems { + return this.updateItem(itemId, (item) => item.decreaseQuantity()); + } + + remove(itemId: string): CartItems { + return new CartItems(this.items.filter((item) => item.id() !== itemId)); + } + + private find(itemId: string): CartItem | undefined { + return this.items.find((item) => item.id() === itemId); + } + + private replace(updatedItem: CartItem): CartItems { + return new CartItems( + this.items.map((item) => (item.id() === updatedItem.id() ? updatedItem : item)), + ); + } + + private updateItem(itemId: string, update: (item: CartItem) => CartItem): CartItems { + const existing = this.find(itemId); + + if (!existing) { + return this; + } + return this.replace(update(existing)); + } +} diff --git a/src/domain/cart/__spec__/Cart.spec.ts b/src/domain/cart/__spec__/Cart.spec.ts index e0d3334..d129981 100644 --- a/src/domain/cart/__spec__/Cart.spec.ts +++ b/src/domain/cart/__spec__/Cart.spec.ts @@ -1,108 +1,13 @@ import { describe, expect, it } from "vitest"; -import { Money } from "@/domain/cart/value-objects/Money"; -import { - ImageUrl, - KnowledgeContent, - Product, - ProductId, - ProductName, -} from "@/domain/cart/value-objects/Product/Product"; -import { - Effect, - ExtraResources, - ProfileDetails, - Stats, -} from "@/domain/cart/value-objects/Product/ProductDetails"; import { Cart } from "../Cart"; +import { aProduct } from "./productFixtures"; -const PRODUCT_1 = new Product( - new ProductId("thunderfury"), - new ProductName("Thunderfury, Blessed Blade of the Windseeker"), - new Money(485), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ), - new KnowledgeContent( - new ProfileDetails( - "A legendary sword that channels the power of storms. Wield it to summon lightning upon your foes.", - new Stats(150, 50, 0), - [new Effect("Chance to strike enemies with lightning.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury is forged with elemental fury. Great for battle, with bonus lightning damage and attack speed.", - new Stats(300, 100, 10), - [ - new Effect("Increases attack speed by 15%"), - new Effect("Lightning damage with each hit"), - new Effect("Chance to slow enemies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury, Blessed Blade of the Windseeker, is a legendary weapon of immense power. It channels elemental air, unleashing lightning with each strike. Its artifact status is confirmed by its rarity and crafting requirements.", - new Stats(500, 150, 25), - [ - new Effect("Attack speed +25%"), - new Effect("Unleashes chain lightning on critical strikes"), - new Effect("Chance to silence enemies"), - new Effect("Grants immunity to silence for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - ), -); - -const PRODUCT_2 = new Product( - new ProductId("ashbringer"), - new ProductName("Ashbringer"), - new Money(750), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/a/a6/Ashbringer_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A holy sword blessed to purge the undead. Known for its radiant power.", - new Stats(200, 70, 0), - [new Effect("Bonus against undead enemies.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( - "Ashbringer channels holy energy to smite foes, cleansing corruption and undead.", - new Stats(350, 140, 20), - [new Effect("Holy damage +25%"), new Effect("Undead vulnerability.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( - "Ashbringer is the embodiment of righteousness. It delivers devastating holy strikes and vanquishes evil.", - new Stats(600, 220, 40), - [ - new Effect("Holy damage +50%"), - new Effect("Instant kill chance on undead"), - new Effect("Grant blessing to allies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - ), -); +const PRODUCT_1 = aProduct({ + id: "thunderfury", + name: "Thunderfury, Blessed Blade of the Windseeker", + price: 485, +}); +const PRODUCT_2 = aProduct({ id: "ashbringer", name: "Ashbringer", price: 750 }); describe("Cart", () => { it("should be empty when initialized without items", () => { @@ -115,11 +20,10 @@ describe("Cart", () => { const cartAfterAdd = cart.addItem(PRODUCT_1); expect(cartAfterAdd.isEmpty()).toBe(false); - expect(cartAfterAdd.getItemsCopy().length).toBe(1); - const [addedItem] = cartAfterAdd.getItemsCopy(); + const [addedItem] = cartAfterAdd.listItems(); expect(addedItem).toBeDefined(); - expect(addedItem?.getId()).toBe("thunderfury"); - expect(addedItem?.getQuantity().toValue()).toBe(1); + expect(addedItem?.id()).toBe("thunderfury"); + expect(addedItem?.quantity().toValue()).toBe(1); }); it("should increment quantity if product already exists", () => { @@ -127,36 +31,52 @@ describe("Cart", () => { cart = cart.addItem(PRODUCT_1); cart = cart.addItem(PRODUCT_1); - const [item] = cart.getItemsCopy(); - expect(item?.getQuantity().toValue()).toBe(2); + const [item] = cart.listItems(); + expect(item?.quantity().toValue()).toBe(2); + }); + + it("should increase quantity of an existing item by id", () => { + let cart = new Cart(); + cart = cart.addItem(PRODUCT_1); + cart = cart.increaseQuantity("thunderfury"); + + const [item] = cart.listItems(); + expect(item?.quantity().toValue()).toBe(2); }); - it("should decrement item quantity", () => { + it("should decrease quantity of an existing item by id", () => { let cart = new Cart(); cart = cart.addItem(PRODUCT_1); cart = cart.addItem(PRODUCT_1); - const cartAfterDecrement = cart.decrementItem(PRODUCT_1); - const [item] = cartAfterDecrement.getItemsCopy(); - expect(item?.getQuantity().toValue()).toBe(1); + const cartAfterDecrease = cart.decreaseQuantity("thunderfury"); + const [item] = cartAfterDecrease.listItems(); + expect(item?.quantity().toValue()).toBe(1); }); it("should not decrement below MIN_QUANTITY", () => { const cart = new Cart().addItem(PRODUCT_1); - const cartAfterDecrement = cart.decrementItem(PRODUCT_1); - const [item] = cartAfterDecrement.getItemsCopy(); - expect(item?.getQuantity().toValue()).toBe(1); + const cartAfterDecrease = cart.decreaseQuantity("thunderfury"); + const [item] = cartAfterDecrease.listItems(); + expect(item?.quantity().toValue()).toBe(1); + }); + + it("should do nothing when decreasing or increasing an id that isn't in the cart", () => { + const cart = new Cart().addItem(PRODUCT_1); + + expect(cart.decreaseQuantity("unknown").listItems()).toHaveLength(1); + expect(cart.increaseQuantity("unknown").listItems()).toHaveLength(1); }); - it("should delete item from cart", () => { + it("should remove an item from the cart by id", () => { let cart = new Cart(); cart = cart.addItem(PRODUCT_1); cart = cart.addItem(PRODUCT_2); - const cartAfterDelete = cart.deleteItem(PRODUCT_1); - const [remainingItem] = cartAfterDelete.getItemsCopy(); - expect(cartAfterDelete.getItemsCopy().length).toBe(1); - expect(remainingItem?.getId()).toBe("ashbringer"); + const cartAfterRemoval = cart.removeItem("thunderfury"); + const [remainingItem] = cartAfterRemoval.listItems(); + expect(cartAfterRemoval.listItems()).toHaveLength(1); + expect(remainingItem?.id()).toBe("ashbringer"); }); it("should calculate subtotal correctly", () => { @@ -195,4 +115,16 @@ describe("Cart", () => { expect(tax).toBeCloseTo(86.45); expect(total).toBeCloseTo(1321.45); }); + + it("should report how much is left to reach free shipping", () => { + const cart = new Cart().addItem(aProduct({ id: "cheap", price: 100 })); + + expect(cart.remainingForFreeShipping().toAmount()).toBe(200); + }); + + it("should report nothing left once the free shipping threshold is met", () => { + const cart = new Cart().addItem(aProduct({ id: "expensive", price: 500 })); + + expect(cart.remainingForFreeShipping().toAmount()).toBe(0); + }); }); diff --git a/src/domain/cart/__spec__/CartItem.spec.ts b/src/domain/cart/__spec__/CartItem.spec.ts index aa238e2..3f791b1 100644 --- a/src/domain/cart/__spec__/CartItem.spec.ts +++ b/src/domain/cart/__spec__/CartItem.spec.ts @@ -1,126 +1,70 @@ import { describe, expect, it } from "vitest"; -import { Money } from "@/domain/cart/value-objects/Money"; -import { - ImageUrl, - KnowledgeContent, - Product, - ProductId, - ProductName, -} from "@/domain/cart/value-objects/Product/Product"; -import { - Effect, - ExtraResources, - ProfileDetails, - Stats, -} from "@/domain/cart/value-objects/Product/ProductDetails"; import { Quantity } from "@/domain/cart/value-objects/Quantity"; import { CartItem } from "../CartItem"; +import { aProduct } from "./productFixtures"; -const PRODUCT = new Product( - new ProductId("thunderfury"), - new ProductName("Thunderfury, Blessed Blade of the Windseeker"), - new Money(485), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ), - new KnowledgeContent( - new ProfileDetails( - "A legendary sword that channels the power of storms. Wield it to summon lightning upon your foes.", - new Stats(150, 50, 0), - [new Effect("Chance to strike enemies with lightning.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury is forged with elemental fury. Great for battle, with bonus lightning damage and attack speed.", - new Stats(300, 100, 10), - [ - new Effect("Increases attack speed by 15%"), - new Effect("Lightning damage with each hit"), - new Effect("Chance to slow enemies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury, Blessed Blade of the Windseeker, is a legendary weapon of immense power. It channels elemental air, unleashing lightning with each strike. Its artifact status is confirmed by its rarity and crafting requirements.", - new Stats(500, 150, 25), - [ - new Effect("Attack speed +25%"), - new Effect("Unleashes chain lightning on critical strikes"), - new Effect("Chance to silence enemies"), - new Effect("Grants immunity to silence for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - ), -); +const PRODUCT = aProduct({ + id: "thunderfury", + name: "Thunderfury, Blessed Blade of the Windseeker", + price: 485, + imageUrl: "https://example.com/thunderfury.jpg", +}); describe("CartItem", () => { - // const product = PRODUCT; const initialQuantity = new Quantity(5); const cartItem = new CartItem(PRODUCT, initialQuantity); it("should return correct id, name, image and quantity", () => { - expect(cartItem.getId()).toBe("thunderfury"); - expect(cartItem.getName()).toBe("Thunderfury, Blessed Blade of the Windseeker"); - expect(cartItem.getImage()).toBe( - "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ); - expect(cartItem.getQuantity().toValue()).toBe(5); + expect(cartItem.id()).toBe("thunderfury"); + expect(cartItem.name()).toBe("Thunderfury, Blessed Blade of the Windseeker"); + expect(cartItem.image()).toBe("https://example.com/thunderfury.jpg"); + expect(cartItem.quantity().toValue()).toBe(5); }); it("should increase quantity correctly up to MAX_QUANTITY", () => { const increased = cartItem.increaseQuantity(); - expect(increased.getQuantity().toValue()).toBe(6); + expect(increased.quantity().toValue()).toBe(6); // Increase to max let maxed = increased; for (let i = 0; i < 10; i++) { maxed = maxed.increaseQuantity(); } - expect(maxed.getQuantity().toValue()).toBe(CartItem.MAX_QUANTITY); + expect(maxed.quantity().toValue()).toBe(CartItem.MAX_QUANTITY.toValue()); // Cannot exceed MAX_QUANTITY const stillMax = maxed.increaseQuantity(); - expect(stillMax.getQuantity().toValue()).toBe(CartItem.MAX_QUANTITY); + expect(stillMax.quantity().toValue()).toBe(CartItem.MAX_QUANTITY.toValue()); }); it("should decrease quantity correctly down to MIN_QUANTITY", () => { const decreased = cartItem.decreaseQuantity(); - expect(decreased.getQuantity().toValue()).toBe(4); + expect(decreased.quantity().toValue()).toBe(4); // Decrease to min let minItem = new CartItem(PRODUCT, new Quantity(3)); for (let i = 0; i < 10; i++) { minItem = minItem.decreaseQuantity(); } - expect(minItem.getQuantity().toValue()).toBe(CartItem.MIN_QUANTITY); + expect(minItem.quantity().toValue()).toBe(CartItem.MIN_QUANTITY.toValue()); // Cannot go below MIN_QUANTITY const stillMin = minItem.decreaseQuantity(); - expect(stillMin.getQuantity().toValue()).toBe(CartItem.MIN_QUANTITY); + expect(stillMin.quantity().toValue()).toBe(CartItem.MIN_QUANTITY.toValue()); }); it("should update quantity and clamp between min and max", () => { const tooLow = new Quantity(0); const updatedLow = cartItem.updateQuantity(tooLow); - expect(updatedLow.getQuantity().toValue()).toBe(CartItem.MIN_QUANTITY); + expect(updatedLow.quantity().toValue()).toBe(CartItem.MIN_QUANTITY.toValue()); const tooHigh = new Quantity(50); const updatedHigh = cartItem.updateQuantity(tooHigh); - expect(updatedHigh.getQuantity().toValue()).toBe(CartItem.MAX_QUANTITY); + expect(updatedHigh.quantity().toValue()).toBe(CartItem.MAX_QUANTITY.toValue()); const valid = new Quantity(7); const updatedValid = cartItem.updateQuantity(valid); - expect(updatedValid.getQuantity().toValue()).toBe(7); + expect(updatedValid.quantity().toValue()).toBe(7); }); it("should calculate total price correctly", () => { diff --git a/src/domain/cart/__spec__/productFixtures.ts b/src/domain/cart/__spec__/productFixtures.ts new file mode 100644 index 0000000..705acee --- /dev/null +++ b/src/domain/cart/__spec__/productFixtures.ts @@ -0,0 +1,36 @@ +import { Money } from "@/domain/cart/value-objects/Money"; +import { + ExtraResources, + ImageUrl, + KnowledgeContent, + Product, + ProductId, + ProductName, +} from "@/domain/cart/value-objects/Product/Product"; +import { + Effect, + ProfileDetails, + Stats, +} from "@/domain/cart/value-objects/Product/ProductDetails"; + +/** + * A minimal, valid Product for domain tests that don't care about its knowledge content, + * only its identity and price. Cart.spec.ts and CartItem.spec.ts both used to build this + * by hand, repeating the same five-level nesting for every fixture. + */ +export function aProduct( + overrides: Partial<{ id: string; name: string; price: number; imageUrl: string }> = {}, +): Product { + const profile = new ProfileDetails("A test product profile.", new Stats(1, 1, 1), [ + new Effect("Test effect"), + ]); + + return new Product( + new ProductId(overrides.id ?? "test-product"), + new ProductName(overrides.name ?? "Test Product"), + new Money(overrides.price ?? 100), + new ImageUrl(overrides.imageUrl ?? "https://example.com/image.png"), + new KnowledgeContent(profile, profile, profile), + new ExtraResources("https://example.com/lore", "https://example.com/video"), + ); +} diff --git a/src/domain/cart/policy/ShippingPolicy.ts b/src/domain/cart/policy/ShippingPolicy.ts index 1741f43..187aaa6 100644 --- a/src/domain/cart/policy/ShippingPolicy.ts +++ b/src/domain/cart/policy/ShippingPolicy.ts @@ -6,7 +6,7 @@ export class ShippingPolicy { static readonly SHIPPING_COST = new Money(COMMERCE_CONFIG.shippingCost); static calculate(subtotal: Money): Money { - return subtotal.toAmount() >= ShippingPolicy.SHIPPING_THRESHOLD.toAmount() + return subtotal.isAtLeast(ShippingPolicy.SHIPPING_THRESHOLD) ? new Money(0) : ShippingPolicy.SHIPPING_COST; } diff --git a/src/domain/cart/value-objects/Money.ts b/src/domain/cart/value-objects/Money.ts index cfb19dd..57ead98 100644 --- a/src/domain/cart/value-objects/Money.ts +++ b/src/domain/cart/value-objects/Money.ts @@ -1,5 +1,5 @@ export class Money { - constructor(private _amount: number) { + constructor(private readonly _amount: number) { if (_amount < 0) { throw new Error("Money amount cannot be negative"); } @@ -23,6 +23,14 @@ export class Money { return new Money(this._amount * factor); } + isAtLeast(other: Money): boolean { + return this._amount >= other._amount; + } + + isZero(): boolean { + return this._amount === 0; + } + toAmount(): number { return this._amount; } diff --git a/src/domain/cart/value-objects/Product/Product.ts b/src/domain/cart/value-objects/Product/Product.ts index 8ce8250..32094ab 100644 --- a/src/domain/cart/value-objects/Product/Product.ts +++ b/src/domain/cart/value-objects/Product/Product.ts @@ -1,5 +1,6 @@ +import type { SkillValue } from "@/domain/welcomeSurvey/value-objects/Skill"; import type { Money } from "../Money"; -import type { ProfileDetails } from "./ProductDetails"; +import type { NamedStat, ProfileDetails } from "./ProductDetails"; export class ProductId { constructor(private readonly _value: string) { @@ -31,40 +32,53 @@ export class ImageUrl { } } -export type KnowledgeProfile = "beginner" | "adventurer" | "expert"; +/** + * A product's lore page and video, shared across every knowledge level: they describe + * the item itself, not a skill-specific rendering of it. + */ +export class ExtraResources { + constructor( + private readonly _loreLink: string, + private readonly _videoUrl: string, + ) {} + + getLoreLink(): string { + return this._loreLink; + } + + getVideoUrl(): string { + return this._videoUrl; + } +} + +/** + * The same vocabulary as WelcomeSurvey's SkillValue: a shopper's stated skill and the + * knowledge tier a product's content is written for are the same concept. + */ +export type KnowledgeProfile = SkillValue; export class KnowledgeContent { constructor( private readonly _beginnerProfile: ProfileDetails, - private readonly _adventurerProfile: ProfileDetails, + private readonly _intermediateProfile: ProfileDetails, private readonly _expertProfile: ProfileDetails, ) {} - getProfileDescription(level: KnowledgeProfile): string { + describeProfile(level: KnowledgeProfile): string { return this.getProfile(level).describe(); } - getProfileEffects(level: KnowledgeProfile): string[] { + listProfileEffects(level: KnowledgeProfile): string[] { return this.getProfile(level).listEffects(); } - getProfileStats( - level: KnowledgeProfile, - ): Record<"power" | "durability" | "manaBoost", number> { - return this.getProfile(level).getStats(); - } - - getProfileLoreLink(level: KnowledgeProfile): string { - return this.getProfile(level).getLoreLink(); - } - - getProfileVideoUrl(level: KnowledgeProfile): string { - return this.getProfile(level).getVideoUrl(); + listProfileStats(level: KnowledgeProfile): NamedStat[] { + return this.getProfile(level).listStats(); } private getProfile(level: KnowledgeProfile): ProfileDetails { if (level === "beginner") return this._beginnerProfile; - if (level === "adventurer") return this._adventurerProfile; + if (level === "intermediate") return this._intermediateProfile; return this._expertProfile; } } @@ -76,6 +90,7 @@ export class Product { private readonly _price: Money, private readonly _imageUrl: ImageUrl, private readonly _knowledge: KnowledgeContent, + private readonly _extraResources: ExtraResources, ) {} displayId(): string { @@ -95,24 +110,22 @@ export class Product { } describeProfile(level: KnowledgeProfile): string { - return this._knowledge.getProfileDescription(level); + return this._knowledge.describeProfile(level); } listProfileEffects(level: KnowledgeProfile): string[] { - return this._knowledge.getProfileEffects(level); + return this._knowledge.listProfileEffects(level); } - getProfileStats( - level: KnowledgeProfile, - ): Record<"power" | "durability" | "manaBoost", number> { - return this._knowledge.getProfileStats(level); + listProfileStats(level: KnowledgeProfile): NamedStat[] { + return this._knowledge.listProfileStats(level); } - getLoreLink(level: KnowledgeProfile): string { - return this._knowledge.getProfileLoreLink(level); + getLoreLink(): string { + return this._extraResources.getLoreLink(); } - getVideoUrl(level: KnowledgeProfile): string { - return this._knowledge.getProfileVideoUrl(level); + getVideoUrl(): string { + return this._extraResources.getVideoUrl(); } } diff --git a/src/domain/cart/value-objects/Product/ProductDetails.ts b/src/domain/cart/value-objects/Product/ProductDetails.ts index 21307f1..46cbf2f 100644 --- a/src/domain/cart/value-objects/Product/ProductDetails.ts +++ b/src/domain/cart/value-objects/Product/ProductDetails.ts @@ -6,6 +6,11 @@ export class Effect { } } +export interface NamedStat { + label: string; + value: number; +} + export class Stats { constructor( private readonly _power: number, @@ -21,55 +26,37 @@ export class Stats { return new Stats(this._power + amount, this._durability, this._manaBoost); } - asObject(): Record<"power" | "durability" | "manaBoost", number> { - return { - power: this._power, - durability: this._durability, - manaBoost: this._manaBoost, - }; - } -} - -export class ExtraResources { - constructor( - private readonly _loreLink: string, - private readonly _videoUrl: string, - ) {} - - getLoreLink(): string { - return this._loreLink; - } - - getVideoUrl(): string { - return this._videoUrl; + /** Only the stats worth showing: zero-value ones are omitted rather than rendered as 0. */ + list(): NamedStat[] { + return [ + { label: "Power", value: this._power }, + { label: "Durability", value: this._durability }, + { label: "Mana Boost", value: this._manaBoost }, + ].filter((stat) => stat.value > 0); } } +/** + * The description, stats, and effects for one knowledge level of a product. A product's + * lore link and video live on Product itself (see ExtraResources), they don't vary by + * knowledge level. + */ export class ProfileDetails { constructor( private readonly _description: string, private readonly _stats: Stats, private readonly _effects: Effect[], - private readonly _extraResources: ExtraResources, ) {} describe(): string { return this._description; } - getStats(): Record<"power" | "durability" | "manaBoost", number> { - return this._stats.asObject(); + listStats(): NamedStat[] { + return this._stats.list(); } listEffects(): string[] { return this._effects.map((effect) => effect.toValue()); } - - getLoreLink(): string { - return this._extraResources.getLoreLink(); - } - - getVideoUrl(): string { - return this._extraResources.getVideoUrl(); - } } diff --git a/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts b/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts index ee97d60..f5b3c85 100644 --- a/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts +++ b/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { Money } from "../../Money"; -import { ImageUrl, KnowledgeContent, Product, ProductId, ProductName } from "../Product"; -import { Effect, ExtraResources, ProfileDetails, Stats } from "../ProductDetails"; +import { + ExtraResources, + ImageUrl, + KnowledgeContent, + Product, + ProductId, + ProductName, +} from "../Product"; +import { Effect, ProfileDetails, Stats } from "../ProductDetails"; describe("Product Value Objects", () => { it("should create a ProductId and return value", () => { @@ -21,28 +28,31 @@ describe("Product Value Objects", () => { expect(imageUrl.toValue()).toBe("http://image.link"); expect(() => new ImageUrl("")).toThrow("Image URL cannot be empty"); }); + + it("should return the lore link and video URL", () => { + const resources = new ExtraResources("https://lore.link", "https://video.link"); + expect(resources.getLoreLink()).toBe("https://lore.link"); + expect(resources.getVideoUrl()).toBe("https://video.link"); + }); }); describe("KnowledgeContent and Product", () => { const statsByLevel = { beginner: { power: 1, durability: 2, manaBoost: 3 }, - adventurer: { power: 4, durability: 5, manaBoost: 6 }, + intermediate: { power: 4, durability: 5, manaBoost: 6 }, expert: { power: 7, durability: 8, manaBoost: 9 }, } as const; - const profileFor = (level: "beginner" | "adventurer" | "expert") => { + const profileFor = (level: "beginner" | "intermediate" | "expert") => { const { power, durability, manaBoost } = statsByLevel[level]; - return new ProfileDetails( - `${level} tale`, - new Stats(power, durability, manaBoost), - [new Effect(`${level} effect`)], - new ExtraResources(`https://lore.link/${level}`, `https://video.link/${level}`), - ); + return new ProfileDetails(`${level} tale`, new Stats(power, durability, manaBoost), [ + new Effect(`${level} effect`), + ]); }; const knowledge = new KnowledgeContent( profileFor("beginner"), - profileFor("adventurer"), + profileFor("intermediate"), profileFor("expert"), ); @@ -52,6 +62,7 @@ describe("KnowledgeContent and Product", () => { new Money(500), new ImageUrl("http://image.link"), knowledge, + new ExtraResources("https://lore.link/excalibur", "https://video.link/excalibur"), ); it("should return correct product info", () => { @@ -61,14 +72,23 @@ describe("KnowledgeContent and Product", () => { expect(product.displayImage()).toBe("http://image.link"); }); - it.each(["beginner", "adventurer", "expert"] as const)( - "should route %s to its own profile's description, effects, stats, lore link, and video URL", + it("should return the same lore link and video URL regardless of knowledge level", () => { + expect(product.getLoreLink()).toBe("https://lore.link/excalibur"); + expect(product.getVideoUrl()).toBe("https://video.link/excalibur"); + }); + + it.each(["beginner", "intermediate", "expert"] as const)( + "should route %s to its own profile's description, effects, and stats", (level) => { + const { power, durability, manaBoost } = statsByLevel[level]; + expect(product.describeProfile(level)).toBe(`${level} tale`); expect(product.listProfileEffects(level)).toEqual([`${level} effect`]); - expect(product.getProfileStats(level)).toEqual(statsByLevel[level]); - expect(product.getLoreLink(level)).toBe(`https://lore.link/${level}`); - expect(product.getVideoUrl(level)).toBe(`https://video.link/${level}`); + expect(product.listProfileStats(level)).toEqual([ + { label: "Power", value: power }, + { label: "Durability", value: durability }, + { label: "Mana Boost", value: manaBoost }, + ]); }, ); }); diff --git a/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts b/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts index 287ce45..fe91eb3 100644 --- a/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts +++ b/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts @@ -1,35 +1,26 @@ import { describe, expect, it } from "vitest"; -import { Effect, ExtraResources, ProfileDetails, Stats } from "../ProductDetails"; +import { Effect, ProfileDetails, Stats } from "../ProductDetails"; describe("ProfileDetails", () => { const stats = new Stats(10, 20, 5); const effects = [new Effect("Fire damage"), new Effect("Stun enemies")]; - const extraResources = new ExtraResources("https://lore.link", "https://video.link"); - const profile = new ProfileDetails("Test description", stats, effects, extraResources); + const profile = new ProfileDetails("Test description", stats, effects); it("should return the correct description", () => { expect(profile.describe()).toBe("Test description"); }); - it("should return stats as object", () => { - expect(profile.getStats()).toEqual({ - power: 10, - durability: 20, - manaBoost: 5, - }); + it("should return the non-zero stats", () => { + expect(profile.listStats()).toEqual([ + { label: "Power", value: 10 }, + { label: "Durability", value: 20 }, + { label: "Mana Boost", value: 5 }, + ]); }); it("should return a list of effect values", () => { expect(profile.listEffects()).toEqual(["Fire damage", "Stun enemies"]); }); - - it("should return the correct lore link", () => { - expect(profile.getLoreLink()).toBe("https://lore.link"); - }); - - it("should return the correct video URL", () => { - expect(profile.getVideoUrl()).toBe("https://video.link"); - }); }); describe("Stats", () => { @@ -40,16 +31,25 @@ describe("Stats", () => { it("should increase power correctly", () => { const originalStats = new Stats(5, 10, 15); const newStats = originalStats.increasePower(7); - expect(newStats.asObject()).toEqual({ - power: 12, - durability: 10, - manaBoost: 15, - }); + expect(newStats.list()).toEqual([ + { label: "Power", value: 12 }, + { label: "Durability", value: 10 }, + { label: "Mana Boost", value: 15 }, + ]); }); - it("should return stats as an object", () => { + it("should list every stat that is greater than zero", () => { const stats = new Stats(3, 6, 9); - expect(stats.asObject()).toEqual({ power: 3, durability: 6, manaBoost: 9 }); + expect(stats.list()).toEqual([ + { label: "Power", value: 3 }, + { label: "Durability", value: 6 }, + { label: "Mana Boost", value: 9 }, + ]); + }); + + it("should omit stats that are zero", () => { + const stats = new Stats(3, 0, 0); + expect(stats.list()).toEqual([{ label: "Power", value: 3 }]); }); }); @@ -59,11 +59,3 @@ describe("Effect", () => { expect(effect.toValue()).toBe("Stun enemies"); }); }); - -describe("ExtraResources", () => { - it("should return the lore link and video URL", () => { - const resources = new ExtraResources("https://lore.link", "https://video.link"); - expect(resources.getLoreLink()).toBe("https://lore.link"); - expect(resources.getVideoUrl()).toBe("https://video.link"); - }); -}); diff --git a/src/domain/cart/value-objects/Quantity.ts b/src/domain/cart/value-objects/Quantity.ts index df85448..0e637f1 100644 --- a/src/domain/cart/value-objects/Quantity.ts +++ b/src/domain/cart/value-objects/Quantity.ts @@ -1,5 +1,5 @@ export class Quantity { - constructor(private _value: number) { + constructor(private readonly _value: number) { if (_value < 0) { throw new Error("Quantity cannot be negative"); } @@ -20,6 +20,20 @@ export class Quantity { return new Quantity(this._value - 1); } + clampBetween(min: Quantity, max: Quantity): Quantity { + if (this._value < min._value) return min; + if (this._value > max._value) return max; + return this; + } + + isAtLeast(other: Quantity): boolean { + return this._value >= other._value; + } + + isAtMost(other: Quantity): boolean { + return this._value <= other._value; + } + toValue(): number { return this._value; } diff --git a/src/domain/cart/value-objects/__spec__/Money.spec.ts b/src/domain/cart/value-objects/__spec__/Money.spec.ts index 8b7e149..86fa525 100644 --- a/src/domain/cart/value-objects/__spec__/Money.spec.ts +++ b/src/domain/cart/value-objects/__spec__/Money.spec.ts @@ -59,6 +59,17 @@ describe("Money", () => { expect(multipliedMoney.toAmount()).toBe(120); }); + it("should report whether it is at least another amount", () => { + expect(new Money(100).isAtLeast(new Money(100))).toBe(true); + expect(new Money(101).isAtLeast(new Money(100))).toBe(true); + expect(new Money(99).isAtLeast(new Money(100))).toBe(false); + }); + + it("should report whether it is zero", () => { + expect(new Money(0).isZero()).toBe(true); + expect(new Money(1).isZero()).toBe(false); + }); + it("should support chained operations", () => { const startingMoney = new Money(50); const resultMoney = startingMoney diff --git a/src/domain/cart/value-objects/__spec__/Quantity.spec.ts b/src/domain/cart/value-objects/__spec__/Quantity.spec.ts index feaee7c..4d2fe6f 100644 --- a/src/domain/cart/value-objects/__spec__/Quantity.spec.ts +++ b/src/domain/cart/value-objects/__spec__/Quantity.spec.ts @@ -38,6 +38,32 @@ describe("Quantity", () => { expect(() => zeroQuantity.decrement()).toThrowError("Quantity cannot be less than 0"); }); + it("should report whether it is at least or at most another quantity", () => { + expect(new Quantity(5).isAtLeast(new Quantity(5))).toBe(true); + expect(new Quantity(6).isAtLeast(new Quantity(5))).toBe(true); + expect(new Quantity(4).isAtLeast(new Quantity(5))).toBe(false); + + expect(new Quantity(5).isAtMost(new Quantity(5))).toBe(true); + expect(new Quantity(4).isAtMost(new Quantity(5))).toBe(true); + expect(new Quantity(6).isAtMost(new Quantity(5))).toBe(false); + }); + + it("should clamp to the minimum when below it", () => { + const clamped = new Quantity(0).clampBetween(new Quantity(1), new Quantity(10)); + expect(clamped.toValue()).toBe(1); + }); + + it("should clamp to the maximum when above it", () => { + const clamped = new Quantity(50).clampBetween(new Quantity(1), new Quantity(10)); + expect(clamped.toValue()).toBe(10); + }); + + it("should pass through unchanged when already within bounds", () => { + const withinBounds = new Quantity(7); + const clamped = withinBounds.clampBetween(new Quantity(1), new Quantity(10)); + expect(clamped).toBe(withinBounds); + }); + it("should be immutable (methods return a new instance)", () => { const baseQuantity = new Quantity(3); const incrementedQuantity = baseQuantity.increment(); diff --git a/src/domain/currency/GoldSilverCopperAmount.ts b/src/domain/currency/GoldSilverCopperAmount.ts new file mode 100644 index 0000000..3c96be6 --- /dev/null +++ b/src/domain/currency/GoldSilverCopperAmount.ts @@ -0,0 +1,50 @@ +import { COMMERCE_CONFIG } from "@/config/commerce.config"; +import type { Money } from "@/domain/cart/value-objects/Money"; + +const { copperPerSilver, silverPerGold } = COMMERCE_CONFIG.currency; +const copperPerGold = copperPerSilver * silverPerGold; + +/** + * The gold/silver/copper breakdown of a copper-denominated Money amount. Converting + * copper into denominations is this class's only job, formatting the result is + * GoldSilverCopperFormatter's. + */ +export class GoldSilverCopperAmount { + private constructor( + private readonly _gold: number, + private readonly _silver: number, + private readonly _copper: number, + ) {} + + static fromCopper(amount: Money): GoldSilverCopperAmount { + const safeCopper = Math.round(amount.toAmount()); + + const gold = Math.floor(safeCopper / copperPerGold); + const remainderAfterGold = safeCopper - gold * copperPerGold; + + const silver = Math.floor(remainderAfterGold / copperPerSilver); + const remainingCopper = remainderAfterGold - silver * copperPerSilver; + + return new GoldSilverCopperAmount(gold, silver, remainingCopper); + } + + gold(): number { + return this._gold; + } + + silver(): number { + return this._silver; + } + + copper(): number { + return this._copper; + } + + hasGold(): boolean { + return this._gold > 0; + } + + hasSilver(): boolean { + return this._silver > 0; + } +} diff --git a/src/domain/currency/GoldSilverCopperFormatter.ts b/src/domain/currency/GoldSilverCopperFormatter.ts index 5271074..0c102f4 100644 --- a/src/domain/currency/GoldSilverCopperFormatter.ts +++ b/src/domain/currency/GoldSilverCopperFormatter.ts @@ -1,36 +1,21 @@ -import { COMMERCE_CONFIG } from "@/config/commerce.config"; import type { Money } from "@/domain/cart/value-objects/Money"; - -const { copperPerSilver, silverPerGold } = COMMERCE_CONFIG.currency; -const copperPerGold = copperPerSilver * silverPerGold; +import { GoldSilverCopperAmount } from "@/domain/currency/GoldSilverCopperAmount"; /** - * Converts copper-based Money values into Azeroth-style currency - * (Gold, Silver, Copper) and formats them for display. + * Formats a copper-based Money value as Azeroth-style currency (Gold, Silver, Copper). + * Conversion itself lives in GoldSilverCopperAmount, this class only renders it. */ class GoldSilverCopperFormatter { - convertCopper(copper: Money) { - const safeCopper = Math.round(copper.toAmount()); - - const gold = Math.floor(safeCopper / copperPerGold); - const remainderAfterGold = safeCopper - gold * copperPerGold; - - const silver = Math.floor(remainderAfterGold / copperPerSilver); - const remainingCopper = remainderAfterGold - silver * copperPerSilver; - - return { gold, silver, copper: remainingCopper }; - } - format(amount: Money): string { - const { gold, silver, copper } = this.convertCopper(amount); - - const formatted: string[] = []; + const converted = GoldSilverCopperAmount.fromCopper(amount); + const parts: string[] = []; - if (gold > 0) formatted.push(`${gold} 🟡`); - if (silver > 0 || gold > 0) formatted.push(`${silver} ⚪`); - formatted.push(`${copper} 🟤`); + if (converted.hasGold()) parts.push(`${converted.gold()} 🟡`); + if (converted.hasSilver() || converted.hasGold()) + parts.push(`${converted.silver()} ⚪`); + parts.push(`${converted.copper()} 🟤`); - return formatted.join(" "); + return parts.join(" "); } } diff --git a/src/domain/currency/__spec__/GoldSilverCopperAmount.spec.ts b/src/domain/currency/__spec__/GoldSilverCopperAmount.spec.ts new file mode 100644 index 0000000..ec6aa5b --- /dev/null +++ b/src/domain/currency/__spec__/GoldSilverCopperAmount.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { Money } from "@/domain/cart/value-objects/Money"; +import { GoldSilverCopperAmount } from "../GoldSilverCopperAmount"; + +describe("GoldSilverCopperAmount", () => { + it("should convert an amount under one silver into copper only", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(42)); + + expect(amount.gold()).toBe(0); + expect(amount.silver()).toBe(0); + expect(amount.copper()).toBe(42); + }); + + it("should convert an amount under one gold into silver and copper", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(1234)); + + expect(amount.gold()).toBe(0); + expect(amount.silver()).toBe(12); + expect(amount.copper()).toBe(34); + }); + + it("should convert a large amount into gold, silver, and copper", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(115105)); + + expect(amount.gold()).toBe(11); + expect(amount.silver()).toBe(51); + expect(amount.copper()).toBe(5); + }); + + it("should round fractional copper before converting", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(99.6)); + + expect(amount.gold()).toBe(0); + expect(amount.silver()).toBe(1); + expect(amount.copper()).toBe(0); + }); + + it("should report whether it has gold or silver", () => { + expect(GoldSilverCopperAmount.fromCopper(new Money(0)).hasGold()).toBe(false); + expect(GoldSilverCopperAmount.fromCopper(new Money(0)).hasSilver()).toBe(false); + expect(GoldSilverCopperAmount.fromCopper(new Money(10000)).hasGold()).toBe(true); + expect(GoldSilverCopperAmount.fromCopper(new Money(100)).hasSilver()).toBe(true); + }); +}); diff --git a/src/domain/currency/__spec__/GoldSilverCopperFormatter.spec.ts b/src/domain/currency/__spec__/GoldSilverCopperFormatter.spec.ts new file mode 100644 index 0000000..48b6458 --- /dev/null +++ b/src/domain/currency/__spec__/GoldSilverCopperFormatter.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { Money } from "@/domain/cart/value-objects/Money"; +import { goldSilverCopperFormatter } from "../GoldSilverCopperFormatter"; + +describe("goldSilverCopperFormatter", () => { + it("should show only copper when there is no gold or silver", () => { + expect(goldSilverCopperFormatter.format(new Money(0))).toBe("0 🟤"); + expect(goldSilverCopperFormatter.format(new Money(42))).toBe("42 🟤"); + }); + + it("should show silver and copper once there is at least one silver", () => { + expect(goldSilverCopperFormatter.format(new Money(1234))).toBe("12 ⚪ 34 🟤"); + }); + + it("should show gold, silver, and copper for large amounts", () => { + expect(goldSilverCopperFormatter.format(new Money(115105))).toBe("11 🟡 51 ⚪ 5 🟤"); + }); + + it("should still show silver when it is 0 but gold is present", () => { + expect(goldSilverCopperFormatter.format(new Money(10005))).toBe("1 🟡 0 ⚪ 5 🟤"); + }); +}); diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts b/src/domain/welcomeSurvey/infrastructure/LocalStorageWelcomeSurvey.repository.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts rename to src/domain/welcomeSurvey/infrastructure/LocalStorageWelcomeSurvey.repository.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts b/src/domain/welcomeSurvey/infrastructure/TanstackStoreWelcomeSurvey.repository.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts rename to src/domain/welcomeSurvey/infrastructure/TanstackStoreWelcomeSurvey.repository.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/ZustandWelcomeSurvey.repository.ts b/src/domain/welcomeSurvey/infrastructure/ZustandWelcomeSurvey.repository.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/ZustandWelcomeSurvey.repository.ts rename to src/domain/welcomeSurvey/infrastructure/ZustandWelcomeSurvey.repository.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts b/src/domain/welcomeSurvey/infrastructure/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts rename to src/domain/welcomeSurvey/infrastructure/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts b/src/domain/welcomeSurvey/infrastructure/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts rename to src/domain/welcomeSurvey/infrastructure/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts diff --git a/src/domain/welcomeSurvey/infrastructure/__spec__/ZustandWelcomeSurvey.repository.spec.ts b/src/domain/welcomeSurvey/infrastructure/__spec__/ZustandWelcomeSurvey.repository.spec.ts new file mode 100644 index 0000000..9a60a0f --- /dev/null +++ b/src/domain/welcomeSurvey/infrastructure/__spec__/ZustandWelcomeSurvey.repository.spec.ts @@ -0,0 +1,35 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; +import { ZustandWelcomeSurveyRepository } from "../ZustandWelcomeSurvey.repository"; + +describe("ZustandWelcomeSurveyRepository", () => { + it("should save and read back a survey", () => { + const repository = new ZustandWelcomeSurveyRepository(); + + repository.saveSurvey({ skill: "expert" }); + + expect(repository.getSurvey()).toEqual({ skill: "expert" }); + }); + + it("should share state across instances, same as the other repositories", () => { + const writer = new ZustandWelcomeSurveyRepository(); + writer.saveSurvey({ skill: "intermediate" }); + + const reader = new ZustandWelcomeSurveyRepository(); + + expect(reader.getSurvey()).toEqual({ skill: "intermediate" }); + }); + + it("should notify subscribers when a survey is saved", () => { + const repository = new ZustandWelcomeSurveyRepository(); + const listener = vi.fn(); + const unsubscribe = repository.subscribe(listener); + + repository.saveSurvey({ skill: "beginner" }); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + repository.saveSurvey({ skill: "expert" }); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/globals.d.ts b/src/globals.d.ts index 54f14ff..69dd9fd 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -2,4 +2,13 @@ interface Umami { track: (eventName: string, props?: Record) => void; } -declare const umami: Umami | undefined; +declare global { + interface Window { + // Set by the Umami script tag in index.html, absent if it hasn't loaded yet, is + // blocked, or failed to load: always access it through `window.umami?.`, never as a + // bare `umami` identifier, which throws ReferenceError when the script never ran. + umami?: Umami; + } +} + +export {}; diff --git a/src/ui/components/WelcomeModal/WelcomeModal.tsx b/src/ui/components/WelcomeModal/WelcomeModal.tsx index 72c427a..b424288 100644 --- a/src/ui/components/WelcomeModal/WelcomeModal.tsx +++ b/src/ui/components/WelcomeModal/WelcomeModal.tsx @@ -91,7 +91,7 @@ const WelcomeModalComponent = ( if (!safeValue) return; field.handleChange(safeValue); - umami?.track(ANALYTICS_CONFIG.events.selectKnowledgeLevel, { + window.umami?.track(ANALYTICS_CONFIG.events.selectKnowledgeLevel, { level: safeValue, }); }} diff --git a/src/ui/components/WelcomeModal/__spec__/WelcomeModal.spec.tsx b/src/ui/components/WelcomeModal/__spec__/WelcomeModal.spec.tsx new file mode 100644 index 0000000..96ec170 --- /dev/null +++ b/src/ui/components/WelcomeModal/__spec__/WelcomeModal.spec.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { welcomeSurveyStorage } from "@/config/storage.config"; +import { WelcomeModal } from "@/ui/components/WelcomeModal/WelcomeModal"; + +function Wrapper() { + const { ref } = WelcomeModal.useWelcomeModalHandle(); + + return ( + + ); +} + +describe("WelcomeModal", () => { + beforeEach(() => { + welcomeSurveyStorage.saveSurvey(null); + }); + + it("should open automatically when no survey has been saved yet", () => { + render(); + + expect(screen.getByText("Choose Your Knowledge Level")).toBeInTheDocument(); + }); + + it("should not auto-open once a survey already exists", () => { + welcomeSurveyStorage.saveSurvey({ skill: "expert" }); + + render(); + + expect(screen.queryByText("Choose Your Knowledge Level")).not.toBeInTheDocument(); + }); + + it("should save the selected skill and close the modal", async () => { + const user = userEvent.setup(); + render(); + + await user.selectOptions(screen.getByRole("combobox"), "intermediate"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(welcomeSurveyStorage.getSurvey()).toEqual({ skill: "intermediate" }); + expect(screen.queryByText("Choose Your Knowledge Level")).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/cart/ProductCard/ProductCard.logic.ts b/src/ui/components/cart/ProductCard/ProductCard.logic.ts deleted file mode 100644 index dec1f8c..0000000 --- a/src/ui/components/cart/ProductCard/ProductCard.logic.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { KnowledgeProfile } from "@/domain/cart/value-objects/Product/Product"; -import type { WelcomeSurveyData } from "@/domain/welcomeSurvey/WelcomeSurvey.data"; - -export const ProductCardLogic = { - selectProfile: (skill: WelcomeSurveyData["skill"]): KnowledgeProfile => { - switch (skill) { - case "expert": - return "expert"; - case "intermediate": - return "adventurer"; - default: - return "beginner"; - } - }, -}; diff --git a/src/ui/components/cart/ProductCard/ProductCardContent.tsx b/src/ui/components/cart/ProductCard/ProductCardContent.tsx index 380347d..bf3da55 100644 --- a/src/ui/components/cart/ProductCard/ProductCardContent.tsx +++ b/src/ui/components/cart/ProductCard/ProductCardContent.tsx @@ -19,14 +19,14 @@ interface ProductCardContentProps { } const STAT_ICONS: Record = { - power: Swords, - durability: Shield, + Power: Swords, + Durability: Shield, }; export function ProductCardContent({ product, profile }: ProductCardContentProps) { const description = product.describeProfile(profile); const effects = product.listProfileEffects(profile); - const stats = product.getProfileStats(profile); + const stats = product.listProfileStats(profile); return ( @@ -35,22 +35,22 @@ export function ProductCardContent({ product, profile }: ProductCardContentProps {description}

- {profile !== "beginner" && ( + {profile !== "beginner" && stats.length > 0 && (

    - {Object.entries(stats).map(([key, value]) => { - const StatIcon = STAT_ICONS[key] ?? Wand2; + {stats.map(({ label, value }) => { + const StatIcon = STAT_ICONS[label] ?? Wand2; return ( -
  • +
  • ); })} diff --git a/src/ui/components/cart/ProductCard/ProductCardFooter.tsx b/src/ui/components/cart/ProductCard/ProductCardFooter.tsx index eac7e77..94b95ad 100644 --- a/src/ui/components/cart/ProductCard/ProductCardFooter.tsx +++ b/src/ui/components/cart/ProductCard/ProductCardFooter.tsx @@ -1,4 +1,5 @@ import { BookOpenText, Clapperboard, ShoppingCart } from "lucide-react"; +import { ANALYTICS_CONFIG } from "@/config/analytics.config"; import type { KnowledgeProfile, Product, @@ -25,15 +26,15 @@ export function ProductCardFooter({ profile, onAddToCart, }: ProductCardFooterProps) { - const loreLink = product.getLoreLink(profile); - const videoUrl = product.getVideoUrl(profile); + const loreLink = product.getLoreLink(); + const videoUrl = product.getVideoUrl(); return ( @@ -76,7 +77,7 @@ export function ShoppingCartItem({ className="px-3 py-1 min-w-[30px] text-center text-sm font-medium" aria-live="polite" > - {item.getQuantity().toValue()} + {item.quantity().toValue()} @@ -86,9 +87,9 @@ export function ShoppingCartItem({ variant="ghost" size="icon" className="h-9 w-9 p-0 rounded-l-none hover:bg-gray-100" - aria-label={`Increase quantity of ${item.getName()}`} - disabled={item.getQuantity().toValue() >= CartItem.MAX_QUANTITY} - data-umami-event="shopping-cart.increase-quantity" + aria-label={`Increase quantity of ${item.name()}`} + disabled={item.quantity().isAtLeast(CartItem.MAX_QUANTITY)} + data-umami-event={ANALYTICS_CONFIG.events.increaseQuantity} >