diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 4e3c67c..734287d 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -15,4 +15,11 @@ export const routes: Routes = [ (m) => m.CreationalOpsComponent ), }, + { + path: 'rxjs/flattening-operators', + loadComponent: () => + import('./rxjs/flattening-ops/flattening-ops.component').then( + (m) => m.FlatteningOpsComponent + ), + }, ]; diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.html b/src/app/rxjs/flattening-ops/flattening-ops.component.html new file mode 100644 index 0000000..2e43b18 --- /dev/null +++ b/src/app/rxjs/flattening-ops/flattening-ops.component.html @@ -0,0 +1,7 @@ +

+ Flattening Operators +

+ +

ConcatMap: {{ concatMapMessage$ | async }}

+ +

MergeMap: {{ mergeMapMessage$ | async }}

diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.scss b/src/app/rxjs/flattening-ops/flattening-ops.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts b/src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts new file mode 100644 index 0000000..dec3728 --- /dev/null +++ b/src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { dispatchFakeEvent, findEl } from './../../spec-helpers/element.spec-helper'; + +import { FlatteningOpsComponent } from './flattening-ops.component'; + +describe('FlatteningOpsComponent', () => { + let component: FlatteningOpsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FlatteningOpsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FlatteningOpsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should log the concatMap flattened observable', (done) => { + // Arrange + const expected = ['0', '1', '2', '3']; // Match actual emissions + const receivedValues: string[] = []; + + // Act + component.concatMapMessage$.subscribe({ + next: (value) => { + receivedValues.push(value); // Capture emitted values + }, + complete: () => { + console.info('concatMap', receivedValues); + + // Assert + expect(receivedValues).toEqual(expected); // Assert emissions directly + done(); // Mark test as completed + } + }); + + // Assert + dispatchFakeEvent(findEl(fixture, 'concatmap-heading').nativeElement, 'click'); // Trigger the fake event + fixture.detectChanges(); + }) + + it('should log the mergeMap flattened observable', (done) => { + // Arrange + const expected = ['0', '1', '2', '0', '3', '1', '2', '3']; // Match actual emissions + const receivedValues: string[] = []; + + // Act + component.mergeMapMessage$.subscribe({ + next: (value) => { + receivedValues.push(value); // Capture emitted values + }, + complete: () => { + console.info('mergeMap', receivedValues); + + // Assert + expect(receivedValues).toEqual(expected); // Assert emissions directly + done(); // Mark test as completed + } + }); + + // Assert + dispatchFakeEvent(findEl(fixture, 'mergemap-heading').nativeElement, 'click'); // Trigger the fake event (1st) + setTimeout(() => { + dispatchFakeEvent(findEl(fixture, 'mergemap-heading').nativeElement, 'click'); // Trigger the fake event (2nd) + fixture.detectChanges(); + }, 100 * 2 + 10 /* Start at the (end-1) of the 1st click + 10ms */) + fixture.detectChanges(); + }) +}); diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.ts b/src/app/rxjs/flattening-ops/flattening-ops.component.ts new file mode 100644 index 0000000..2f09251 --- /dev/null +++ b/src/app/rxjs/flattening-ops/flattening-ops.component.ts @@ -0,0 +1,60 @@ +import { AsyncPipe } from "@angular/common"; +import { Component, DestroyRef, ElementRef, inject, ViewChild } from '@angular/core'; +import { concatMap, fromEvent, interval, map, mergeMap, of, Subject, take, takeUntil, tap } from "rxjs"; + +@Component({ + selector: 'app-flattening-ops', + standalone: true, + imports: [AsyncPipe], + templateUrl: './flattening-ops.component.html', + styleUrl: './flattening-ops.component.scss' +}) +export class FlatteningOpsComponent { + + protected readonly destroy = inject(DestroyRef); + + concatMapMessage$ = new Subject(); + @ViewChild('concatMapHeading') concatMapHeading!: ElementRef; + + mergeMapMessage$ = new Subject(); + mergeMapClickCount = 0; + @ViewChild('mergeMapHeading') mergeMapHeading!: ElementRef; + + ngAfterViewInit() { + this.registerConcatMap(); + } + + onMergeMapClickHandler() { + if (!this.mergeMapClickCount) { // only if first click + setTimeout(() => this.mergeMapMessage$.complete(), 100 * 8 + 10); + } + + this.registerMergeMap().subscribe(); + this.mergeMapClickCount++; + } + + registerConcatMap() { + fromEvent(this.concatMapHeading.nativeElement, 'click').pipe( + tap(() => { + setTimeout(() => this.concatMapMessage$.complete(), 150 * 4 + 10) + }), + takeUntil(this.concatMapMessage$), + concatMap(() => interval(150).pipe(take(4))), + ).subscribe({ + next: (number) => { + this.concatMapMessage$.next(number.toString()); + // Logs: 0, 1, 2, 3 on every click + }, + }) + } + + registerMergeMap() { + return of({}).pipe( + takeUntil(this.mergeMapMessage$), + mergeMap(() => interval(100).pipe(take(4))), + map((number) => { + this.mergeMapMessage$.next(number.toString()) + }) + ); + } +} diff --git a/src/app/spec-helpers/element.spec-helper.ts b/src/app/spec-helpers/element.spec-helper.ts new file mode 100644 index 0000000..0a0bfc8 --- /dev/null +++ b/src/app/spec-helpers/element.spec-helper.ts @@ -0,0 +1,242 @@ +/* istanbul ignore file */ + +import { DebugElement } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +/** + * Spec helpers for working with the DOM + */ + +/** + * Returns a selector for the `data-testid` attribute with the given attribute value. + * + * @param testId Test id set by `data-testid` + * + */ +export function testIdSelector(testId: string): string { + return `[data-testid="${testId}"]`; +} + +/** + * Finds a single element inside the Component by the given CSS selector. + * Throws an error if no element was found. + * + * @param fixture Component fixture + * @param selector CSS selector + * + */ +export function queryByCss( + fixture: ComponentFixture, + selector: string, +): DebugElement { + // The return type of DebugElement#query() is declared as DebugElement, + // but the actual return type is DebugElement | null. + // See https://github.com/angular/angular/issues/22449. + const debugElement = fixture.debugElement.query(By.css(selector)); + // Fail on null so the return type is always DebugElement. + if (!debugElement) { + throw new Error(`queryByCss: Element with ${selector} not found`); + } + return debugElement; +} + +/** + * Finds an element inside the Component by the given `data-testid` attribute. + * Throws an error if no element was found. + * + * @param fixture Component fixture + * @param testId Test id set by `data-testid` + * + */ +export function findEl(fixture: ComponentFixture, testId: string): DebugElement { + return queryByCss(fixture, testIdSelector(testId)); +} + +/** + * Finds all elements with the given `data-testid` attribute. + * + * @param fixture Component fixture + * @param testId Test id set by `data-testid` + */ +export function findEls(fixture: ComponentFixture, testId: string): DebugElement[] { + return fixture.debugElement.queryAll(By.css(testIdSelector(testId))); +} + +/** + * Gets the text content of an element with the given `data-testid` attribute. + * + * @param fixture Component fixture + * @param testId Test id set by `data-testid` + */ +export function getText(fixture: ComponentFixture, testId: string): string { + return findEl(fixture, testId).nativeElement.textContent; +} + +/** + * Expects that the element with the given `data-testid` attribute + * has the given text content. + * + * @param fixture Component fixture + * @param testId Test id set by `data-testid` + * @param text Expected text + */ +export function expectText( + fixture: ComponentFixture, + testId: string, + text: string, +): void { + expect(getText(fixture, testId)).toBe(text); +} + +/** + * Expects that the element with the given `data-testid` attribute + * has the given text content. + * + * @param fixture Component fixture + * @param text Expected text + */ +export function expectContainedText(fixture: ComponentFixture, text: string): void { + expect(fixture.nativeElement.textContent).toContain(text); +} + +/** + * Expects that a component has the given text content. + * Both the component text content and the expected text are trimmed for reliability. + * + * @param fixture Component fixture + * @param text Expected text + */ +export function expectContent(fixture: ComponentFixture, text: string): void { + expect(fixture.nativeElement.textContent).toBe(text); +} + +/** + * Dispatches a fake event (synthetic event) at the given element. + * + * @param element Element that is the target of the event + * @param type Event name, e.g. `input` + * @param bubbles Whether the event bubbles up in the DOM tree + */ +export function dispatchFakeEvent( + element: EventTarget, + type: string, + bubbles: boolean = false, +): void { + const event = document.createEvent('Event'); + event.initEvent(type, bubbles, false); + element.dispatchEvent(event); +} + +/** + * Enters text into a form field (`input`, `textarea` or `select` element). + * Triggers appropriate events so Angular takes notice of the change. + * If you listen for the `change` event on `input` or `textarea`, + * you need to trigger it separately. + * + * @param element Form field + * @param value Form field value + */ +export function setFieldElementValue( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, + value: string, +): void { + element.value = value; + // Dispatch an `input` or `change` fake event + // so Angular form bindings take notice of the change. + const isSelect = element instanceof HTMLSelectElement; + dispatchFakeEvent(element, isSelect ? 'change' : 'input', isSelect ? false : true); +} + +/** + * Sets the value of a form field with the given `data-testid` attribute. + * + * @param fixture Component fixture + * @param testId Test id set by `data-testid` + * @param value Form field value + */ +export function setFieldValue( + fixture: ComponentFixture, + testId: string, + value: string, +): void { + setFieldElementValue(findEl(fixture, testId).nativeElement, value); +} + +/** + * Checks or unchecks a checkbox or radio button. + * Triggers appropriate events so Angular takes notice of the change. + * + * @param fixture Component fixture + * @param testId Test id set by `data-testid` + * @param checked Whether the checkbox or radio should be checked + */ +export function checkField( + fixture: ComponentFixture, + testId: string, + checked: boolean, +): void { + const { nativeElement } = findEl(fixture, testId); + nativeElement.checked = checked; + // Dispatch a `change` fake event so Angular form bindings take notice of the change. + dispatchFakeEvent(nativeElement, 'change'); +} + +/** + * Makes a fake click event that provides the most important properties. + * Sets the button to left. + * The event can be passed to DebugElement#triggerEventHandler. + * + * @param target Element that is the target of the click event + */ +export function makeClickEvent(target: EventTarget): Partial { + return { + preventDefault(): void { }, + stopPropagation(): void { }, + stopImmediatePropagation(): void { }, + type: 'click', + target, + currentTarget: target, + bubbles: true, + cancelable: true, + button: 0, + }; +} + +/** + * Emulates a left click on the element with the given `data-testid` attribute. + * + * @param fixture Component fixture + * @param testId Test id set by `data-testid` + */ +export function click(fixture: ComponentFixture, testId: string): void { + const element = findEl(fixture, testId); + const event = makeClickEvent(element.nativeElement); + element.triggerEventHandler('click', event); +} + +/** + * Finds a nested Component by its selector, e.g. `app-example`. + * Throws an error if no element was found. + * Use this only for shallow component testing. + * When finding other elements, use `findEl` / `findEls` and `data-testid` attributes. + * + * @param fixture Fixture of the parent Component + * @param selector Element selector, e.g. `app-example` + */ +export function findComponent( + fixture: ComponentFixture, + selector: string, +): DebugElement { + return queryByCss(fixture, selector); +} + +/** + * Finds all nested Components by its selector, e.g. `app-example`. + */ +export function findComponents( + fixture: ComponentFixture, + selector: string, +): DebugElement[] { + return fixture.debugElement.queryAll(By.css(selector)); +}