@skilly-hand/skilly-hand 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ # TDD Templates: RED → GREEN → REFACTOR Cycles
2
+
3
+ Real examples of the RED → GREEN → REFACTOR TDD cycle for implementing components, services, and pipes.
4
+
5
+ **Pattern**: Write failing test → Write minimum code to pass → Refactor while tests stay green
6
+
7
+ ---
8
+
9
+ ## Example 1: Simple Pipe Component
10
+
11
+ ### Scenario: AsyncCachePipe
12
+ Cache hot observables to prevent resubscription overhead.
13
+
14
+ #### Step 1: RED — Write Failing Test
15
+
16
+ ```typescript
17
+ // async-cache.pipe.test.ts
18
+ import { TestBed } from '@angular/core/testing';
19
+ import { Subject, take } from 'rxjs';
20
+ import { AsyncCachePipe } from './async-cache.pipe';
21
+
22
+ describe('AsyncCachePipe', () => {
23
+ let pipe: AsyncCachePipe;
24
+
25
+ beforeEach(() => {
26
+ TestBed.configureTestingModule({});
27
+ pipe = TestBed.runInInjectionContext(() => new AsyncCachePipe());
28
+ });
29
+
30
+ it('should cache async values and replay on late subscriptions', (done) => {
31
+ // GIVEN: An observable that emits after 100ms
32
+ let emitCount = 0;
33
+ const source$ = new Subject<number>();
34
+
35
+ // WHEN: First subscription
36
+ const result$ = pipe.transform(source$);
37
+ const values: number[] = [];
38
+
39
+ const sub1 = result$.subscribe((val) => {
40
+ emitCount++;
41
+ values.push(val);
42
+ });
43
+
44
+ setTimeout(() => {
45
+ source$.next(42);
46
+ }, 100);
47
+
48
+ // WHEN: Second subscription arrives after value is cached
49
+ setTimeout(() => {
50
+ const sub2 = result$.pipe(take(1)).subscribe(() => {
51
+ // THEN: Second subscriber gets cached value immediately
52
+ expect(emitCount).toBe(1);
53
+ expect(values[0]).toBe(42);
54
+
55
+ sub1.unsubscribe();
56
+ sub2.unsubscribe();
57
+ source$.complete();
58
+ done();
59
+ });
60
+ }, 200);
61
+ });
62
+
63
+ // ❌ TEST FAILS HERE - transform() not implemented yet
64
+ });
65
+ ```
66
+
67
+ **Run**: `npm test -- async-cache.pipe.test.ts`
68
+ ```
69
+ FAIL async-cache.pipe.test.ts
70
+ should cache async values and replay on late subscriptions
71
+ TypeError: pipe.transform is not a function
72
+ ```
73
+
74
+ ✅ Test fails as expected (proves test is meaningful)
75
+
76
+ ---
77
+
78
+ #### Step 2: GREEN — Write Minimum Code
79
+
80
+ ```typescript
81
+ // async-cache.pipe.ts
82
+ import { Pipe, PipeTransform } from '@angular/core';
83
+ import { Observable, ReplaySubject } from 'rxjs';
84
+ import { tap } from 'rxjs/operators';
85
+
86
+ @Pipe({
87
+ name: 'asyncCache',
88
+ standalone: true,
89
+ })
90
+ export class AsyncCachePipe implements PipeTransform {
91
+ // ✅ MINIMUM: Just make the test pass
92
+ transform<T>(source: Observable<T>): Observable<T> {
93
+ const cached$ = new ReplaySubject<T>(1);
94
+ source.pipe(tap((val) => cached$.next(val))).subscribe();
95
+ return cached$.asObservable();
96
+ }
97
+ }
98
+ ```
99
+
100
+ **Run**: `npm test -- async-cache.pipe.test.ts`
101
+ ```
102
+ PASS async-cache.pipe.test.ts
103
+ should cache async values and replay on late subscriptions ✓
104
+
105
+ Tests: 1 passed, 1 total
106
+ ```
107
+
108
+ ✅ Test passes (behavior is correct, but code needs cleanup)
109
+
110
+ ---
111
+
112
+ #### Step 3: REFACTOR — Improve Without Changing Behavior
113
+
114
+ ```typescript
115
+ // async-cache.pipe.ts (refactored)
116
+ import { Pipe, PipeTransform } from '@angular/core';
117
+ import { Observable, ReplaySubject, Subject, takeUntil } from 'rxjs';
118
+ import { tap } from 'rxjs/operators';
119
+
120
+ @Pipe({
121
+ name: 'asyncCache',
122
+ standalone: true,
123
+ })
124
+ export class AsyncCachePipe implements PipeTransform {
125
+ private destroy$ = new Subject<void>();
126
+
127
+ // ✅ REFACTORED: Cleaner, adds cleanup
128
+ transform<T>(source: Observable<T>): Observable<T> {
129
+ const cacheSize = 1;
130
+ const cached$ = new ReplaySubject<T>(cacheSize);
131
+
132
+ source
133
+ .pipe(
134
+ tap((val) => cached$.next(val)),
135
+ takeUntil(this.destroy$)
136
+ )
137
+ .subscribe();
138
+
139
+ return cached$.asObservable();
140
+ }
141
+
142
+ ngOnDestroy(): void {
143
+ this.destroy$.next();
144
+ this.destroy$.complete();
145
+ }
146
+ }
147
+ ```
148
+
149
+ **Run**: `npm test -- async-cache.pipe.test.ts`
150
+ ```
151
+ PASS async-cache.pipe.test.ts
152
+ should cache async values and replay on late subscriptions ✓
153
+ ```
154
+
155
+ ✅ Test still passes
156
+
157
+ **What improved**:
158
+ - Constants extracted (`cacheSize`)
159
+ - Cleanup logic added (`takeUntil`, `ngOnDestroy`)
160
+
161
+ ---
162
+
163
+ ## Example 2: Component with Input/Output
164
+
165
+ ### Scenario: AlertComponent
166
+ Display a dismissible alert with a message.
167
+
168
+ #### Step 1: RED — Write Failing Test
169
+
170
+ ```typescript
171
+ // alert.component.test.ts
172
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
173
+ import { AlertComponent } from './alert.component';
174
+
175
+ describe('AlertComponent', () => {
176
+ let component: AlertComponent;
177
+ let fixture: ComponentFixture<AlertComponent>;
178
+
179
+ beforeEach(async () => {
180
+ await TestBed.configureTestingModule({
181
+ imports: [AlertComponent],
182
+ }).compileComponents();
183
+
184
+ fixture = TestBed.createComponent(AlertComponent);
185
+ component = fixture.componentInstance;
186
+ });
187
+
188
+ it('should display the alert message', () => {
189
+ // GIVEN: Component with message input
190
+ component.message = 'Error: Invalid input';
191
+ fixture.detectChanges();
192
+
193
+ // WHEN: Component renders
194
+ const messageEl = fixture.debugElement.nativeElement.querySelector('[data-testid="alert-message"]');
195
+
196
+ // THEN: Message is visible
197
+ expect(messageEl?.textContent).toBe('Error: Invalid input');
198
+ });
199
+
200
+ // ❌ FAILS - Component doesn't exist yet
201
+ });
202
+ ```
203
+
204
+ **Run**: `npm test -- alert.component.test.ts`
205
+ ```
206
+ FAIL alert.component.test.ts
207
+ Cannot find component 'AlertComponent'
208
+ ```
209
+
210
+ ---
211
+
212
+ #### Step 2: GREEN — Write Minimum Implementation
213
+
214
+ ```typescript
215
+ // alert.component.ts
216
+ import { Component, input } from '@angular/core';
217
+
218
+ @Component({
219
+ selector: 'app-alert',
220
+ standalone: true,
221
+ template: `<div data-testid="alert-message">{{ message() }}</div>`,
222
+ })
223
+ export class AlertComponent {
224
+ // ✅ MINIMUM: Just the message input
225
+ message = input<string>('');
226
+ }
227
+ ```
228
+
229
+ **Run**: `npm test -- alert.component.test.ts`
230
+ ```
231
+ PASS alert.component.test.ts
232
+ should display the alert message ✓
233
+ ```
234
+
235
+ ---
236
+
237
+ #### Step 3: REFACTOR — Add Accessibility and Dismiss
238
+
239
+ ```typescript
240
+ // alert.component.ts (refactored)
241
+ import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
242
+
243
+ @Component({
244
+ selector: 'app-alert',
245
+ standalone: true,
246
+ template: `
247
+ <div
248
+ class="alert"
249
+ role="alert"
250
+ aria-live="polite"
251
+ [attr.data-testid]="'alert-message'"
252
+ [class]="'alert--' + severity()">
253
+ <p class="alert__message">{{ message() }}</p>
254
+ <button class="alert__close" aria-label="Dismiss alert" (click)="onDismiss()">✕</button>
255
+ </div>
256
+ `,
257
+ changeDetection: ChangeDetectionStrategy.OnPush,
258
+ })
259
+ export class AlertComponent {
260
+ message = input<string>('');
261
+ severity = input<'success' | 'error' | 'warning' | 'info'>('info');
262
+ dismissed = output<void>();
263
+
264
+ onDismiss(): void {
265
+ this.dismissed.emit();
266
+ }
267
+ }
268
+ ```
269
+
270
+ **Run**: `npm test -- alert.component.test.ts`
271
+ ```
272
+ PASS alert.component.test.ts
273
+ should display the alert message ✓
274
+ ```
275
+
276
+ ✅ Test still passes
277
+
278
+ **What improved**:
279
+ - Accessibility attributes (`role="alert"`, `aria-live`, `aria-label`)
280
+ - Dismiss output and handler
281
+ - Severity levels
282
+ - `ChangeDetectionStrategy.OnPush`
283
+
284
+ ---
285
+
286
+ ## Example 3: Service with State Management
287
+
288
+ ### Scenario: TodoService
289
+ Manage a todo list with add and complete operations.
290
+
291
+ #### Step 1: RED — Multiple Scenarios
292
+
293
+ ```typescript
294
+ // todo.service.test.ts
295
+ import { TestBed } from '@angular/core/testing';
296
+ import { TodoService } from './todo.service';
297
+
298
+ describe('TodoService', () => {
299
+ let service: TodoService;
300
+
301
+ beforeEach(() => {
302
+ TestBed.configureTestingModule({ providers: [TodoService] });
303
+ service = TestBed.inject(TodoService);
304
+ });
305
+
306
+ it('should add a todo and update the list', () => {
307
+ // GIVEN: Empty list
308
+ // WHEN: Add todo
309
+ service.addTodo('Buy milk');
310
+
311
+ // THEN: Todo appears
312
+ expect(service.todos()).toContainEqual(
313
+ jasmine.objectContaining({ text: 'Buy milk', done: false })
314
+ );
315
+ });
316
+
317
+ it('should mark a todo as complete', () => {
318
+ // GIVEN: A todo in the list
319
+ service.addTodo('Buy milk');
320
+ const firstTodo = service.todos()[0];
321
+
322
+ // WHEN: Mark as complete
323
+ service.completeTodo(firstTodo.id);
324
+
325
+ // THEN: Todo is done
326
+ expect(service.todos()[0].done).toBe(true);
327
+ });
328
+
329
+ // ❌ FAILS - Service methods don't exist
330
+ });
331
+ ```
332
+
333
+ **Run**: `npm test -- todo.service.test.ts`
334
+ ```
335
+ FAIL todo.service.test.ts
336
+ service.addTodo is not a function
337
+ ```
338
+
339
+ ---
340
+
341
+ #### Step 2: GREEN — Write Minimum Implementation
342
+
343
+ ```typescript
344
+ // todo.service.ts
345
+ import { Injectable, signal } from '@angular/core';
346
+
347
+ interface Todo { id: number; text: string; done: boolean; }
348
+
349
+ @Injectable({ providedIn: 'root' })
350
+ export class TodoService {
351
+ todos = signal<Todo[]>([]);
352
+ private nextId = 1;
353
+
354
+ addTodo(text: string): void {
355
+ this.todos.update((current) => [...current, { id: this.nextId++, text, done: false }]);
356
+ }
357
+
358
+ completeTodo(id: number): void {
359
+ this.todos.update((current) =>
360
+ current.map((todo) => todo.id === id ? { ...todo, done: true } : todo)
361
+ );
362
+ }
363
+ }
364
+ ```
365
+
366
+ **Run**: `npm test -- todo.service.test.ts`
367
+ ```
368
+ PASS todo.service.test.ts
369
+ should add a todo and update the list ✓
370
+ should mark a todo as complete ✓
371
+ ```
372
+
373
+ ---
374
+
375
+ #### Step 3: REFACTOR — Add Validation and Persistence
376
+
377
+ ```typescript
378
+ // todo.service.ts (refactored)
379
+ import { Injectable, signal } from '@angular/core';
380
+
381
+ interface Todo {
382
+ id: number;
383
+ text: string;
384
+ done: boolean;
385
+ createdAt: Date;
386
+ completedAt?: Date;
387
+ }
388
+
389
+ @Injectable({ providedIn: 'root' })
390
+ export class TodoService {
391
+ todos = signal<Todo[]>([]);
392
+ private nextId = 1;
393
+ private readonly MAX_TODOS = 100;
394
+
395
+ constructor() {
396
+ this.loadFromStorage();
397
+ }
398
+
399
+ addTodo(text: string): void {
400
+ if (!text?.trim()) throw new Error('Todo text cannot be empty');
401
+ if (this.todos().length >= this.MAX_TODOS) throw new Error(`Cannot exceed ${this.MAX_TODOS} todos`);
402
+
403
+ this.todos.update((current) => [
404
+ ...current,
405
+ { id: this.nextId++, text: text.trim(), done: false, createdAt: new Date() },
406
+ ]);
407
+ this.saveToStorage();
408
+ }
409
+
410
+ completeTodo(id: number): void {
411
+ if (!this.todos().find((t) => t.id === id)) throw new Error(`Todo with id ${id} not found`);
412
+
413
+ this.todos.update((current) =>
414
+ current.map((t) => t.id === id ? { ...t, done: true, completedAt: new Date() } : t)
415
+ );
416
+ this.saveToStorage();
417
+ }
418
+
419
+ deleteTodo(id: number): void {
420
+ this.todos.update((current) => current.filter((t) => t.id !== id));
421
+ this.saveToStorage();
422
+ }
423
+
424
+ private loadFromStorage(): void {
425
+ try {
426
+ const stored = localStorage.getItem('todos');
427
+ if (stored) this.todos.set(JSON.parse(stored));
428
+ } catch (e) {
429
+ console.error('Failed to load todos from storage', e);
430
+ }
431
+ }
432
+
433
+ private saveToStorage(): void {
434
+ try {
435
+ localStorage.setItem('todos', JSON.stringify(this.todos()));
436
+ } catch (e) {
437
+ console.error('Failed to save todos to storage', e);
438
+ }
439
+ }
440
+ }
441
+ ```
442
+
443
+ **Run**: `npm test -- todo.service.test.ts`
444
+ ```
445
+ PASS todo.service.test.ts
446
+ should add a todo and update the list ✓
447
+ should mark a todo as complete ✓
448
+ ```
449
+
450
+ ✅ Tests still pass
451
+
452
+ **What improved**:
453
+ - Input validation (empty check, max limit)
454
+ - Timestamps (`createdAt`, `completedAt`)
455
+ - Persistence via `localStorage`
456
+ - `deleteTodo` added for completeness
457
+
458
+ ---
459
+
460
+ ## Quick Checklist: RED → GREEN → REFACTOR
461
+
462
+ ```markdown
463
+ ### For Each Task Using TDD
464
+
465
+ #### RED Phase
466
+ - [ ] Write test that describes the feature
467
+ - [ ] Test has explicit GIVEN / WHEN / THEN
468
+ - [ ] Run tests — FAILS as expected
469
+ - [ ] Failure proves test is meaningful
470
+
471
+ #### GREEN Phase
472
+ - [ ] Write minimum code to pass
473
+ - [ ] No extra features beyond test requirement
474
+ - [ ] Run tests — PASSES
475
+
476
+ #### REFACTOR Phase
477
+ - [ ] Improve code structure / naming
478
+ - [ ] Extract constants, simplify logic
479
+ - [ ] Run tests — STILL PASSES
480
+ - [ ] No behavior changes, only improvements
481
+
482
+ #### Verify
483
+ - [ ] All tests pass: `npm test`
484
+ - [ ] Lint passes: `npm run lint`
485
+ - [ ] Type check: `npx tsc --noEmit`
486
+ - [ ] Build succeeds: `npm run build`
487
+ ```
@@ -0,0 +1,25 @@
1
+ {
2
+ "id": "test-driven-development",
3
+ "title": "Test-Driven Development",
4
+ "description": "Guide implementation using the RED → GREEN → REFACTOR TDD cycle: write a failing test first, write the minimum code to pass, then refactor while tests stay green.",
5
+ "portable": true,
6
+ "tags": ["testing", "workflow", "quality", "core"],
7
+ "detectors": ["always"],
8
+ "detectionTriggers": ["manual"],
9
+ "installsFor": ["all"],
10
+ "agentSupport": ["codex", "claude", "cursor", "gemini", "copilot"],
11
+ "skillMetadata": {
12
+ "author": "skilly-hand",
13
+ "last-edit": "2026-04-04",
14
+ "license": "Apache-2.0",
15
+ "version": "1.0.0",
16
+ "changelog": "Initial TDD skill ported from legacy scannlab-sdd tdd-templates; enables RED→GREEN→REFACTOR workflow across any stack; affects catalog skill coverage for test-first development",
17
+ "auto-invoke": "Implementing features, services, or components using test-driven development (TDD) or RED→GREEN→REFACTOR cycles",
18
+ "allowed-tools": ["Read", "Edit", "Write", "Glob", "Grep", "Bash"]
19
+ },
20
+ "files": [
21
+ { "path": "SKILL.md", "kind": "instruction" },
22
+ { "path": "assets/tdd-cycle.md", "kind": "asset" }
23
+ ],
24
+ "dependencies": []
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilly-hand/skilly-hand",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "publishConfig": {