Skip to content

Commit

Permalink
Adds flattening operators concatMap mergeMap
Browse files Browse the repository at this point in the history
  • Loading branch information
Damian96 committed Nov 24, 2024
1 parent 93de7c0 commit 81c184b
Show file tree
Hide file tree
Showing 6 changed files with 388 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
},
];
7 changes: 7 additions & 0 deletions src/app/rxjs/flattening-ops/flattening-ops.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h1 data-testid="heading">
Flattening Operators
</h1>

<h3 #concatMapHeading data-testid="concatmap-heading">ConcatMap: {{ concatMapMessage$ | async }}</h3>

<h3 #mergeMapHeading data-testid="mergemap-heading" (click)="onMergeMapClickHandler()">MergeMap: {{ mergeMapMessage$ | async }}</h3>
Empty file.
72 changes: 72 additions & 0 deletions src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<FlatteningOpsComponent>;

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();
})
});
60 changes: 60 additions & 0 deletions src/app/rxjs/flattening-ops/flattening-ops.component.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
@ViewChild('concatMapHeading') concatMapHeading!: ElementRef<HTMLElement>;

mergeMapMessage$ = new Subject<string>();
mergeMapClickCount = 0;
@ViewChild('mergeMapHeading') mergeMapHeading!: ElementRef<HTMLElement>;

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())
})
);
}
}
242 changes: 242 additions & 0 deletions src/app/spec-helpers/element.spec-helper.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
fixture: ComponentFixture<T>,
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<T>(fixture: ComponentFixture<T>, testId: string): DebugElement {
return queryByCss<T>(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<T>(fixture: ComponentFixture<T>, 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<T>(fixture: ComponentFixture<T>, 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<T>(
fixture: ComponentFixture<T>,
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<T>(fixture: ComponentFixture<T>, 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<T>(fixture: ComponentFixture<T>, 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<T>(
fixture: ComponentFixture<T>,
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<T>(
fixture: ComponentFixture<T>,
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<MouseEvent> {
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<T>(fixture: ComponentFixture<T>, 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<T>(
fixture: ComponentFixture<T>,
selector: string,
): DebugElement {
return queryByCss(fixture, selector);
}

/**
* Finds all nested Components by its selector, e.g. `app-example`.
*/
export function findComponents<T>(
fixture: ComponentFixture<T>,
selector: string,
): DebugElement[] {
return fixture.debugElement.queryAll(By.css(selector));
}

0 comments on commit 81c184b

Please sign in to comment.