-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds flattening operators concatMap mergeMap
- Loading branch information
Showing
6 changed files
with
388 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
72
src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}) | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
}) | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} |