generated from coulomb/repo-seed
chore: Fixed line endings i tutorials and provided Introduction
This commit is contained in:
199
INTRODUCTION.md
Normal file
199
INTRODUCTION.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# 🧪 Introduction to Test-Driven-UI Development
|
||||||
|
|
||||||
|
Here’s an introduction to the **TestDrive-UI development philosophy**.
|
||||||
|
|
||||||
|
It explains *how to develop UI components the TestDrive-UI way*, why each tool is part of the stack, and links directly to their official projects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### “Design the test first — let the interface emerge”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 What is TestDrive-UI?
|
||||||
|
|
||||||
|
**TestDrive-UI** is a lightweight, browser-first development scaffold for building interactive web components using a **Test-Driven Development (TDD)** workflow.
|
||||||
|
|
||||||
|
It provides a reproducible structure that’s simple enough for hobby projects, but robust enough for AI-assisted, agent-driven development loops.
|
||||||
|
|
||||||
|
> **Core idea:**
|
||||||
|
> Every UI feature begins as a **behavioral test** — not a design or mockup.
|
||||||
|
> The implementation grows only to satisfy that test.
|
||||||
|
> The UI emerges naturally from verified expectations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 The Toolchain Overview
|
||||||
|
|
||||||
|
| Tool | Purpose | Why it’s used | Website |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| 🧩 **[Lit](https://lit.dev/)** | Define Web Components with declarative rendering | Fast, framework-agnostic, minimal build overhead; lets you create reusable, encapsulated components | [https://lit.dev](https://lit.dev) |
|
||||||
|
| 🧪 **[Mocha](https://mochajs.org/)** | Test runner for JavaScript | Mature, flexible, CLI-friendly — ideal for writing descriptive, behavior-oriented tests (`describe` / `it`) | [https://mochajs.org](https://mochajs.org) |
|
||||||
|
| 💬 **[Chai](https://www.chaijs.com/)** | Assertion library | Clean, readable syntax for expectations (`expect(value).to.equal(...)`) | [https://www.chaijs.com](https://www.chaijs.com) |
|
||||||
|
| 🧠 **[JSDOM](https://github.com/jsdom/jsdom)** | Simulated DOM for Node | Lets you run DOM-based UI tests without a browser; deterministic and CI-friendly | [https://github.com/jsdom/jsdom](https://github.com/jsdom/jsdom) |
|
||||||
|
| ⚡ **[Vite](https://vitejs.dev/)** | Development server & bundler | Instant hot-reloads and minimal config for web-component projects | [https://vitejs.dev](https://vitejs.dev) |
|
||||||
|
| 🧱 **[Storybook](https://storybook.js.org/)** (optional) | Visual component sandbox | Lets you document, preview, and visually test each component in isolation | [https://storybook.js.org](https://storybook.js.org) |
|
||||||
|
| 🔬 **[Playwright](https://playwright.dev/)** (optional) | End-to-end browser testing | Adds full browser automation for real UI validation | [https://playwright.dev](https://playwright.dev) |
|
||||||
|
|
||||||
|
Together, these tools form the **TestDrive-UI loop**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Requirement → Test (Mocha+Chai) → Implementation (Lit) → Run (Vite) → Refine → Repeat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 How the Development Flow Works
|
||||||
|
|
||||||
|
### 1. Define the Behavior
|
||||||
|
|
||||||
|
Write a short **requirement statement** or JSON spec describing what the component *should do* — not how it looks.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
> “When the user types in the input field, the greeting updates immediately.”
|
||||||
|
|
||||||
|
This requirement drives both test and implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Write the Test First
|
||||||
|
|
||||||
|
Create a Mocha test file in `src/components/<component-name>/<component>.test.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import "./hello-world.js";
|
||||||
|
|
||||||
|
describe("<hello-world>", () => {
|
||||||
|
it("updates the greeting when user types", async () => {
|
||||||
|
const el = document.createElement("hello-world");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const input = el.shadowRoot.querySelector("input");
|
||||||
|
input.value = "Agent";
|
||||||
|
input.dispatchEvent(new Event("input"));
|
||||||
|
await el.updateComplete;
|
||||||
|
const greeting = el.shadowRoot.querySelector(".greeting");
|
||||||
|
expect(greeting.textContent.trim()).to.equal("Hello, Agent!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
When you run `npm test`, this test will **fail** until you implement the behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Implement Just Enough Code to Pass
|
||||||
|
|
||||||
|
Write the smallest possible component in **Lit** to satisfy the test:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { LitElement, html, css } from "lit";
|
||||||
|
|
||||||
|
export class HelloWorld extends LitElement {
|
||||||
|
static properties = { name: { type: String } };
|
||||||
|
constructor() { super(); this.name = "World"; }
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="greeting">Hello, ${this.name}!</div>
|
||||||
|
<input type="text" .value=${this.name} @input=${this._onInput} />
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
_onInput(e) { this.name = e.target.value; }
|
||||||
|
}
|
||||||
|
customElements.define("hello-world", HelloWorld);
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests again — they should now pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Refine, Extend, and Visualize
|
||||||
|
|
||||||
|
When a component works, visualize it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite opens the browser instantly so you can adjust styling and layout.
|
||||||
|
|
||||||
|
Optionally, use Storybook to display multiple variants:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export default { title: "UI/Hello World" };
|
||||||
|
export const Default = () => `<hello-world></hello-world>`;
|
||||||
|
export const Named = () => `<hello-world name="Coulomb"></hello-world>`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Iterate
|
||||||
|
|
||||||
|
Each new behavior (feature, bugfix, style rule, accessibility improvement) starts with a **new test**.
|
||||||
|
The system evolves incrementally, each step verifiable by automation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Why This Matters
|
||||||
|
|
||||||
|
| Principle | Benefit |
|
||||||
|
| ----------------------------------------- | ------------------------------------------------ |
|
||||||
|
| **TDD-first** | Prevents UI drift and regression early. |
|
||||||
|
| **Declarative UI (Lit)** | Clean separation of state and template. |
|
||||||
|
| **Deterministic Testing (JSDOM + Mocha)** | Reproducible results without browser complexity. |
|
||||||
|
| **Fast Iteration (Vite)** | Keeps flow tight and interactive. |
|
||||||
|
| **Optional Visual Layer (Storybook)** | Communication and design documentation built in. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Advanced Extensions
|
||||||
|
|
||||||
|
Once the base workflow feels solid, TestDrive-UI scales elegantly to more advanced cases:
|
||||||
|
|
||||||
|
* **Shared reactive stores** — implement central state management (see Tutorial 6).
|
||||||
|
* **Persistence** — save data using localStorage or IndexedDB (see Tutorial 7).
|
||||||
|
* **Agent automation** — use AI coding agents to generate and maintain tests automatically (see Tutorial 8).
|
||||||
|
* **End-to-end testing** — run full browser simulations via Playwright.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Official Tool Links
|
||||||
|
|
||||||
|
| Tool | Website |
|
||||||
|
| ---------- | ---------------------------------------------------------------- |
|
||||||
|
| Lit | [https://lit.dev](https://lit.dev) |
|
||||||
|
| Mocha | [https://mochajs.org](https://mochajs.org) |
|
||||||
|
| Chai | [https://www.chaijs.com](https://www.chaijs.com) |
|
||||||
|
| JSDOM | [https://github.com/jsdom/jsdom](https://github.com/jsdom/jsdom) |
|
||||||
|
| Vite | [https://vitejs.dev](https://vitejs.dev) |
|
||||||
|
| Storybook | [https://storybook.js.org](https://storybook.js.org) |
|
||||||
|
| Playwright | [https://playwright.dev](https://playwright.dev) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start Recap
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone or unzip scaffold
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Start live preview
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
* Tests define behavior.
|
||||||
|
* Components evolve from tests.
|
||||||
|
* UIs emerge naturally from verified code.
|
||||||
|
|
||||||
|
> **TestDrive-UI** isn’t a framework — it’s a *method*.
|
||||||
|
> It’s how you and your coding agents learn to reason about interfaces through evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Start with our tutorials to get going.
|
||||||
|
|
||||||
|
xxx
|
||||||
@@ -1,144 +1,144 @@
|
|||||||
List of **next-stage tutorial ideas**, organized by **theme** and **complexity**, to help evolve both the teaching path and the framework.
|
List of **next-stage tutorial ideas**, organized by **theme** and **complexity**, to help evolve both the teaching path and the framework.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 **A. UI and Interaction Patterns**
|
## 🧩 **A. UI and Interaction Patterns**
|
||||||
|
|
||||||
1. **Tutorial 9 — Forms and Validation**
|
1. **Tutorial 9 — Forms and Validation**
|
||||||
|
|
||||||
* Build a `<user-form>` component with multiple fields and validation logic.
|
* Build a `<user-form>` component with multiple fields and validation logic.
|
||||||
* Introduce unit tests for validation rules and error display.
|
* Introduce unit tests for validation rules and error display.
|
||||||
* Show how to test async validations (e.g., simulated API check).
|
* Show how to test async validations (e.g., simulated API check).
|
||||||
|
|
||||||
2. **Tutorial 10 — Modal Dialogs and Overlays**
|
2. **Tutorial 10 — Modal Dialogs and Overlays**
|
||||||
|
|
||||||
* Implement a `<modal-dialog>` web component using Lit and CSS transitions.
|
* Implement a `<modal-dialog>` web component using Lit and CSS transitions.
|
||||||
* Demonstrate accessibility (`aria-*`) and keyboard interactions.
|
* Demonstrate accessibility (`aria-*`) and keyboard interactions.
|
||||||
* TDD focus: test open/close state, keyboard handling, and backdrop clicks.
|
* TDD focus: test open/close state, keyboard handling, and backdrop clicks.
|
||||||
|
|
||||||
3. **Tutorial 11 — Keyboard Navigation and Accessibility**
|
3. **Tutorial 11 — Keyboard Navigation and Accessibility**
|
||||||
|
|
||||||
* Add keyboard shortcuts and focus management to existing components.
|
* Add keyboard shortcuts and focus management to existing components.
|
||||||
* Test tab order, focus restoration, and ARIA attributes using jsdom.
|
* Test tab order, focus restoration, and ARIA attributes using jsdom.
|
||||||
|
|
||||||
4. **Tutorial 12 — Dynamic Lists and Reordering**
|
4. **Tutorial 12 — Dynamic Lists and Reordering**
|
||||||
|
|
||||||
* Build a draggable `<sortable-list>` component.
|
* Build a draggable `<sortable-list>` component.
|
||||||
* Test DOM updates, drag/drop events, and final order assertions.
|
* Test DOM updates, drag/drop events, and final order assertions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ **B. State, Data, and Logic**
|
## ⚙️ **B. State, Data, and Logic**
|
||||||
|
|
||||||
5. **Tutorial 13 — Derived and Computed State**
|
5. **Tutorial 13 — Derived and Computed State**
|
||||||
|
|
||||||
* Extend the store to include computed values (e.g., counts, filters).
|
* Extend the store to include computed values (e.g., counts, filters).
|
||||||
* Teach memoization and how to test reactive derivations.
|
* Teach memoization and how to test reactive derivations.
|
||||||
|
|
||||||
6. **Tutorial 14 — Undo/Redo and Time Travel**
|
6. **Tutorial 14 — Undo/Redo and Time Travel**
|
||||||
|
|
||||||
* Add a store history stack and commands for undo/redo.
|
* Add a store history stack and commands for undo/redo.
|
||||||
* Test state rollbacks deterministically.
|
* Test state rollbacks deterministically.
|
||||||
|
|
||||||
7. **Tutorial 15 — Multi-Store Coordination**
|
7. **Tutorial 15 — Multi-Store Coordination**
|
||||||
|
|
||||||
* Split state across multiple stores (e.g., `UserStore`, `SettingsStore`).
|
* Split state across multiple stores (e.g., `UserStore`, `SettingsStore`).
|
||||||
* Show how to compose subscriptions and test inter-store dependencies.
|
* Show how to compose subscriptions and test inter-store dependencies.
|
||||||
|
|
||||||
8. **Tutorial 16 — Data Fetching and Caching**
|
8. **Tutorial 16 — Data Fetching and Caching**
|
||||||
|
|
||||||
* Use `fetch()` to load data asynchronously and cache it in the store.
|
* Use `fetch()` to load data asynchronously and cache it in the store.
|
||||||
* Mock HTTP requests in tests.
|
* Mock HTTP requests in tests.
|
||||||
* Discuss retry and error handling patterns.
|
* Discuss retry and error handling patterns.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💎 **C. Styling, Theming, and Branding**
|
## 💎 **C. Styling, Theming, and Branding**
|
||||||
|
|
||||||
9. **Tutorial 17 — Theming and CSS Variables**
|
9. **Tutorial 17 — Theming and CSS Variables**
|
||||||
|
|
||||||
* Implement a theme manager (light/dark/custom colors).
|
* Implement a theme manager (light/dark/custom colors).
|
||||||
* Test dynamic theme changes via store and DOM styles.
|
* Test dynamic theme changes via store and DOM styles.
|
||||||
|
|
||||||
10. **Tutorial 18 — Component Libraries and Design Tokens**
|
10. **Tutorial 18 — Component Libraries and Design Tokens**
|
||||||
|
|
||||||
* Introduce reusable style tokens and component variants.
|
* Introduce reusable style tokens and component variants.
|
||||||
* Build visual regression tests using Storybook snapshots.
|
* Build visual regression tests using Storybook snapshots.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 **D. Architecture and Automation**
|
## 🧠 **D. Architecture and Automation**
|
||||||
|
|
||||||
11. **Tutorial 19 — Reactive Controllers and Composition**
|
11. **Tutorial 19 — Reactive Controllers and Composition**
|
||||||
|
|
||||||
* Use Lit’s `ReactiveController` pattern to encapsulate logic like stores or timers.
|
* Use Lit’s `ReactiveController` pattern to encapsulate logic like stores or timers.
|
||||||
* TDD pattern: one controller, multiple components.
|
* TDD pattern: one controller, multiple components.
|
||||||
|
|
||||||
12. **Tutorial 20 — Custom Build and Test Pipelines**
|
12. **Tutorial 20 — Custom Build and Test Pipelines**
|
||||||
|
|
||||||
* Show how to integrate Mocha tests into CI (GitHub Actions).
|
* Show how to integrate Mocha tests into CI (GitHub Actions).
|
||||||
* Include code coverage (nyc) and automatic agent test reporting.
|
* Include code coverage (nyc) and automatic agent test reporting.
|
||||||
|
|
||||||
13. **Tutorial 21 — Agentic Refactoring**
|
13. **Tutorial 21 — Agentic Refactoring**
|
||||||
|
|
||||||
* Demonstrate agents proposing architectural changes:
|
* Demonstrate agents proposing architectural changes:
|
||||||
|
|
||||||
* identifying code smells,
|
* identifying code smells,
|
||||||
* extracting controllers,
|
* extracting controllers,
|
||||||
* enforcing consistent store usage.
|
* enforcing consistent store usage.
|
||||||
|
|
||||||
14. **Tutorial 22 — Agent-Driven Story Generation**
|
14. **Tutorial 22 — Agent-Driven Story Generation**
|
||||||
|
|
||||||
* Agents create new Storybook stories based on test cases.
|
* Agents create new Storybook stories based on test cases.
|
||||||
* Bridge test specs and UX documentation automatically.
|
* Bridge test specs and UX documentation automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔮 **E. Integration and Expansion**
|
## 🔮 **E. Integration and Expansion**
|
||||||
|
|
||||||
15. **Tutorial 23 — REST and GraphQL API Integration**
|
15. **Tutorial 23 — REST and GraphQL API Integration**
|
||||||
|
|
||||||
* Fetch and render real backend data.
|
* Fetch and render real backend data.
|
||||||
* Mock responses for offline testing.
|
* Mock responses for offline testing.
|
||||||
* Introduce contract tests for schema validation.
|
* Introduce contract tests for schema validation.
|
||||||
|
|
||||||
16. **Tutorial 24 — Internationalization (i18n)**
|
16. **Tutorial 24 — Internationalization (i18n)**
|
||||||
|
|
||||||
* Add locale switching to greetings.
|
* Add locale switching to greetings.
|
||||||
* Test translation loading and pluralization behavior.
|
* Test translation loading and pluralization behavior.
|
||||||
|
|
||||||
17. **Tutorial 25 — Progressive Web App (PWA) Integration**
|
17. **Tutorial 25 — Progressive Web App (PWA) Integration**
|
||||||
|
|
||||||
* Cache store data offline.
|
* Cache store data offline.
|
||||||
* Add service worker tests for persistence.
|
* Add service worker tests for persistence.
|
||||||
|
|
||||||
18. **Tutorial 26 — Performance and Profiling**
|
18. **Tutorial 26 — Performance and Profiling**
|
||||||
|
|
||||||
* Measure component render times.
|
* Measure component render times.
|
||||||
* Write regression tests for performance thresholds.
|
* Write regression tests for performance thresholds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 **F. Visionary / Meta Layer**
|
## 🚀 **F. Visionary / Meta Layer**
|
||||||
|
|
||||||
19. **Tutorial 27 — Visual AI Testing**
|
19. **Tutorial 27 — Visual AI Testing**
|
||||||
|
|
||||||
* Integrate image snapshots from Storybook for visual regression comparison.
|
* Integrate image snapshots from Storybook for visual regression comparison.
|
||||||
* Show how agents can detect UI drift.
|
* Show how agents can detect UI drift.
|
||||||
|
|
||||||
20. **Tutorial 28 — Self-Testing Components**
|
20. **Tutorial 28 — Self-Testing Components**
|
||||||
|
|
||||||
* Each component ships with its own self-test harness.
|
* Each component ships with its own self-test harness.
|
||||||
* Components can verify their own integrity in isolation.
|
* Components can verify their own integrity in isolation.
|
||||||
|
|
||||||
21. **Tutorial 29 — AI-Assisted UX Heuristics**
|
21. **Tutorial 29 — AI-Assisted UX Heuristics**
|
||||||
|
|
||||||
* Agents analyze user interaction logs and propose UI simplifications.
|
* Agents analyze user interaction logs and propose UI simplifications.
|
||||||
* TDD for heuristic improvements.
|
* TDD for heuristic improvements.
|
||||||
|
|
||||||
22. **Tutorial 30 — Meta-Framework Evolution**
|
22. **Tutorial 30 — Meta-Framework Evolution**
|
||||||
|
|
||||||
* Turn TestDrive-UI into a reusable CLI (`npx testdrive-ui new <component>`).
|
* Turn TestDrive-UI into a reusable CLI (`npx testdrive-ui new <component>`).
|
||||||
* Generate scaffolds, tests, and stories automatically.
|
* Generate scaffolds, tests, and stories automatically.
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
|
|||||||
@@ -1,187 +1,187 @@
|
|||||||
# Tutorial 1: Hello World!
|
# Tutorial 1: Hello World!
|
||||||
|
|
||||||
Let’s walk through your first **HelloWorld** component built with **TestDrive-UI**.
|
Let’s walk through your first **HelloWorld** component built with **TestDrive-UI**.
|
||||||
|
|
||||||
This will demonstrate the full workflow:
|
This will demonstrate the full workflow:
|
||||||
👉 requirement → test → implementation → run → refinement.
|
👉 requirement → test → implementation → run → refinement.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧱 1. Project setup
|
## 🧱 1. Project setup
|
||||||
|
|
||||||
If you haven’t already:
|
If you haven’t already:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
unzip testdrive-ui.zip
|
unzip testdrive-ui.zip
|
||||||
cd testdrive-ui
|
cd testdrive-ui
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you’re ready to create your first component.
|
Now you’re ready to create your first component.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 2. Create a component folder
|
## 🧩 2. Create a component folder
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir src/components/hello-world
|
mkdir src/components/hello-world
|
||||||
```
|
```
|
||||||
|
|
||||||
Inside it, you’ll have:
|
Inside it, you’ll have:
|
||||||
|
|
||||||
```
|
```
|
||||||
hello-world/
|
hello-world/
|
||||||
├── hello-world.js
|
├── hello-world.js
|
||||||
├── hello-world.test.js
|
├── hello-world.test.js
|
||||||
└── hello-world.stories.js
|
└── hello-world.stories.js
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✏️ 3. Step 1 — Write the requirement
|
## ✏️ 3. Step 1 — Write the requirement
|
||||||
|
|
||||||
> “When `<hello-world>` is rendered, it should display the text **Hello World!**
|
> “When `<hello-world>` is rendered, it should display the text **Hello World!**
|
||||||
> and clicking the element should show an alert.”
|
> and clicking the element should show an alert.”
|
||||||
|
|
||||||
That’s your behavioral spec — the *“why”* that drives the test.
|
That’s your behavioral spec — the *“why”* that drives the test.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 4. Step 2 — Write the test first
|
## 🧪 4. Step 2 — Write the test first
|
||||||
|
|
||||||
`src/components/hello-world/hello-world.test.js`
|
`src/components/hello-world/hello-world.test.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-world.js";
|
import "./hello-world.js";
|
||||||
|
|
||||||
describe("<hello-world>", () => {
|
describe("<hello-world>", () => {
|
||||||
it("renders the correct greeting", () => {
|
it("renders the correct greeting", () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const content = el.shadowRoot.textContent;
|
const content = el.shadowRoot.textContent;
|
||||||
expect(content).to.include("Hello World!");
|
expect(content).to.include("Hello World!");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("triggers an alert on click", () => {
|
it("triggers an alert on click", () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
let alerted = false;
|
let alerted = false;
|
||||||
window.alert = () => (alerted = true);
|
window.alert = () => (alerted = true);
|
||||||
|
|
||||||
const div = el.shadowRoot.querySelector("div");
|
const div = el.shadowRoot.querySelector("div");
|
||||||
div.click();
|
div.click();
|
||||||
|
|
||||||
expect(alerted).to.be.true;
|
expect(alerted).to.be.true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run this test now:
|
Run this test now:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Both tests will **fail** initially — perfect, that’s the TDD start.
|
Both tests will **fail** initially — perfect, that’s the TDD start.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💡 5. Step 3 — Implement the component
|
## 💡 5. Step 3 — Implement the component
|
||||||
|
|
||||||
`src/components/hello-world/hello-world.js`
|
`src/components/hello-world/hello-world.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { LitElement, html, css } from "lit";
|
import { LitElement, html, css } from "lit";
|
||||||
|
|
||||||
export class HelloWorld extends LitElement {
|
export class HelloWorld extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
div {
|
div {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #007acc;
|
color: #007acc;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
div:hover {
|
div:hover {
|
||||||
color: #005fa3;
|
color: #005fa3;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`<div @click=${this._onClick}>Hello World!</div>`;
|
return html`<div @click=${this._onClick}>Hello World!</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onClick() {
|
_onClick() {
|
||||||
alert("Hello from TestDrive-UI!");
|
alert("Hello from TestDrive-UI!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("hello-world", HelloWorld);
|
customElements.define("hello-world", HelloWorld);
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the tests again:
|
Run the tests again:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ Both should now pass.
|
✅ Both should now pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚡ 6. Step 4 — Preview it in the browser
|
## ⚡ 6. Step 4 — Preview it in the browser
|
||||||
|
|
||||||
Add it to your `index.html`:
|
Add it to your `index.html`:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script type="module" src="./components/hello-world/hello-world.js"></script>
|
<script type="module" src="./components/hello-world/hello-world.js"></script>
|
||||||
<hello-world></hello-world>
|
<hello-world></hello-world>
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run:
|
Then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:5173](http://localhost:5173)
|
Open [http://localhost:5173](http://localhost:5173)
|
||||||
→ You’ll see your clickable **Hello World!** component rendered live.
|
→ You’ll see your clickable **Hello World!** component rendered live.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧭 7. Step 5 — Visual story (optional)
|
## 🧭 7. Step 5 — Visual story (optional)
|
||||||
|
|
||||||
`src/components/hello-world/hello-world.stories.js`
|
`src/components/hello-world/hello-world.stories.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-world.js";
|
import "./hello-world.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "UI/Hello World",
|
title: "UI/Hello World",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default = () => `<hello-world></hello-world>`;
|
export const Default = () => `<hello-world></hello-world>`;
|
||||||
```
|
```
|
||||||
|
|
||||||
Once you add Storybook to the scaffold later, this file will automatically generate a visual preview card.
|
Once you add Storybook to the scaffold later, this file will automatically generate a visual preview card.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 8. What you achieved
|
## ✅ 8. What you achieved
|
||||||
|
|
||||||
* Created an **independent UI component** with Lit
|
* Created an **independent UI component** with Lit
|
||||||
* Wrote deterministic **tests with jsdom + Mocha**
|
* Wrote deterministic **tests with jsdom + Mocha**
|
||||||
* Verified behavior automatically
|
* Verified behavior automatically
|
||||||
* Previewed visually via **Vite**
|
* Previewed visually via **Vite**
|
||||||
|
|
||||||
This loop is your core **TestDrive-UI workflow**:
|
This loop is your core **TestDrive-UI workflow**:
|
||||||
|
|
||||||
```
|
```
|
||||||
spec → test → implement → run → refine
|
spec → test → implement → run → refine
|
||||||
```
|
```
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
|
|||||||
@@ -1,217 +1,217 @@
|
|||||||
# Tutorial 2 Reactive Properties
|
# Tutorial 2 Reactive Properties
|
||||||
|
|
||||||
This second **TestDrive-UI** tutorial extends your previous `hello-world` component by introducing **reactive properties** (i.e., component inputs) and **dynamic rendering**, all under **TDD** control.
|
This second **TestDrive-UI** tutorial extends your previous `hello-world` component by introducing **reactive properties** (i.e., component inputs) and **dynamic rendering**, all under **TDD** control.
|
||||||
|
|
||||||
We’ll end up with a `<hello-world name="Kale"></hello-world>` component that greets a given name — and can change dynamically when the property updates.
|
We’ll end up with a `<hello-world name="Kale"></hello-world>` component that greets a given name — and can change dynamically when the property updates.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧭 1. Goal
|
## 🧭 1. Goal
|
||||||
|
|
||||||
> The component should display “Hello, [name]!”
|
> The component should display “Hello, [name]!”
|
||||||
> and automatically update when the `name` property changes.
|
> and automatically update when the `name` property changes.
|
||||||
|
|
||||||
If no `name` is given, it should default to “World”.
|
If no `name` is given, it should default to “World”.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 2. Step 1 — Write the failing test first
|
## 🧪 2. Step 1 — Write the failing test first
|
||||||
|
|
||||||
Create `src/components/hello-world/hello-world.props.test.js`:
|
Create `src/components/hello-world/hello-world.props.test.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-world.js";
|
import "./hello-world.js";
|
||||||
|
|
||||||
describe("<hello-world> (with name property)", () => {
|
describe("<hello-world> (with name property)", () => {
|
||||||
it("renders default greeting when no name is set", () => {
|
it("renders default greeting when no name is set", () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const text = el.shadowRoot.textContent.trim();
|
const text = el.shadowRoot.textContent.trim();
|
||||||
expect(text).to.equal("Hello, World!");
|
expect(text).to.equal("Hello, World!");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders custom greeting when name is set", async () => {
|
it("renders custom greeting when name is set", async () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
el.name = "Kale";
|
el.name = "Kale";
|
||||||
|
|
||||||
// Wait for Lit’s update cycle
|
// Wait for Lit’s update cycle
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
const text = el.shadowRoot.textContent.trim();
|
const text = el.shadowRoot.textContent.trim();
|
||||||
expect(text).to.equal("Hello, Kale!");
|
expect(text).to.equal("Hello, Kale!");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reacts to property change after initial render", async () => {
|
it("reacts to property change after initial render", async () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
el.name = "Aria";
|
el.name = "Aria";
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
let text = el.shadowRoot.textContent.trim();
|
let text = el.shadowRoot.textContent.trim();
|
||||||
expect(text).to.equal("Hello, Aria!");
|
expect(text).to.equal("Hello, Aria!");
|
||||||
|
|
||||||
el.name = "Nova";
|
el.name = "Nova";
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
text = el.shadowRoot.textContent.trim();
|
text = el.shadowRoot.textContent.trim();
|
||||||
expect(text).to.equal("Hello, Nova!");
|
expect(text).to.equal("Hello, Nova!");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
All tests should fail — we haven’t implemented anything yet.
|
All tests should fail — we haven’t implemented anything yet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 3. Step 2 — Implement the feature
|
## 🧩 3. Step 2 — Implement the feature
|
||||||
|
|
||||||
Open your existing `src/components/hello-world/hello-world.js`
|
Open your existing `src/components/hello-world/hello-world.js`
|
||||||
and replace the class with this improved version:
|
and replace the class with this improved version:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { LitElement, html, css } from "lit";
|
import { LitElement, html, css } from "lit";
|
||||||
|
|
||||||
export class HelloWorld extends LitElement {
|
export class HelloWorld extends LitElement {
|
||||||
static properties = {
|
static properties = {
|
||||||
name: { type: String }
|
name: { type: String }
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.name = "World";
|
this.name = "World";
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
div {
|
div {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #007acc;
|
color: #007acc;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
div:hover {
|
div:hover {
|
||||||
color: #005fa3;
|
color: #005fa3;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`<div @click=${this._onClick}>
|
return html`<div @click=${this._onClick}>
|
||||||
Hello, ${this.name}!
|
Hello, ${this.name}!
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onClick() {
|
_onClick() {
|
||||||
alert(`Hello, ${this.name}!`);
|
alert(`Hello, ${this.name}!`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("hello-world", HelloWorld);
|
customElements.define("hello-world", HelloWorld);
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the tests again:
|
Run the tests again:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ All should now pass.
|
✅ All should now pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚡ 4. Step 3 — Try it live
|
## ⚡ 4. Step 3 — Try it live
|
||||||
|
|
||||||
Edit `src/index.html` to demonstrate both variants:
|
Edit `src/index.html` to demonstrate both variants:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<hello-world></hello-world>
|
<hello-world></hello-world>
|
||||||
<hello-world name="Coulomb"></hello-world>
|
<hello-world name="Coulomb"></hello-world>
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
In the browser you’ll see:
|
In the browser you’ll see:
|
||||||
|
|
||||||
```
|
```
|
||||||
Hello, World!
|
Hello, World!
|
||||||
Hello, Coulomb!
|
Hello, Coulomb!
|
||||||
```
|
```
|
||||||
|
|
||||||
and both are clickable.
|
and both are clickable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 5. Step 4 — Live updates (optional exploration)
|
## 🔄 5. Step 4 — Live updates (optional exploration)
|
||||||
|
|
||||||
Open the browser console and type:
|
Open the browser console and type:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
document.querySelector("hello-world").name = "Agent";
|
document.querySelector("hello-world").name = "Agent";
|
||||||
```
|
```
|
||||||
|
|
||||||
The first greeting should **update instantly** to:
|
The first greeting should **update instantly** to:
|
||||||
|
|
||||||
```
|
```
|
||||||
Hello, Agent!
|
Hello, Agent!
|
||||||
```
|
```
|
||||||
|
|
||||||
That’s Lit’s reactive update mechanism at work.
|
That’s Lit’s reactive update mechanism at work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧭 6. Step 5 — Visual story (optional)
|
## 🧭 6. Step 5 — Visual story (optional)
|
||||||
|
|
||||||
`src/components/hello-world/hello-world.stories.js`
|
`src/components/hello-world/hello-world.stories.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-world.js";
|
import "./hello-world.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "UI/Hello World (Reactive)"
|
title: "UI/Hello World (Reactive)"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default = () => `<hello-world></hello-world>`;
|
export const Default = () => `<hello-world></hello-world>`;
|
||||||
export const CustomName = () => `<hello-world name="Bernd"></hello-world>`;
|
export const CustomName = () => `<hello-world name="Bernd"></hello-world>`;
|
||||||
```
|
```
|
||||||
|
|
||||||
If Storybook is later installed, these stories will become live demos.
|
If Storybook is later installed, these stories will become live demos.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 7. Key Takeaways
|
## 🧩 7. Key Takeaways
|
||||||
|
|
||||||
| Concept | Explanation |
|
| Concept | Explanation |
|
||||||
| --------------------- | ------------------------------------------------- |
|
| --------------------- | ------------------------------------------------- |
|
||||||
| **Reactive property** | Declared via `static properties = { ... }` in Lit |
|
| **Reactive property** | Declared via `static properties = { ... }` in Lit |
|
||||||
| **Default values** | Set in the constructor |
|
| **Default values** | Set in the constructor |
|
||||||
| **Automatic updates** | Changing the property triggers re-render |
|
| **Automatic updates** | Changing the property triggers re-render |
|
||||||
| **Testing updates** | Use `await el.updateComplete` before asserting |
|
| **Testing updates** | Use `await el.updateComplete` before asserting |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 8. What you learned
|
## 🧪 8. What you learned
|
||||||
|
|
||||||
* How to **declare reactive component properties**
|
* How to **declare reactive component properties**
|
||||||
* How to **test reactivity** with Mocha + jsdom
|
* How to **test reactivity** with Mocha + jsdom
|
||||||
* How to **update and verify UI behavior** in a TDD loop
|
* How to **update and verify UI behavior** in a TDD loop
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Next, we can take it one level further:
|
Next, we can take it one level further:
|
||||||
|
|
||||||
> Add a **text input** inside `<hello-world>` that updates the `name` property live when the user types.
|
> Add a **text input** inside `<hello-world>` that updates the `name` property live when the user types.
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
@@ -1,221 +1,221 @@
|
|||||||
# Tutorial 3: Bi-Directional Data Binding
|
# Tutorial 3: Bi-Directional Data Binding
|
||||||
|
|
||||||
This **third tutorial** builds directly on your `hello-world` component and introduces **two-way interaction** (bi-directional data binding).
|
This **third tutorial** builds directly on your `hello-world` component and introduces **two-way interaction** (bi-directional data binding).
|
||||||
|
|
||||||
You’ll learn how to let the user type into an input field and see the greeting update live — while continuing to follow a **TDD-first** approach.
|
You’ll learn how to let the user type into an input field and see the greeting update live — while continuing to follow a **TDD-first** approach.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧭 1. Goal
|
## 🧭 1. Goal
|
||||||
|
|
||||||
> The `<hello-world>` component should display a greeting and an input box.
|
> The `<hello-world>` component should display a greeting and an input box.
|
||||||
> Typing into the input should update the greeting **in real time**.
|
> Typing into the input should update the greeting **in real time**.
|
||||||
> The `name` property should still be readable/writable programmatically.
|
> The `name` property should still be readable/writable programmatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 2. Step 1 — Write the failing test first
|
## 🧪 2. Step 1 — Write the failing test first
|
||||||
|
|
||||||
Create a new test file:
|
Create a new test file:
|
||||||
`src/components/hello-world/hello-world.input.test.js`
|
`src/components/hello-world/hello-world.input.test.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-world.js";
|
import "./hello-world.js";
|
||||||
|
|
||||||
describe("<hello-world> (interactive input)", () => {
|
describe("<hello-world> (interactive input)", () => {
|
||||||
it("renders an input element and shows default greeting", () => {
|
it("renders an input element and shows default greeting", () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const input = el.shadowRoot.querySelector("input");
|
const input = el.shadowRoot.querySelector("input");
|
||||||
const greeting = el.shadowRoot.querySelector(".greeting");
|
const greeting = el.shadowRoot.querySelector(".greeting");
|
||||||
|
|
||||||
expect(input).to.exist;
|
expect(input).to.exist;
|
||||||
expect(greeting.textContent.trim()).to.equal("Hello, World!");
|
expect(greeting.textContent.trim()).to.equal("Hello, World!");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates greeting when user types into input", async () => {
|
it("updates greeting when user types into input", async () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const input = el.shadowRoot.querySelector("input");
|
const input = el.shadowRoot.querySelector("input");
|
||||||
input.value = "Agent";
|
input.value = "Agent";
|
||||||
input.dispatchEvent(new Event("input"));
|
input.dispatchEvent(new Event("input"));
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
const greeting = el.shadowRoot.querySelector(".greeting");
|
const greeting = el.shadowRoot.querySelector(".greeting");
|
||||||
expect(greeting.textContent.trim()).to.equal("Hello, Agent!");
|
expect(greeting.textContent.trim()).to.equal("Hello, Agent!");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reflects property changes in input value", async () => {
|
it("reflects property changes in input value", async () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
el.name = "Nova";
|
el.name = "Nova";
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
const input = el.shadowRoot.querySelector("input");
|
const input = el.shadowRoot.querySelector("input");
|
||||||
expect(input.value).to.equal("Nova");
|
expect(input.value).to.equal("Nova");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run tests:
|
Run tests:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
They will all **fail** — as expected.
|
They will all **fail** — as expected.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 3. Step 2 — Implement the feature
|
## 🧩 3. Step 2 — Implement the feature
|
||||||
|
|
||||||
Edit `src/components/hello-world/hello-world.js` and replace the render logic:
|
Edit `src/components/hello-world/hello-world.js` and replace the render logic:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { LitElement, html, css } from "lit";
|
import { LitElement, html, css } from "lit";
|
||||||
|
|
||||||
export class HelloWorld extends LitElement {
|
export class HelloWorld extends LitElement {
|
||||||
static properties = {
|
static properties = {
|
||||||
name: { type: String }
|
name: { type: String }
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.name = "World";
|
this.name = "World";
|
||||||
}
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
.container {
|
.container {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #007acc;
|
color: #007acc;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
input {
|
input {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
width: 60%;
|
width: 60%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="greeting">Hello, ${this.name}!</div>
|
<div class="greeting">Hello, ${this.name}!</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
.value=${this.name}
|
.value=${this.name}
|
||||||
@input=${this._onInput}
|
@input=${this._onInput}
|
||||||
aria-label="Name input"
|
aria-label="Name input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onInput(event) {
|
_onInput(event) {
|
||||||
this.name = event.target.value;
|
this.name = event.target.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("hello-world", HelloWorld);
|
customElements.define("hello-world", HelloWorld);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 4. Step 3 — Run the tests again
|
## 🧪 4. Step 3 — Run the tests again
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ All three tests should now pass.
|
✅ All three tests should now pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚡ 5. Step 4 — Try it live
|
## ⚡ 5. Step 4 — Try it live
|
||||||
|
|
||||||
Update `src/index.html` to:
|
Update `src/index.html` to:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<hello-world></hello-world>
|
<hello-world></hello-world>
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
In your browser:
|
In your browser:
|
||||||
|
|
||||||
* You’ll see “Hello, World!”
|
* You’ll see “Hello, World!”
|
||||||
* Type “Coulomb” in the input box.
|
* Type “Coulomb” in the input box.
|
||||||
* The greeting updates instantly: **Hello, Coulomb!**
|
* The greeting updates instantly: **Hello, Coulomb!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 6. Step 5 — Explore two-way binding manually
|
## 🧠 6. Step 5 — Explore two-way binding manually
|
||||||
|
|
||||||
Open DevTools Console and run:
|
Open DevTools Console and run:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
document.querySelector("hello-world").name = "Bernd";
|
document.querySelector("hello-world").name = "Bernd";
|
||||||
```
|
```
|
||||||
|
|
||||||
The input value updates automatically, and the greeting reflects the change too.
|
The input value updates automatically, and the greeting reflects the change too.
|
||||||
That’s the power of Lit’s **reactive updates** combined with the native DOM event loop.
|
That’s the power of Lit’s **reactive updates** combined with the native DOM event loop.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 7. Step 6 — Optional Storybook story
|
## 📚 7. Step 6 — Optional Storybook story
|
||||||
|
|
||||||
`src/components/hello-world/hello-world.interactive.stories.js`
|
`src/components/hello-world/hello-world.interactive.stories.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-world.js";
|
import "./hello-world.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "UI/Hello World (Interactive)"
|
title: "UI/Hello World (Interactive)"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Interactive = () => `<hello-world></hello-world>`;
|
export const Interactive = () => `<hello-world></hello-world>`;
|
||||||
```
|
```
|
||||||
|
|
||||||
When you add Storybook later, this story will provide a live, interactive playground.
|
When you add Storybook later, this story will provide a live, interactive playground.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 8. Key TDD lessons learned
|
## 🔍 8. Key TDD lessons learned
|
||||||
|
|
||||||
| Concept | Explanation |
|
| Concept | Explanation |
|
||||||
| -------------------- | ------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------ |
|
||||||
| **Event testing** | Simulate user input with `dispatchEvent(new Event('input'))` |
|
| **Event testing** | Simulate user input with `dispatchEvent(new Event('input'))` |
|
||||||
| **Reactive updates** | Use `await el.updateComplete` to wait for re-render |
|
| **Reactive updates** | Use `await el.updateComplete` to wait for re-render |
|
||||||
| **Two-way binding** | Property changes update DOM; DOM events update property |
|
| **Two-way binding** | Property changes update DOM; DOM events update property |
|
||||||
| **Isolation** | jsdom tests confirm behavior without running a browser |
|
| **Isolation** | jsdom tests confirm behavior without running a browser |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 9. Summary
|
## ✅ 9. Summary
|
||||||
|
|
||||||
You’ve now completed:
|
You’ve now completed:
|
||||||
|
|
||||||
1. Static rendering (`Hello World!`)
|
1. Static rendering (`Hello World!`)
|
||||||
2. Reactive property rendering (`Hello, ${name}!`)
|
2. Reactive property rendering (`Hello, ${name}!`)
|
||||||
3. Bi-directional interaction (live input → UI → property → UI)
|
3. Bi-directional interaction (live input → UI → property → UI)
|
||||||
|
|
||||||
You can now **test-drive** any UI component using the same methodology.
|
You can now **test-drive** any UI component using the same methodology.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Continue to the **fourth tutorial** on **component composition** — i.e., building a small dashboard that uses multiple custom components together, still under test Control.
|
Continue to the **fourth tutorial** on **component composition** — i.e., building a small dashboard that uses multiple custom components together, still under test Control.
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
@@ -1,257 +1,257 @@
|
|||||||
# Tutorial 4: Component Composition
|
# Tutorial 4: Component Composition
|
||||||
|
|
||||||
Welcome to the **fourth tutorial** for TestDriveUi building on the first three and introduces **component composition**.
|
Welcome to the **fourth tutorial** for TestDriveUi building on the first three and introduces **component composition**.
|
||||||
|
|
||||||
You’ll learn how to make multiple Lit components **interact** while keeping the development **test-driven** and agent-friendly.
|
You’ll learn how to make multiple Lit components **interact** while keeping the development **test-driven** and agent-friendly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 🧩 Tutorial 4 — Composing Components
|
# 🧩 Tutorial 4 — Composing Components
|
||||||
|
|
||||||
### “Hello Dashboard” — combining reusable components
|
### “Hello Dashboard” — combining reusable components
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Goal
|
## 🎯 Goal
|
||||||
|
|
||||||
We’ll create a small **dashboard** component called `<hello-dashboard>` that:
|
We’ll create a small **dashboard** component called `<hello-dashboard>` that:
|
||||||
|
|
||||||
1. Renders multiple `<hello-world>` components.
|
1. Renders multiple `<hello-world>` components.
|
||||||
2. Tracks how many greetings are currently visible.
|
2. Tracks how many greetings are currently visible.
|
||||||
3. Updates a counter when a new greeter is added.
|
3. Updates a counter when a new greeter is added.
|
||||||
4. Uses **TDD** for structure and behavior.
|
4. Uses **TDD** for structure and behavior.
|
||||||
|
|
||||||
In short:
|
In short:
|
||||||
|
|
||||||
> “A dashboard that shows several personalized greetings and a live counter.”
|
> “A dashboard that shows several personalized greetings and a live counter.”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 1. Step 1 — Write the failing test first
|
## 🧪 1. Step 1 — Write the failing test first
|
||||||
|
|
||||||
Create `src/components/hello-dashboard/hello-dashboard.test.js`:
|
Create `src/components/hello-dashboard/hello-dashboard.test.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "../hello-world/hello-world.js";
|
import "../hello-world/hello-world.js";
|
||||||
import "./hello-dashboard.js";
|
import "./hello-dashboard.js";
|
||||||
|
|
||||||
describe("<hello-dashboard>", () => {
|
describe("<hello-dashboard>", () => {
|
||||||
it("renders a header and a counter", () => {
|
it("renders a header and a counter", () => {
|
||||||
const el = document.createElement("hello-dashboard");
|
const el = document.createElement("hello-dashboard");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const header = el.shadowRoot.querySelector("h2");
|
const header = el.shadowRoot.querySelector("h2");
|
||||||
const counter = el.shadowRoot.querySelector(".counter");
|
const counter = el.shadowRoot.querySelector(".counter");
|
||||||
|
|
||||||
expect(header.textContent).to.include("Hello Dashboard");
|
expect(header.textContent).to.include("Hello Dashboard");
|
||||||
expect(counter.textContent).to.match(/Total greetings:/);
|
expect(counter.textContent).to.match(/Total greetings:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders at least one <hello-world> component", () => {
|
it("renders at least one <hello-world> component", () => {
|
||||||
const el = document.createElement("hello-dashboard");
|
const el = document.createElement("hello-dashboard");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||||||
expect(greeters.length).to.be.greaterThan(0);
|
expect(greeters.length).to.be.greaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds a new greeter when the Add button is clicked", async () => {
|
it("adds a new greeter when the Add button is clicked", async () => {
|
||||||
const el = document.createElement("hello-dashboard");
|
const el = document.createElement("hello-dashboard");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const addButton = el.shadowRoot.querySelector("button");
|
const addButton = el.shadowRoot.querySelector("button");
|
||||||
addButton.click();
|
addButton.click();
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||||||
expect(greeters.length).to.equal(2);
|
expect(greeters.length).to.equal(2);
|
||||||
|
|
||||||
const counter = el.shadowRoot.querySelector(".counter").textContent;
|
const counter = el.shadowRoot.querySelector(".counter").textContent;
|
||||||
expect(counter).to.include("2");
|
expect(counter).to.include("2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run it:
|
Run it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
All tests fail (expected).
|
All tests fail (expected).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ 2. Step 2 — Implement `<hello-dashboard>`
|
## ⚙️ 2. Step 2 — Implement `<hello-dashboard>`
|
||||||
|
|
||||||
Create a new folder:
|
Create a new folder:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/components/hello-dashboard/
|
src/components/hello-dashboard/
|
||||||
```
|
```
|
||||||
|
|
||||||
and add this file:
|
and add this file:
|
||||||
`src/components/hello-dashboard/hello-dashboard.js`
|
`src/components/hello-dashboard/hello-dashboard.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { LitElement, html, css } from "lit";
|
import { LitElement, html, css } from "lit";
|
||||||
import "../hello-world/hello-world.js";
|
import "../hello-world/hello-world.js";
|
||||||
|
|
||||||
export class HelloDashboard extends LitElement {
|
export class HelloDashboard extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
.container {
|
.container {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.counter {
|
.counter {
|
||||||
margin: 0.5rem 0 1rem 0;
|
margin: 0.5rem 0 1rem 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #007acc;
|
color: #007acc;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
background: #007acc;
|
background: #007acc;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
background: #005fa3;
|
background: #005fa3;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.greeters = ["World"];
|
this.greeters = ["World"];
|
||||||
}
|
}
|
||||||
|
|
||||||
_addGreeter() {
|
_addGreeter() {
|
||||||
const newName = `User${this.greeters.length + 1}`;
|
const newName = `User${this.greeters.length + 1}`;
|
||||||
this.greeters = [...this.greeters, newName];
|
this.greeters = [...this.greeters, newName];
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Hello Dashboard</h2>
|
<h2>Hello Dashboard</h2>
|
||||||
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
||||||
${this.greeters.map(
|
${this.greeters.map(
|
||||||
(name) => html`<hello-world name=${name}></hello-world>`
|
(name) => html`<hello-world name=${name}></hello-world>`
|
||||||
)}
|
)}
|
||||||
<button @click=${this._addGreeter}>Add Greeting</button>
|
<button @click=${this._addGreeter}>Add Greeting</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("hello-dashboard", HelloDashboard);
|
customElements.define("hello-dashboard", HelloDashboard);
|
||||||
```
|
```
|
||||||
|
|
||||||
Run tests again:
|
Run tests again:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ All should now pass.
|
✅ All should now pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 3. Step 3 — Try it live
|
## 🌐 3. Step 3 — Try it live
|
||||||
|
|
||||||
Add this to `src/index.html`:
|
Add this to `src/index.html`:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script type="module" src="./components/hello-dashboard/hello-dashboard.js"></script>
|
<script type="module" src="./components/hello-dashboard/hello-dashboard.js"></script>
|
||||||
<hello-dashboard></hello-dashboard>
|
<hello-dashboard></hello-dashboard>
|
||||||
```
|
```
|
||||||
|
|
||||||
Then start the dev server:
|
Then start the dev server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:5173](http://localhost:5173)
|
Open [http://localhost:5173](http://localhost:5173)
|
||||||
You’ll see:
|
You’ll see:
|
||||||
|
|
||||||
```
|
```
|
||||||
Hello Dashboard
|
Hello Dashboard
|
||||||
Total greetings: 1
|
Total greetings: 1
|
||||||
Hello, World!
|
Hello, World!
|
||||||
[Add Greeting]
|
[Add Greeting]
|
||||||
```
|
```
|
||||||
|
|
||||||
Click **Add Greeting** — new greeters appear, and the counter updates automatically.
|
Click **Add Greeting** — new greeters appear, and the counter updates automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 4. Step 4 — Extend your test coverage (optional)
|
## 🧠 4. Step 4 — Extend your test coverage (optional)
|
||||||
|
|
||||||
Add to `hello-dashboard.test.js`:
|
Add to `hello-dashboard.test.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
it("propagates custom names correctly", () => {
|
it("propagates custom names correctly", () => {
|
||||||
const el = document.createElement("hello-dashboard");
|
const el = document.createElement("hello-dashboard");
|
||||||
el.greeters = ["Alpha", "Beta", "Gamma"];
|
el.greeters = ["Alpha", "Beta", "Gamma"];
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||||||
const names = Array.from(greeters).map((g) => g.getAttribute("name"));
|
const names = Array.from(greeters).map((g) => g.getAttribute("name"));
|
||||||
expect(names).to.deep.equal(["Alpha", "Beta", "Gamma"]);
|
expect(names).to.deep.equal(["Alpha", "Beta", "Gamma"]);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 5. Step 5 — Optional Storybook story
|
## 📚 5. Step 5 — Optional Storybook story
|
||||||
|
|
||||||
`src/components/hello-dashboard/hello-dashboard.stories.js`:
|
`src/components/hello-dashboard/hello-dashboard.stories.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-dashboard.js";
|
import "./hello-dashboard.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "UI/Hello Dashboard"
|
title: "UI/Hello Dashboard"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default = () => `<hello-dashboard></hello-dashboard>`;
|
export const Default = () => `<hello-dashboard></hello-dashboard>`;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 6. Lessons learned
|
## 🧩 6. Lessons learned
|
||||||
|
|
||||||
| Concept | What you practiced |
|
| Concept | What you practiced |
|
||||||
| --------------------- | ------------------------------------------- |
|
| --------------------- | ------------------------------------------- |
|
||||||
| **Composition** | One Lit component rendering others |
|
| **Composition** | One Lit component rendering others |
|
||||||
| **Reactive arrays** | Using property updates to trigger re-render |
|
| **Reactive arrays** | Using property updates to trigger re-render |
|
||||||
| **Dynamic templates** | Rendering lists with `.map()` |
|
| **Dynamic templates** | Rendering lists with `.map()` |
|
||||||
| **Event testing** | Simulating clicks and rechecking the DOM |
|
| **Event testing** | Simulating clicks and rechecking the DOM |
|
||||||
| **Incremental TDD** | Adding small features one test at a time |
|
| **Incremental TDD** | Adding small features one test at a time |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 7. Summary
|
## ✅ 7. Summary
|
||||||
|
|
||||||
You’ve now implemented:
|
You’ve now implemented:
|
||||||
|
|
||||||
1. A simple reactive component (`hello-world`)
|
1. A simple reactive component (`hello-world`)
|
||||||
2. User interaction (input updates)
|
2. User interaction (input updates)
|
||||||
3. Property-based rendering
|
3. Property-based rendering
|
||||||
4. Component composition (`hello-dashboard`)
|
4. Component composition (`hello-dashboard`)
|
||||||
|
|
||||||
Your **TestDrive-UI** foundation now supports:
|
Your **TestDrive-UI** foundation now supports:
|
||||||
|
|
||||||
* Isolated unit tests (`Mocha + jsdom`)
|
* Isolated unit tests (`Mocha + jsdom`)
|
||||||
* Composed component testing
|
* Composed component testing
|
||||||
* Interactive browser previews (`Vite`)
|
* Interactive browser previews (`Vite`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Continue to **Event communication** between components — e.g., the dashboard listening to events fired by each greeter when they change their name.
|
Continue to **Event communication** between components — e.g., the dashboard listening to events fired by each greeter when they change their name.
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
|
|||||||
@@ -1,252 +1,252 @@
|
|||||||
# Tutorial 5: Component Communication
|
# Tutorial 5: Component Communication
|
||||||
|
|
||||||
Welcome to the **fifth tutorial** takes you into one of the most important patterns in UI development: **communication between components.**
|
Welcome to the **fifth tutorial** takes you into one of the most important patterns in UI development: **communication between components.**
|
||||||
Up to now, your `<hello-world>` components have been independent. In this tutorial, you’ll make them *talk* to their parent `<hello-dashboard>` component via **custom events**, keeping everything under **TDD**.
|
Up to now, your `<hello-world>` components have been independent. In this tutorial, you’ll make them *talk* to their parent `<hello-dashboard>` component via **custom events**, keeping everything under **TDD**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 🧭 Tutorial 5 — Component Communication
|
# 🧭 Tutorial 5 — Component Communication
|
||||||
|
|
||||||
### “Talking Components” — events between parent and child
|
### “Talking Components” — events between parent and child
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Goal
|
## 🎯 Goal
|
||||||
|
|
||||||
We’ll make each `<hello-world>` component **emit an event** when its name changes.
|
We’ll make each `<hello-world>` component **emit an event** when its name changes.
|
||||||
The `<hello-dashboard>` component will **listen to these events** and maintain a live **activity log**.
|
The `<hello-dashboard>` component will **listen to these events** and maintain a live **activity log**.
|
||||||
|
|
||||||
The flow will look like this:
|
The flow will look like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
[User types in <hello-world> input]
|
[User types in <hello-world> input]
|
||||||
↓
|
↓
|
||||||
<hello-world> fires event "name-changed"
|
<hello-world> fires event "name-changed"
|
||||||
↓
|
↓
|
||||||
<hello-dashboard> listens, updates log
|
<hello-dashboard> listens, updates log
|
||||||
↓
|
↓
|
||||||
Log list displays latest updates
|
Log list displays latest updates
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 1. Step 1 — Write the failing test first
|
## 🧪 1. Step 1 — Write the failing test first
|
||||||
|
|
||||||
Create `src/components/hello-dashboard/hello-dashboard.events.test.js`:
|
Create `src/components/hello-dashboard/hello-dashboard.events.test.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "../hello-world/hello-world.js";
|
import "../hello-world/hello-world.js";
|
||||||
import "./hello-dashboard.js";
|
import "./hello-dashboard.js";
|
||||||
|
|
||||||
describe("<hello-dashboard> (events)", () => {
|
describe("<hello-dashboard> (events)", () => {
|
||||||
it("shows a log section that updates when a child emits 'name-changed'", async () => {
|
it("shows a log section that updates when a child emits 'name-changed'", async () => {
|
||||||
const el = document.createElement("hello-dashboard");
|
const el = document.createElement("hello-dashboard");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
let log = el.shadowRoot.querySelector(".log");
|
let log = el.shadowRoot.querySelector(".log");
|
||||||
expect(log.textContent).to.include("No activity yet");
|
expect(log.textContent).to.include("No activity yet");
|
||||||
|
|
||||||
// Simulate child event
|
// Simulate child event
|
||||||
const firstChild = el.shadowRoot.querySelector("hello-world");
|
const firstChild = el.shadowRoot.querySelector("hello-world");
|
||||||
const event = new CustomEvent("name-changed", {
|
const event = new CustomEvent("name-changed", {
|
||||||
detail: { oldName: "World", newName: "Agent" },
|
detail: { oldName: "World", newName: "Agent" },
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true
|
composed: true
|
||||||
});
|
});
|
||||||
firstChild.dispatchEvent(event);
|
firstChild.dispatchEvent(event);
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
log = el.shadowRoot.querySelector(".log");
|
log = el.shadowRoot.querySelector(".log");
|
||||||
expect(log.textContent).to.include("World → Agent");
|
expect(log.textContent).to.include("World → Agent");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
The test fails (expected).
|
The test fails (expected).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 2. Step 2 — Update `<hello-world>` to fire events
|
## 🧩 2. Step 2 — Update `<hello-world>` to fire events
|
||||||
|
|
||||||
Open `src/components/hello-world/hello-world.js`
|
Open `src/components/hello-world/hello-world.js`
|
||||||
and update `_onInput()` to emit an event whenever the name changes:
|
and update `_onInput()` to emit an event whenever the name changes:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
_onInput(event) {
|
_onInput(event) {
|
||||||
const oldName = this.name;
|
const oldName = this.name;
|
||||||
this.name = event.target.value;
|
this.name = event.target.value;
|
||||||
|
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("name-changed", {
|
new CustomEvent("name-changed", {
|
||||||
detail: { oldName, newName: this.name },
|
detail: { oldName, newName: this.name },
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true
|
composed: true
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
That’s it — now `hello-world` tells the world when its name changes.
|
That’s it — now `hello-world` tells the world when its name changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ 3. Step 3 — Update `<hello-dashboard>` to handle events
|
## ⚙️ 3. Step 3 — Update `<hello-dashboard>` to handle events
|
||||||
|
|
||||||
Edit `src/components/hello-dashboard/hello-dashboard.js`:
|
Edit `src/components/hello-dashboard/hello-dashboard.js`:
|
||||||
|
|
||||||
Add a new property and event handler:
|
Add a new property and event handler:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.greeters = ["World"];
|
this.greeters = ["World"];
|
||||||
this.logs = [];
|
this.logs = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.addEventListener("name-changed", this._onNameChanged);
|
this.addEventListener("name-changed", this._onNameChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onNameChanged = (e) => {
|
_onNameChanged = (e) => {
|
||||||
const { oldName, newName } = e.detail;
|
const { oldName, newName } = e.detail;
|
||||||
this.logs = [`${oldName} → ${newName}`, ...this.logs];
|
this.logs = [`${oldName} → ${newName}`, ...this.logs];
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Now render the log area under the button:
|
Now render the log area under the button:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Hello Dashboard</h2>
|
<h2>Hello Dashboard</h2>
|
||||||
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
||||||
${this.greeters.map(
|
${this.greeters.map(
|
||||||
(name) => html`<hello-world name=${name}></hello-world>`
|
(name) => html`<hello-world name=${name}></hello-world>`
|
||||||
)}
|
)}
|
||||||
<button @click=${this._addGreeter}>Add Greeting</button>
|
<button @click=${this._addGreeter}>Add Greeting</button>
|
||||||
|
|
||||||
<div class="log">
|
<div class="log">
|
||||||
${this.logs.length === 0
|
${this.logs.length === 0
|
||||||
? html`<p>No activity yet</p>`
|
? html`<p>No activity yet</p>`
|
||||||
: html`<ul>
|
: html`<ul>
|
||||||
${this.logs.map((l) => html`<li>${l}</li>`)}
|
${this.logs.map((l) => html`<li>${l}</li>`)}
|
||||||
</ul>`}
|
</ul>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run tests again:
|
Run tests again:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ The event test should now pass.
|
✅ The event test should now pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 4. Step 4 — Try it live
|
## 🌐 4. Step 4 — Try it live
|
||||||
|
|
||||||
In `src/index.html`:
|
In `src/index.html`:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<hello-dashboard></hello-dashboard>
|
<hello-dashboard></hello-dashboard>
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the page —
|
Open the page —
|
||||||
Type into a greeter input, and you’ll see a live **activity log** appear below the button:
|
Type into a greeter input, and you’ll see a live **activity log** appear below the button:
|
||||||
|
|
||||||
```
|
```
|
||||||
Hello Dashboard
|
Hello Dashboard
|
||||||
Total greetings: 1
|
Total greetings: 1
|
||||||
Hello, World!
|
Hello, World!
|
||||||
[ Add Greeting ]
|
[ Add Greeting ]
|
||||||
Activity Log:
|
Activity Log:
|
||||||
World → Agent
|
World → Agent
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 5. Step 5 — Add a test for multiple events (optional)
|
## 🧠 5. Step 5 — Add a test for multiple events (optional)
|
||||||
|
|
||||||
Extend `hello-dashboard.events.test.js`:
|
Extend `hello-dashboard.events.test.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
it("keeps a running list of multiple name changes", async () => {
|
it("keeps a running list of multiple name changes", async () => {
|
||||||
const el = document.createElement("hello-dashboard");
|
const el = document.createElement("hello-dashboard");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
|
|
||||||
const child = el.shadowRoot.querySelector("hello-world");
|
const child = el.shadowRoot.querySelector("hello-world");
|
||||||
|
|
||||||
const events = [
|
const events = [
|
||||||
new CustomEvent("name-changed", { detail: { oldName: "World", newName: "A" }, bubbles: true, composed: true }),
|
new CustomEvent("name-changed", { detail: { oldName: "World", newName: "A" }, bubbles: true, composed: true }),
|
||||||
new CustomEvent("name-changed", { detail: { oldName: "A", newName: "B" }, bubbles: true, composed: true })
|
new CustomEvent("name-changed", { detail: { oldName: "A", newName: "B" }, bubbles: true, composed: true })
|
||||||
];
|
];
|
||||||
|
|
||||||
child.dispatchEvent(events[0]);
|
child.dispatchEvent(events[0]);
|
||||||
child.dispatchEvent(events[1]);
|
child.dispatchEvent(events[1]);
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
const items = Array.from(el.shadowRoot.querySelectorAll(".log li")).map(li => li.textContent);
|
const items = Array.from(el.shadowRoot.querySelectorAll(".log li")).map(li => li.textContent);
|
||||||
expect(items).to.deep.equal(["A → B", "World → A"]);
|
expect(items).to.deep.equal(["A → B", "World → A"]);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ It should pass too.
|
✅ It should pass too.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔩 6. Key Concepts
|
## 🔩 6. Key Concepts
|
||||||
|
|
||||||
| Concept | Explanation |
|
| Concept | Explanation |
|
||||||
| -------------------------- | ----------------------------------------------------------------- |
|
| -------------------------- | ----------------------------------------------------------------- |
|
||||||
| **CustomEvent** | A way for child components to send structured messages to parents |
|
| **CustomEvent** | A way for child components to send structured messages to parents |
|
||||||
| **Bubbling + composed** | Ensures events cross shadow DOM boundaries |
|
| **Bubbling + composed** | Ensures events cross shadow DOM boundaries |
|
||||||
| **Reactive logs** | Updating an array property triggers rerender |
|
| **Reactive logs** | Updating an array property triggers rerender |
|
||||||
| **Hierarchical testing** | TDD across parent/child relationships |
|
| **Hierarchical testing** | TDD across parent/child relationships |
|
||||||
| **Single source of truth** | Dashboard owns state; children only emit events |
|
| **Single source of truth** | Dashboard owns state; children only emit events |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 7. Summary
|
## ✅ 7. Summary
|
||||||
|
|
||||||
You’ve now built:
|
You’ve now built:
|
||||||
|
|
||||||
1. Independent reactive components (`hello-world`)
|
1. Independent reactive components (`hello-world`)
|
||||||
2. Composed layouts (`hello-dashboard`)
|
2. Composed layouts (`hello-dashboard`)
|
||||||
3. Two-way UI interaction (input updates)
|
3. Two-way UI interaction (input updates)
|
||||||
4. **Inter-component event communication**
|
4. **Inter-component event communication**
|
||||||
|
|
||||||
Your **TestDrive-UI** framework now supports:
|
Your **TestDrive-UI** framework now supports:
|
||||||
|
|
||||||
* Pure front-end logic
|
* Pure front-end logic
|
||||||
* Reactive re-rendering
|
* Reactive re-rendering
|
||||||
* Nested component tests
|
* Nested component tests
|
||||||
* Event-driven state management
|
* Event-driven state management
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Next step (Tutorial 6) will cover:
|
Next step (Tutorial 6) will cover:
|
||||||
|
|
||||||
> **State synchronization** — introducing a shared store or context (e.g. using Lit’s reactive controllers or a lightweight signal system) so that multiple components reflect shared data automatically.
|
> **State synchronization** — introducing a shared store or context (e.g. using Lit’s reactive controllers or a lightweight signal system) so that multiple components reflect shared data automatically.
|
||||||
|
|
||||||
Would you like to continue with that direction next?
|
Would you like to continue with that direction next?
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
@@ -1,322 +1,322 @@
|
|||||||
# Tutorial 6: Shared State
|
# Tutorial 6: Shared State
|
||||||
|
|
||||||
Now that your components can communicate through events, the **sixth tutorial** introduces the next natural step in professional UI architecture: **shared state** (synchronizing multiple components via a central store).
|
Now that your components can communicate through events, the **sixth tutorial** introduces the next natural step in professional UI architecture: **shared state** (synchronizing multiple components via a central store).
|
||||||
|
|
||||||
You’ll learn how to implement a **lightweight reactive store** with Lit, integrate it with your existing `<hello-world>` and `<hello-dashboard>` components, and keep everything **test-driven**.
|
You’ll learn how to implement a **lightweight reactive store** with Lit, integrate it with your existing `<hello-world>` and `<hello-dashboard>` components, and keep everything **test-driven**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# ⚡ Tutorial 6 — Shared State
|
# ⚡ Tutorial 6 — Shared State
|
||||||
|
|
||||||
### “The Store Pattern” — Synchronizing Multiple Components
|
### “The Store Pattern” — Synchronizing Multiple Components
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Goal
|
## 🎯 Goal
|
||||||
|
|
||||||
We’ll create a **store** that holds the global list of greeter names.
|
We’ll create a **store** that holds the global list of greeter names.
|
||||||
All `<hello-world>` components will automatically update when the store changes.
|
All `<hello-world>` components will automatically update when the store changes.
|
||||||
The `<hello-dashboard>` will use the store to add, remove, and rename greeters — without manually passing props or listening to custom events.
|
The `<hello-dashboard>` will use the store to add, remove, and rename greeters — without manually passing props or listening to custom events.
|
||||||
|
|
||||||
**Architecture overview:**
|
**Architecture overview:**
|
||||||
|
|
||||||
```
|
```
|
||||||
+------------------------+
|
+------------------------+
|
||||||
| HelloDashboard |
|
| HelloDashboard |
|
||||||
| (manages store ops) |
|
| (manages store ops) |
|
||||||
| |
|
| |
|
||||||
| ┌────────────┐ |
|
| ┌────────────┐ |
|
||||||
| | HelloWorld | <── store.subscribe()
|
| | HelloWorld | <── store.subscribe()
|
||||||
| └────────────┘ |
|
| └────────────┘ |
|
||||||
| ┌────────────┐ |
|
| ┌────────────┐ |
|
||||||
| | HelloWorld | |
|
| | HelloWorld | |
|
||||||
| └────────────┘ |
|
| └────────────┘ |
|
||||||
+------------------------+
|
+------------------------+
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Shared Store
|
Shared Store
|
||||||
(holds array of greeters)
|
(holds array of greeters)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 1. Step 1 — Write the failing test first
|
## 🧪 1. Step 1 — Write the failing test first
|
||||||
|
|
||||||
Create `src/store/hello-store.test.js`:
|
Create `src/store/hello-store.test.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { helloStore } from "./hello-store.js";
|
import { helloStore } from "./hello-store.js";
|
||||||
|
|
||||||
describe("helloStore", () => {
|
describe("helloStore", () => {
|
||||||
it("starts with one default greeter", () => {
|
it("starts with one default greeter", () => {
|
||||||
const state = helloStore.getState();
|
const state = helloStore.getState();
|
||||||
expect(state.greeters).to.deep.equal(["World"]);
|
expect(state.greeters).to.deep.equal(["World"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can add a new greeter", () => {
|
it("can add a new greeter", () => {
|
||||||
helloStore.addGreeter("Alice");
|
helloStore.addGreeter("Alice");
|
||||||
const state = helloStore.getState();
|
const state = helloStore.getState();
|
||||||
expect(state.greeters).to.include("Alice");
|
expect(state.greeters).to.include("Alice");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("notifies subscribers on change", (done) => {
|
it("notifies subscribers on change", (done) => {
|
||||||
const unsubscribe = helloStore.subscribe((state) => {
|
const unsubscribe = helloStore.subscribe((state) => {
|
||||||
expect(state.greeters).to.include("Bob");
|
expect(state.greeters).to.include("Bob");
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
helloStore.addGreeter("Bob");
|
helloStore.addGreeter("Bob");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
All tests fail (expected).
|
All tests fail (expected).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ 2. Step 2 — Implement the store
|
## ⚙️ 2. Step 2 — Implement the store
|
||||||
|
|
||||||
Create a new file:
|
Create a new file:
|
||||||
`src/store/hello-store.js`
|
`src/store/hello-store.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
class HelloStore {
|
class HelloStore {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = { greeters: ["World"] };
|
this.state = { greeters: ["World"] };
|
||||||
this.listeners = new Set();
|
this.listeners = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
getState() {
|
getState() {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
_notify() {
|
_notify() {
|
||||||
for (const cb of this.listeners) cb(this.state);
|
for (const cb of this.listeners) cb(this.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
this.listeners.add(callback);
|
this.listeners.add(callback);
|
||||||
callback(this.state); // immediate call with current state
|
callback(this.state); // immediate call with current state
|
||||||
return () => this.listeners.delete(callback);
|
return () => this.listeners.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
addGreeter(name) {
|
addGreeter(name) {
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
greeters: [...this.state.greeters, name]
|
greeters: [...this.state.greeters, name]
|
||||||
};
|
};
|
||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
renameGreeter(oldName, newName) {
|
renameGreeter(oldName, newName) {
|
||||||
const updated = this.state.greeters.map((n) =>
|
const updated = this.state.greeters.map((n) =>
|
||||||
n === oldName ? newName : n
|
n === oldName ? newName : n
|
||||||
);
|
);
|
||||||
this.state = { ...this.state, greeters: updated };
|
this.state = { ...this.state, greeters: updated };
|
||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const helloStore = new HelloStore();
|
export const helloStore = new HelloStore();
|
||||||
```
|
```
|
||||||
|
|
||||||
Re-run:
|
Re-run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ All tests should now pass.
|
✅ All tests should now pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 3. Step 3 — Update `<hello-dashboard>` to use the store
|
## 🧩 3. Step 3 — Update `<hello-dashboard>` to use the store
|
||||||
|
|
||||||
Modify `src/components/hello-dashboard/hello-dashboard.js`:
|
Modify `src/components/hello-dashboard/hello-dashboard.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { LitElement, html, css } from "lit";
|
import { LitElement, html, css } from "lit";
|
||||||
import "../hello-world/hello-world.js";
|
import "../hello-world/hello-world.js";
|
||||||
import { helloStore } from "../../store/hello-store.js";
|
import { helloStore } from "../../store/hello-store.js";
|
||||||
|
|
||||||
export class HelloDashboard extends LitElement {
|
export class HelloDashboard extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
.container {
|
.container {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.counter {
|
.counter {
|
||||||
margin: 0.5rem 0 1rem 0;
|
margin: 0.5rem 0 1rem 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #007acc;
|
color: #007acc;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
background: #007acc;
|
background: #007acc;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.greeters = [];
|
this.greeters = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.unsubscribe = helloStore.subscribe((state) => {
|
this.unsubscribe = helloStore.subscribe((state) => {
|
||||||
this.greeters = state.greeters;
|
this.greeters = state.greeters;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.unsubscribe?.();
|
this.unsubscribe?.();
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
_addGreeter() {
|
_addGreeter() {
|
||||||
const newName = `User${this.greeters.length + 1}`;
|
const newName = `User${this.greeters.length + 1}`;
|
||||||
helloStore.addGreeter(newName);
|
helloStore.addGreeter(newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Hello Dashboard</h2>
|
<h2>Hello Dashboard</h2>
|
||||||
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
||||||
|
|
||||||
${this.greeters.map(
|
${this.greeters.map(
|
||||||
(name) => html`<hello-world name=${name}></hello-world>`
|
(name) => html`<hello-world name=${name}></hello-world>`
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button @click=${this._addGreeter}>Add Greeting</button>
|
<button @click=${this._addGreeter}>Add Greeting</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("hello-dashboard", HelloDashboard);
|
customElements.define("hello-dashboard", HelloDashboard);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 4. Step 4 — Update `<hello-world>` to use store for renaming
|
## 🧠 4. Step 4 — Update `<hello-world>` to use store for renaming
|
||||||
|
|
||||||
Update `_onInput()` in `src/components/hello-world/hello-world.js`:
|
Update `_onInput()` in `src/components/hello-world/hello-world.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { helloStore } from "../../store/hello-store.js";
|
import { helloStore } from "../../store/hello-store.js";
|
||||||
|
|
||||||
_onInput(event) {
|
_onInput(event) {
|
||||||
const oldName = this.name;
|
const oldName = this.name;
|
||||||
this.name = event.target.value;
|
this.name = event.target.value;
|
||||||
helloStore.renameGreeter(oldName, this.name);
|
helloStore.renameGreeter(oldName, this.name);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Now each greeter updates the shared store when its input changes — all other components subscribed to the store will react automatically.
|
Now each greeter updates the shared store when its input changes — all other components subscribed to the store will react automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 5. Step 5 — Integration test (optional)
|
## 🧪 5. Step 5 — Integration test (optional)
|
||||||
|
|
||||||
Add `src/integration/dashboard-store.test.js`:
|
Add `src/integration/dashboard-store.test.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "../components/hello-dashboard/hello-dashboard.js";
|
import "../components/hello-dashboard/hello-dashboard.js";
|
||||||
import { helloStore } from "../store/hello-store.js";
|
import { helloStore } from "../store/hello-store.js";
|
||||||
|
|
||||||
describe("HelloDashboard and HelloStore integration", () => {
|
describe("HelloDashboard and HelloStore integration", () => {
|
||||||
it("renders as many greeters as store entries", async () => {
|
it("renders as many greeters as store entries", async () => {
|
||||||
helloStore.addGreeter("Eve");
|
helloStore.addGreeter("Eve");
|
||||||
const el = document.createElement("hello-dashboard");
|
const el = document.createElement("hello-dashboard");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||||||
expect(greeters.length).to.equal(helloStore.getState().greeters.length);
|
expect(greeters.length).to.equal(helloStore.getState().greeters.length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ All pass again.
|
✅ All pass again.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 6. Step 6 — Try it live
|
## 🌐 6. Step 6 — Try it live
|
||||||
|
|
||||||
Add to your `index.html`:
|
Add to your `index.html`:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<hello-dashboard></hello-dashboard>
|
<hello-dashboard></hello-dashboard>
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
* Click “Add Greeting” → new greeters appear instantly.
|
* Click “Add Greeting” → new greeters appear instantly.
|
||||||
* Type into any greeter → all components referencing that name update automatically.
|
* Type into any greeter → all components referencing that name update automatically.
|
||||||
|
|
||||||
Congratulations — you’ve just implemented **a reactive state system** entirely in vanilla JS + Lit!
|
Congratulations — you’ve just implemented **a reactive state system** entirely in vanilla JS + Lit!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📘 7. Key Concepts
|
## 📘 7. Key Concepts
|
||||||
|
|
||||||
| Concept | Description |
|
| Concept | Description |
|
||||||
| -------------------------- | ----------------------------------------------------- |
|
| -------------------------- | ----------------------------------------------------- |
|
||||||
| **Centralized state** | One store manages the data for all components |
|
| **Centralized state** | One store manages the data for all components |
|
||||||
| **Observer pattern** | Components subscribe to updates, re-render on change |
|
| **Observer pattern** | Components subscribe to updates, re-render on change |
|
||||||
| **Reactive sync** | Changes propagate automatically without prop drilling |
|
| **Reactive sync** | Changes propagate automatically without prop drilling |
|
||||||
| **TDD-first store design** | Ensures predictable, testable state behavior |
|
| **TDD-first store design** | Ensures predictable, testable state behavior |
|
||||||
| **Agent-friendliness** | Clear separation of state, presentation, and tests |
|
| **Agent-friendliness** | Clear separation of state, presentation, and tests |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 8. Summary
|
## ✅ 8. Summary
|
||||||
|
|
||||||
At this point, your **TestDrive-UI** ecosystem includes:
|
At this point, your **TestDrive-UI** ecosystem includes:
|
||||||
|
|
||||||
| Layer | Responsibility |
|
| Layer | Responsibility |
|
||||||
| ----------------- | ---------------------------- |
|
| ----------------- | ---------------------------- |
|
||||||
| `hello-world` | Simple reactive component |
|
| `hello-world` | Simple reactive component |
|
||||||
| `hello-dashboard` | Container and coordinator |
|
| `hello-dashboard` | Container and coordinator |
|
||||||
| `hello-store` | Shared, observable state |
|
| `hello-store` | Shared, observable state |
|
||||||
| Tests | Guard behavior at each level |
|
| Tests | Guard behavior at each level |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔮 9. Next Tutorial (optional direction)
|
## 🔮 9. Next Tutorial (optional direction)
|
||||||
|
|
||||||
You can now evolve in one of two directions:
|
You can now evolve in one of two directions:
|
||||||
|
|
||||||
1. **Tutorial 7 — Persistence**
|
1. **Tutorial 7 — Persistence**
|
||||||
→ Save and restore store state via `localStorage` or IndexedDB (so greetings persist).
|
→ Save and restore store state via `localStorage` or IndexedDB (so greetings persist).
|
||||||
|
|
||||||
2. **Tutorial 8 — Agent Automation**
|
2. **Tutorial 8 — Agent Automation**
|
||||||
→ Introduce agent-driven TDD loops: use LLMs to generate new tests, detect regressions, and propose refactorings automatically.
|
→ Introduce agent-driven TDD loops: use LLMs to generate new tests, detect regressions, and propose refactorings automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Continue with **Tutorial 7: State Persistence** next — or move directly into **agent-driven TDD automation**.
|
Continue with **Tutorial 7: State Persistence** next — or move directly into **agent-driven TDD automation**.
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
|
|||||||
@@ -1,238 +1,238 @@
|
|||||||
# Tutorial 7: Persistence Patterns
|
# Tutorial 7: Persistence Patterns
|
||||||
|
|
||||||
This tutorial teaches both **localStorage** and **IndexedDB** persistence patterns for your reactive store.
|
This tutorial teaches both **localStorage** and **IndexedDB** persistence patterns for your reactive store.
|
||||||
|
|
||||||
### “Saving Greetings” — Making the store survive reloads
|
### “Saving Greetings” — Making the store survive reloads
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Goal
|
## 🎯 Goal
|
||||||
|
|
||||||
Extend the `helloStore` so that all greetings persist between browser sessions.
|
Extend the `helloStore` so that all greetings persist between browser sessions.
|
||||||
When the page reloads, previously added greeters are restored automatically.
|
When the page reloads, previously added greeters are restored automatically.
|
||||||
|
|
||||||
We’ll implement this in **two steps**:
|
We’ll implement this in **two steps**:
|
||||||
|
|
||||||
1. Start with simple **`localStorage`** persistence (fast, synchronous).
|
1. Start with simple **`localStorage`** persistence (fast, synchronous).
|
||||||
2. Upgrade to **`IndexedDB`** for larger data or structured offline storage.
|
2. Upgrade to **`IndexedDB`** for larger data or structured offline storage.
|
||||||
|
|
||||||
Both variants will keep your TestDrive-UI environment self-contained and testable.
|
Both variants will keep your TestDrive-UI environment self-contained and testable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Step 1 — Write the failing tests
|
## 🧪 Step 1 — Write the failing tests
|
||||||
|
|
||||||
Create a new file:
|
Create a new file:
|
||||||
`src/store/hello-store.persistence.test.js`
|
`src/store/hello-store.persistence.test.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { helloStore } from "./hello-store.js";
|
import { helloStore } from "./hello-store.js";
|
||||||
|
|
||||||
describe("helloStore persistence", () => {
|
describe("helloStore persistence", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clean simulated storage
|
// Clean simulated storage
|
||||||
global.localStorage = {
|
global.localStorage = {
|
||||||
store: {},
|
store: {},
|
||||||
getItem(k) { return this.store[k] || null; },
|
getItem(k) { return this.store[k] || null; },
|
||||||
setItem(k, v) { this.store[k] = v; },
|
setItem(k, v) { this.store[k] = v; },
|
||||||
clear() { this.store = {}; }
|
clear() { this.store = {}; }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves state to localStorage after modification", () => {
|
it("saves state to localStorage after modification", () => {
|
||||||
helloStore.addGreeter("Persisty");
|
helloStore.addGreeter("Persisty");
|
||||||
const saved = JSON.parse(localStorage.getItem("helloStore"));
|
const saved = JSON.parse(localStorage.getItem("helloStore"));
|
||||||
expect(saved.greeters).to.include("Persisty");
|
expect(saved.greeters).to.include("Persisty");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores saved state when re-initialized", () => {
|
it("restores saved state when re-initialized", () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"helloStore",
|
"helloStore",
|
||||||
JSON.stringify({ greeters: ["Restored"] })
|
JSON.stringify({ greeters: ["Restored"] })
|
||||||
);
|
);
|
||||||
const { HelloStore } = await import("./hello-store.js");
|
const { HelloStore } = await import("./hello-store.js");
|
||||||
const freshStore = new HelloStore();
|
const freshStore = new HelloStore();
|
||||||
const state = freshStore.getState();
|
const state = freshStore.getState();
|
||||||
expect(state.greeters).to.deep.equal(["Restored"]);
|
expect(state.greeters).to.deep.equal(["Restored"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
All tests will fail initially — perfect.
|
All tests will fail initially — perfect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ Step 2 — Implement `localStorage` persistence
|
## ⚙️ Step 2 — Implement `localStorage` persistence
|
||||||
|
|
||||||
Open `src/store/hello-store.js` and extend it like this:
|
Open `src/store/hello-store.js` and extend it like this:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
class HelloStore {
|
class HelloStore {
|
||||||
constructor() {
|
constructor() {
|
||||||
const saved = localStorage.getItem("helloStore");
|
const saved = localStorage.getItem("helloStore");
|
||||||
this.state = saved ? JSON.parse(saved) : { greeters: ["World"] };
|
this.state = saved ? JSON.parse(saved) : { greeters: ["World"] };
|
||||||
this.listeners = new Set();
|
this.listeners = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
getState() {
|
getState() {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
_notify() {
|
_notify() {
|
||||||
// Save current state before notifying
|
// Save current state before notifying
|
||||||
localStorage.setItem("helloStore", JSON.stringify(this.state));
|
localStorage.setItem("helloStore", JSON.stringify(this.state));
|
||||||
for (const cb of this.listeners) cb(this.state);
|
for (const cb of this.listeners) cb(this.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
this.listeners.add(callback);
|
this.listeners.add(callback);
|
||||||
callback(this.state);
|
callback(this.state);
|
||||||
return () => this.listeners.delete(callback);
|
return () => this.listeners.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
addGreeter(name) {
|
addGreeter(name) {
|
||||||
this.state = { ...this.state, greeters: [...this.state.greeters, name] };
|
this.state = { ...this.state, greeters: [...this.state.greeters, name] };
|
||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
renameGreeter(oldName, newName) {
|
renameGreeter(oldName, newName) {
|
||||||
const updated = this.state.greeters.map((n) =>
|
const updated = this.state.greeters.map((n) =>
|
||||||
n === oldName ? newName : n
|
n === oldName ? newName : n
|
||||||
);
|
);
|
||||||
this.state = { ...this.state, greeters: updated };
|
this.state = { ...this.state, greeters: updated };
|
||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const helloStore = new HelloStore();
|
export const helloStore = new HelloStore();
|
||||||
export { HelloStore }; // exported for testing
|
export { HelloStore }; // exported for testing
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ Run `npm test` again — tests should now pass.
|
✅ Run `npm test` again — tests should now pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 Step 3 — Try it live
|
## 🌐 Step 3 — Try it live
|
||||||
|
|
||||||
Start your dev server:
|
Start your dev server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Add new greeters in the dashboard.
|
1. Add new greeters in the dashboard.
|
||||||
2. Reload the browser.
|
2. Reload the browser.
|
||||||
→ The same greeters reappear immediately!
|
→ The same greeters reappear immediately!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧱 Step 4 — Optional: Switch to IndexedDB for larger data
|
## 🧱 Step 4 — Optional: Switch to IndexedDB for larger data
|
||||||
|
|
||||||
`localStorage` is fine for small JSON objects.
|
`localStorage` is fine for small JSON objects.
|
||||||
To persist more complex data or avoid blocking the main thread, you can use **IndexedDB** (via the tiny `idb-keyval` helper or native API).
|
To persist more complex data or avoid blocking the main thread, you can use **IndexedDB** (via the tiny `idb-keyval` helper or native API).
|
||||||
|
|
||||||
Create `src/store/storage.js`:
|
Create `src/store/storage.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Minimal IndexedDB wrapper using idb-keyval style API
|
// Minimal IndexedDB wrapper using idb-keyval style API
|
||||||
const DB_NAME = "testdrive-ui";
|
const DB_NAME = "testdrive-ui";
|
||||||
const STORE_NAME = "hello";
|
const STORE_NAME = "hello";
|
||||||
|
|
||||||
export async function saveState(state) {
|
export async function saveState(state) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = indexedDB.open(DB_NAME, 1);
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
const tx = req.result.transaction(STORE_NAME, "readwrite");
|
const tx = req.result.transaction(STORE_NAME, "readwrite");
|
||||||
tx.objectStore(STORE_NAME).put(state, "store");
|
tx.objectStore(STORE_NAME).put(state, "store");
|
||||||
tx.oncomplete = resolve;
|
tx.oncomplete = resolve;
|
||||||
tx.onerror = reject;
|
tx.onerror = reject;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadState() {
|
export async function loadState() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = indexedDB.open(DB_NAME, 1);
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
const tx = req.result.transaction(STORE_NAME, "readonly");
|
const tx = req.result.transaction(STORE_NAME, "readonly");
|
||||||
const get = tx.objectStore(STORE_NAME).get("store");
|
const get = tx.objectStore(STORE_NAME).get("store");
|
||||||
get.onsuccess = () => resolve(get.result);
|
get.onsuccess = () => resolve(get.result);
|
||||||
get.onerror = reject;
|
get.onerror = reject;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Now update the store’s `_notify()` and constructor to use these async calls:
|
Now update the store’s `_notify()` and constructor to use these async calls:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { saveState, loadState } from "./storage.js";
|
import { saveState, loadState } from "./storage.js";
|
||||||
|
|
||||||
class HelloStore {
|
class HelloStore {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.listeners = new Set();
|
this.listeners = new Set();
|
||||||
loadState().then(
|
loadState().then(
|
||||||
(saved) =>
|
(saved) =>
|
||||||
(this.state = saved || { greeters: ["World"] }) &&
|
(this.state = saved || { greeters: ["World"] }) &&
|
||||||
this._notify()
|
this._notify()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _notify() {
|
async _notify() {
|
||||||
await saveState(this.state);
|
await saveState(this.state);
|
||||||
for (const cb of this.listeners) cb(this.state);
|
for (const cb of this.listeners) cb(this.state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
💡 Tip: because IndexedDB operations are async, wrap state changes in `async` methods and adjust your tests with `await`.
|
💡 Tip: because IndexedDB operations are async, wrap state changes in `async` methods and adjust your tests with `await`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 Step 5 — Testing IndexedDB logic
|
## 🧠 Step 5 — Testing IndexedDB logic
|
||||||
|
|
||||||
In Node environments, you can simulate IndexedDB with `fake-indexeddb`:
|
In Node environments, you can simulate IndexedDB with `fake-indexeddb`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --save-dev fake-indexeddb
|
npm install --save-dev fake-indexeddb
|
||||||
```
|
```
|
||||||
|
|
||||||
Then in your test setup:
|
Then in your test setup:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
```
|
```
|
||||||
|
|
||||||
Your tests now work without a browser.
|
Your tests now work without a browser.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Outcome
|
## ✅ Outcome
|
||||||
|
|
||||||
Your TestDrive-UI environment now supports:
|
Your TestDrive-UI environment now supports:
|
||||||
|
|
||||||
| Persistence Layer | Use Case | Pros |
|
| Persistence Layer | Use Case | Pros |
|
||||||
| ----------------- | ------------------------------------- | ------------------ |
|
| ----------------- | ------------------------------------- | ------------------ |
|
||||||
| **localStorage** | Small, synchronous, quick persistence | Simple & immediate |
|
| **localStorage** | Small, synchronous, quick persistence | Simple & immediate |
|
||||||
| **IndexedDB** | Large, structured, async persistence | Scalable & robust |
|
| **IndexedDB** | Large, structured, async persistence | Scalable & robust |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 Reflection
|
## 🔍 Reflection
|
||||||
|
|
||||||
Persistence transforms your store into a **long-term memory layer**.
|
Persistence transforms your store into a **long-term memory layer**.
|
||||||
With this, TestDrive-UI crosses into **progressive-web-app territory** — offline-ready, recoverable, and reliable.
|
With this, TestDrive-UI crosses into **progressive-web-app territory** — offline-ready, recoverable, and reliable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Continue to Tutorial 8 on Agent Automation.
|
Continue to Tutorial 8 on Agent Automation.
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
|
|||||||
@@ -1,242 +1,242 @@
|
|||||||
# 🤖 Tutorial 8 — Agent-Driven TDD Automation
|
# 🤖 Tutorial 8 — Agent-Driven TDD Automation
|
||||||
|
|
||||||
This tutorial focuses on *Agent-Driven TDD Automation* — turning TestDrive-UI into a living collaboration space between human developers and coding agents.
|
This tutorial focuses on *Agent-Driven TDD Automation* — turning TestDrive-UI into a living collaboration space between human developers and coding agents.
|
||||||
|
|
||||||
### “Coding with Companions” — Using AI Agents for Continuous Improvement
|
### “Coding with Companions” — Using AI Agents for Continuous Improvement
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Goal
|
## 🎯 Goal
|
||||||
|
|
||||||
Integrate LLM-based coding agents into your TestDrive-UI workflow so they can:
|
Integrate LLM-based coding agents into your TestDrive-UI workflow so they can:
|
||||||
|
|
||||||
1. **Generate new tests** from natural-language requirements.
|
1. **Generate new tests** from natural-language requirements.
|
||||||
2. **Run tests and detect regressions** automatically.
|
2. **Run tests and detect regressions** automatically.
|
||||||
3. **Propose targeted refactorings or patches** when failures occur.
|
3. **Propose targeted refactorings or patches** when failures occur.
|
||||||
|
|
||||||
By combining deterministic testing with creative reasoning, you build a feedback loop that never stops improving your code.
|
By combining deterministic testing with creative reasoning, you build a feedback loop that never stops improving your code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 Concept Overview
|
## 🧩 Concept Overview
|
||||||
|
|
||||||
Traditional TDD cycle:
|
Traditional TDD cycle:
|
||||||
|
|
||||||
```
|
```
|
||||||
requirement → test → fail → code → pass → refactor
|
requirement → test → fail → code → pass → refactor
|
||||||
```
|
```
|
||||||
|
|
||||||
Agent-Driven TDD cycle:
|
Agent-Driven TDD cycle:
|
||||||
|
|
||||||
```
|
```
|
||||||
requirement → agent generates test → run → fail →
|
requirement → agent generates test → run → fail →
|
||||||
agent proposes fix → run → pass → review → merge
|
agent proposes fix → run → pass → review → merge
|
||||||
```
|
```
|
||||||
|
|
||||||
You remain the architect and reviewer — the agent acts as an automated junior developer executing fast, repeatable loops.
|
You remain the architect and reviewer — the agent acts as an automated junior developer executing fast, repeatable loops.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Step 1 — Define a Machine-Readable Requirement
|
## 🧪 Step 1 — Define a Machine-Readable Requirement
|
||||||
|
|
||||||
Create a simple JSON file to store behavioral specifications.
|
Create a simple JSON file to store behavioral specifications.
|
||||||
|
|
||||||
`specs/hello-world.name-change.json`
|
`specs/hello-world.name-change.json`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"component": "hello-world",
|
"component": "hello-world",
|
||||||
"feature": "name-change",
|
"feature": "name-change",
|
||||||
"description": "Greeting text should update when the user types a new name.",
|
"description": "Greeting text should update when the user types a new name.",
|
||||||
"expected_behavior": [
|
"expected_behavior": [
|
||||||
"Typing in the input field updates the displayed greeting immediately.",
|
"Typing in the input field updates the displayed greeting immediately.",
|
||||||
"The component property 'name' reflects the latest input value."
|
"The component property 'name' reflects the latest input value."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Agents use these files as prompts to generate corresponding tests.
|
Agents use these files as prompts to generate corresponding tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ Step 2 — Agent Generates a Test
|
## ⚙️ Step 2 — Agent Generates a Test
|
||||||
|
|
||||||
An agent reads the JSON spec and produces Mocha tests automatically.
|
An agent reads the JSON spec and produces Mocha tests automatically.
|
||||||
|
|
||||||
Example output from an LLM agent:
|
Example output from an LLM agent:
|
||||||
|
|
||||||
`generated-tests/hello-world.name-change.test.js`
|
`generated-tests/hello-world.name-change.test.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import "./hello-world.js";
|
import "./hello-world.js";
|
||||||
|
|
||||||
describe("<hello-world> (auto-generated name-change)", () => {
|
describe("<hello-world> (auto-generated name-change)", () => {
|
||||||
it("updates greeting text when user types", async () => {
|
it("updates greeting text when user types", async () => {
|
||||||
const el = document.createElement("hello-world");
|
const el = document.createElement("hello-world");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
const input = el.shadowRoot.querySelector("input");
|
const input = el.shadowRoot.querySelector("input");
|
||||||
input.value = "Agent";
|
input.value = "Agent";
|
||||||
input.dispatchEvent(new Event("input"));
|
input.dispatchEvent(new Event("input"));
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
const greeting = el.shadowRoot.querySelector(".greeting");
|
const greeting = el.shadowRoot.querySelector(".greeting");
|
||||||
expect(greeting.textContent.trim()).to.equal("Hello, Agent!");
|
expect(greeting.textContent.trim()).to.equal("Hello, Agent!");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Run your full suite:
|
Run your full suite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
If the test fails, the agent analyzes output and proposes a minimal fix for `hello-world.js`.
|
If the test fails, the agent analyzes output and proposes a minimal fix for `hello-world.js`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 Step 3 — Detect Regressions
|
## 🔍 Step 3 — Detect Regressions
|
||||||
|
|
||||||
Agents monitor test results over time by parsing Mocha’s machine-readable report (`--reporter json`).
|
Agents monitor test results over time by parsing Mocha’s machine-readable report (`--reporter json`).
|
||||||
|
|
||||||
Example agent logic (pseudocode):
|
Example agent logic (pseudocode):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def analyze_report(report):
|
def analyze_report(report):
|
||||||
failed = [t for t in report['tests'] if t['err']]
|
failed = [t for t in report['tests'] if t['err']]
|
||||||
if not failed:
|
if not failed:
|
||||||
return "All green!"
|
return "All green!"
|
||||||
for f in failed:
|
for f in failed:
|
||||||
print(f"Regression in {f['title']}: {f['err']['message']}")
|
print(f"Regression in {f['title']}: {f['err']['message']}")
|
||||||
propose_fix(f)
|
propose_fix(f)
|
||||||
```
|
```
|
||||||
|
|
||||||
This analysis lets agents flag new failures, auto-create issues, or propose PRs.
|
This analysis lets agents flag new failures, auto-create issues, or propose PRs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧠 Step 4 — Agent Proposes Refactorings
|
## 🧠 Step 4 — Agent Proposes Refactorings
|
||||||
|
|
||||||
Once tests are green, agents can examine code for quality signals:
|
Once tests are green, agents can examine code for quality signals:
|
||||||
|
|
||||||
| Heuristic | Example Action |
|
| Heuristic | Example Action |
|
||||||
| --------------------- | ------------------------------------------ |
|
| --------------------- | ------------------------------------------ |
|
||||||
| Duplicate logic | Extract shared helper or controller |
|
| Duplicate logic | Extract shared helper or controller |
|
||||||
| Excessive DOM queries | Cache references or use ReactiveController |
|
| Excessive DOM queries | Cache references or use ReactiveController |
|
||||||
| Long methods | Suggest method splitting |
|
| Long methods | Suggest method splitting |
|
||||||
| Repeated strings | Propose localization constants |
|
| Repeated strings | Propose localization constants |
|
||||||
|
|
||||||
Example prompt to your agent:
|
Example prompt to your agent:
|
||||||
|
|
||||||
```
|
```
|
||||||
“Review src/components/hello-world.js and propose a refactor
|
“Review src/components/hello-world.js and propose a refactor
|
||||||
that reduces duplication without breaking current tests.”
|
that reduces duplication without breaking current tests.”
|
||||||
```
|
```
|
||||||
|
|
||||||
The agent runs tests after each change to validate its proposal.
|
The agent runs tests after each change to validate its proposal.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔁 Step 5 — Automate the Loop
|
## 🔁 Step 5 — Automate the Loop
|
||||||
|
|
||||||
Create a simple Node script to chain the whole process.
|
Create a simple Node script to chain the whole process.
|
||||||
|
|
||||||
`scripts/agent-tdd.js`
|
`scripts/agent-tdd.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import OpenAI from "openai"; // or another LLM SDK
|
import OpenAI from "openai"; // or another LLM SDK
|
||||||
|
|
||||||
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
try {
|
try {
|
||||||
const output = execSync("npx mocha --reporter json", { encoding: "utf-8" });
|
const output = execSync("npx mocha --reporter json", { encoding: "utf-8" });
|
||||||
return JSON.parse(output);
|
return JSON.parse(output);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return JSON.parse(e.stdout);
|
return JSON.parse(e.stdout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const report = runTests();
|
const report = runTests();
|
||||||
const failed = report.tests.filter(t => t.err.message);
|
const failed = report.tests.filter(t => t.err.message);
|
||||||
if (failed.length === 0) return console.log("✅ All tests passed");
|
if (failed.length === 0) return console.log("✅ All tests passed");
|
||||||
|
|
||||||
const prompt = `Some tests failed:\n${JSON.stringify(failed, null, 2)}\n
|
const prompt = `Some tests failed:\n${JSON.stringify(failed, null, 2)}\n
|
||||||
Propose minimal code changes to fix them.`;
|
Propose minimal code changes to fix them.`;
|
||||||
const completion = await client.responses.create({ model: "gpt-5", input: prompt });
|
const completion = await client.responses.create({ model: "gpt-5", input: prompt });
|
||||||
fs.writeFileSync("agent-proposal.txt", completion.output_text);
|
fs.writeFileSync("agent-proposal.txt", completion.output_text);
|
||||||
console.log("💡 Agent proposal written to agent-proposal.txt");
|
console.log("💡 Agent proposal written to agent-proposal.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
```
|
```
|
||||||
|
|
||||||
This script connects your tests with an AI assistant that learns and suggests fixes continuously.
|
This script connects your tests with an AI assistant that learns and suggests fixes continuously.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧰 Step 6 — Integrate into CI
|
## 🧰 Step 6 — Integrate into CI
|
||||||
|
|
||||||
Add a CI job (e.g., GitHub Actions or local cron) to run the agent loop daily or on push.
|
Add a CI job (e.g., GitHub Actions or local cron) to run the agent loop daily or on push.
|
||||||
|
|
||||||
Example workflow:
|
Example workflow:
|
||||||
|
|
||||||
```
|
```
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 2 * * *'
|
- cron: '0 2 * * *'
|
||||||
jobs:
|
jobs:
|
||||||
agent-tdd:
|
agent-tdd:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: node scripts/agent-tdd.js
|
- run: node scripts/agent-tdd.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Now your project self-tests and self-critiques even when you’re offline.
|
Now your project self-tests and self-critiques even when you’re offline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧩 Step 7 — Visualize Agent Progress
|
## 🧩 Step 7 — Visualize Agent Progress
|
||||||
|
|
||||||
Agents can log progress into a dashboard component (`<agent-console>`) showing:
|
Agents can log progress into a dashboard component (`<agent-console>`) showing:
|
||||||
|
|
||||||
* Number of tests generated.
|
* Number of tests generated.
|
||||||
* Pass/fail trend over time.
|
* Pass/fail trend over time.
|
||||||
* Proposed vs. accepted refactors.
|
* Proposed vs. accepted refactors.
|
||||||
|
|
||||||
It’s your window into the machine’s learning curve.
|
It’s your window into the machine’s learning curve.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Outcome
|
## ✅ Outcome
|
||||||
|
|
||||||
You now have a self-reinforcing loop:
|
You now have a self-reinforcing loop:
|
||||||
|
|
||||||
1. Humans write specs.
|
1. Humans write specs.
|
||||||
2. Agents create tests and code.
|
2. Agents create tests and code.
|
||||||
3. The suite proves stability.
|
3. The suite proves stability.
|
||||||
4. Agents refactor and review under guard of tests.
|
4. Agents refactor and review under guard of tests.
|
||||||
|
|
||||||
This combines the discipline of TDD with the creativity and endurance of AI.
|
This combines the discipline of TDD with the creativity and endurance of AI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 Next Steps
|
## 🔍 Next Steps
|
||||||
|
|
||||||
* Add **semantic diff filters** so agents learn from accepted patches.
|
* Add **semantic diff filters** so agents learn from accepted patches.
|
||||||
* Train agents to cluster tests into *feature domains* for smarter coverage analysis.
|
* Train agents to cluster tests into *feature domains* for smarter coverage analysis.
|
||||||
* Integrate Storybook snapshots for visual regression detection.
|
* Integrate Storybook snapshots for visual regression detection.
|
||||||
* Build a CLI (`npx agent-tdd`) to run and audit your AI test loops interactively.
|
* Build a CLI (`npx agent-tdd`) to run and audit your AI test loops interactively.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Congratulations! You finished all tutorials and should be fine going Forward Building components.
|
Congratulations! You finished all tutorials and should be fine going Forward Building components.
|
||||||
|
|
||||||
Feel free to tell us which additional tutorials we should provide.
|
Feel free to tell us which additional tutorials we should provide.
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
Reference in New Issue
Block a user