From 74ddaea925f4e9a9b9ad11ede32321264e3bc3ab Mon Sep 17 00:00:00 2001 From: 3mindedscholar <10akhil.t@gmail.com> Date: Wed, 1 Oct 2025 01:24:52 +0530 Subject: [PATCH 01/12] Added more info for newcomers --- README.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/README.md b/README.md index 6fc5068..e531737 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,102 @@ Rimmel is a powerful, fast and lightweight JavaScript UI library for creating we It implements [RML](https://github.com/ReactiveHTML/reactive-markup), the Reactive Markup which makes your HTML work with Streams in a seamless way. +## Quick Start + +### Installation + +```bash +# Using npm +npm install rimmel rxjs + +# Using yarn +yarn add rimmel rxjs + +# Using pnpm +pnpm add rimmel rxjs +``` + +### Basic Usage + +```js +import { rml } from "rimmel"; +import { Subject } from "rxjs"; + +const counter = new Subject(); + +document.body.innerHTML = rml` + +
Clicks: ${counter}
+`; +``` + +## Key Features + +- **Stream-First Architecture**: Everything is a stream - events, data, UI updates +- **No Virtual DOM**: Direct DOM updates via optimized "sinks" +- **Tiny Bundle Size**: Core is just 2.5KB, tree-shakeable imports +- **Zero Build Requirements**: Works with plain JavaScript +- **Automatic Memory Management**: Handles subscription cleanup +- **Built-in Suspense**: Automatic loading states with BehaviorSubject +- **Web Components Support**: Create custom elements easily +- **TypeScript Support**: Full type definitions included + +## Core Concepts + +### Sources (Input) +- DOM Events (`onclick`, `onmousemove`, etc) +- Promises +- RxJS Observables +- Custom Event Sources + +### Sinks (Output) +- DOM Updates +- Class Management +- Style Updates +- Attribute Changes +- Custom Sinks + +## Available Sinks + +The library includes specialized sinks for common UI operations: + +- `InnerHTML` - Update element content +- `InnerText` - Safe text updates +- `Class` - Manage CSS classes +- `Style` - Update styles +- `Value` - Form input values +- `Disabled` - Toggle disabled state +- `Readonly` - Toggle readonly state +- `Removed` - Remove elements +- `Sanitize` - Safe HTML rendering +- `AppendHTML` - Append content +- `PrependHTML` - Prepend content + +## Development + +```bash +# Install dependencies +npm install + +# Run tests +npm run test + +# Build library +npm run build + +# Run demo app +npm run kitchen-sink +``` + +## Examples + +Check out our examples: + +- [Basic Demos](https://stackblitz.com/@dariomannu/collections/rimmel-js-getting-started) +- [Advanced Patterns](https://stackblitz.com/@dariomannu/collections/rimmel-js-experiments) +- [Web Components](https://stackblitz.com/@dariomannu/collections/web-components) +- [Web Workers](https://stackblitz.com/@dariomannu/collections/web-workers) + ## Getting started If you are new to reactive streams, there is a [3m crash-course](https://medium.com/@fourtyeighthours/the-mostly-inaccurate-crash-course-for-reactive-ui-development-w-rxjs-ddbb7e5e526e) tailored for UI development with Rimmel, arguably the simplest RxJS introduction around to get you started. From b34584f42e344d3e65592849eff304a27ae6cc8e Mon Sep 17 00:00:00 2001 From: 3mindedscholar <10akhil.t@gmail.com> Date: Wed, 1 Oct 2025 11:49:35 +0530 Subject: [PATCH 02/12] Rebase --- QUICKSTART.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 96 +------------------------------------------ 2 files changed, 113 insertions(+), 95 deletions(-) create mode 100644 QUICKSTART.md diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..f45b320 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,112 @@ +## Quick Start + +### Installation + +```bash +# Using npm +npm install rimmel rxjs + +# Using yarn +yarn add rimmel rxjs + +# Using pnpm +pnpm add rimmel rxjs +``` + +### Basic Usage + +```js +import { BehaviorSubject, scan } from 'rxjs'; +import { rml } from 'rimmel'; + +const Component = () => { + const count = new BehaviorSubject(0).pipe( + scan(x=>x+1) + ); + + return rml` + + `; +}; + +const App = () => { + return rml` +

Hello World

+ ${Component()} + +
+ Starter for Rimmel.js + `; +}; + +document.body.innerHTML = App(); +``` + +## Key Features + +- **Stream-First Architecture**: Everything is a stream - events, data, UI updates +- **No Virtual DOM**: Direct DOM updates via optimized "sinks" +- **Tiny Bundle Size**: Core is just 2.5KB, tree-shakeable imports +- **Zero Build Requirements**: Works with plain JavaScript +- **Automatic Memory Management**: Handles subscription cleanup +- **Built-in Suspense**: Automatic loading states with BehaviorSubject +- **Web Components Support**: Create custom elements easily +- **TypeScript Support**: Full type definitions included + +## Core Concepts + +### Sources (Input) +- DOM Events (`onclick`, `onmousemove`, etc) +- Promises +- RxJS Observables +- Custom Event Sources + +### Sinks (Output) +- DOM Updates +- Class Management +- Style Updates +- Attribute Changes +- Custom Sinks + +## Available Sinks + +The library includes specialized sinks for common UI operations: + +- `InnerHTML` - Update element content +- `InnerText` - Safe text updates +- `Class` - Manage CSS classes +- `Style` - Update styles +- `Value` - Form input values +- `Disabled` - Toggle disabled state +- `Readonly` - Toggle readonly state +- `Removed` - Remove elements +- `Sanitize` - Safe HTML rendering +- `AppendHTML` - Append content +- `PrependHTML` - Prepend content + +## Development + +```bash +# Install dependencies +npm install + +# Run tests +npm run test + +# Build library +npm run build + +# Run demo app +npm run kitchen-sink +``` + +## Examples + +Check out our examples: + +- [Basic Demos](https://stackblitz.com/@dariomannu/collections/rimmel-js-getting-started) +- [Advanced Patterns](https://stackblitz.com/@dariomannu/collections/rimmel-js-experiments) +- [Web Components](https://stackblitz.com/@dariomannu/collections/web-components) +- [Web Workers](https://stackblitz.com/@dariomannu/collections/web-workers) \ No newline at end of file diff --git a/README.md b/README.md index e531737..97b9e1a 100644 --- a/README.md +++ b/README.md @@ -10,101 +10,7 @@ Rimmel is a powerful, fast and lightweight JavaScript UI library for creating we It implements [RML](https://github.com/ReactiveHTML/reactive-markup), the Reactive Markup which makes your HTML work with Streams in a seamless way. -## Quick Start - -### Installation - -```bash -# Using npm -npm install rimmel rxjs - -# Using yarn -yarn add rimmel rxjs - -# Using pnpm -pnpm add rimmel rxjs -``` - -### Basic Usage - -```js -import { rml } from "rimmel"; -import { Subject } from "rxjs"; - -const counter = new Subject(); - -document.body.innerHTML = rml` - -
Clicks: ${counter}
-`; -``` - -## Key Features - -- **Stream-First Architecture**: Everything is a stream - events, data, UI updates -- **No Virtual DOM**: Direct DOM updates via optimized "sinks" -- **Tiny Bundle Size**: Core is just 2.5KB, tree-shakeable imports -- **Zero Build Requirements**: Works with plain JavaScript -- **Automatic Memory Management**: Handles subscription cleanup -- **Built-in Suspense**: Automatic loading states with BehaviorSubject -- **Web Components Support**: Create custom elements easily -- **TypeScript Support**: Full type definitions included - -## Core Concepts - -### Sources (Input) -- DOM Events (`onclick`, `onmousemove`, etc) -- Promises -- RxJS Observables -- Custom Event Sources - -### Sinks (Output) -- DOM Updates -- Class Management -- Style Updates -- Attribute Changes -- Custom Sinks - -## Available Sinks - -The library includes specialized sinks for common UI operations: - -- `InnerHTML` - Update element content -- `InnerText` - Safe text updates -- `Class` - Manage CSS classes -- `Style` - Update styles -- `Value` - Form input values -- `Disabled` - Toggle disabled state -- `Readonly` - Toggle readonly state -- `Removed` - Remove elements -- `Sanitize` - Safe HTML rendering -- `AppendHTML` - Append content -- `PrependHTML` - Prepend content - -## Development - -```bash -# Install dependencies -npm install - -# Run tests -npm run test - -# Build library -npm run build - -# Run demo app -npm run kitchen-sink -``` - -## Examples - -Check out our examples: - -- [Basic Demos](https://stackblitz.com/@dariomannu/collections/rimmel-js-getting-started) -- [Advanced Patterns](https://stackblitz.com/@dariomannu/collections/rimmel-js-experiments) -- [Web Components](https://stackblitz.com/@dariomannu/collections/web-components) -- [Web Workers](https://stackblitz.com/@dariomannu/collections/web-workers) +# To Busy to read, here the [TL;DR 📜](./QUICKSTART.md)
## Getting started If you are new to reactive streams, there is a [3m crash-course](https://medium.com/@fourtyeighthours/the-mostly-inaccurate-crash-course-for-reactive-ui-development-w-rxjs-ddbb7e5e526e) tailored for UI development with Rimmel, arguably the simplest RxJS introduction around to get you started. From ad7bdd825df470a79b3dcc4de3f77d1f73e9fdb9 Mon Sep 17 00:00:00 2001 From: 3mindedscholar <10akhil.t@gmail.com> Date: Wed, 1 Oct 2025 11:56:34 +0530 Subject: [PATCH 03/12] Fix heading --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 97b9e1a..1dda6e9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Rimmel is a powerful, fast and lightweight JavaScript UI library for creating we It implements [RML](https://github.com/ReactiveHTML/reactive-markup), the Reactive Markup which makes your HTML work with Streams in a seamless way. -# To Busy to read, here the [TL;DR 📜](./QUICKSTART.md)
+## To Busy to read, here the [TL;DR 📜](./QUICKSTART.md)
## Getting started If you are new to reactive streams, there is a [3m crash-course](https://medium.com/@fourtyeighthours/the-mostly-inaccurate-crash-course-for-reactive-ui-development-w-rxjs-ddbb7e5e526e) tailored for UI development with Rimmel, arguably the simplest RxJS introduction around to get you started. From 5c25b25e975d033a9313b4a5494102ef156b085f Mon Sep 17 00:00:00 2001 From: 3mindedscholar <10akhil.t@gmail.com> Date: Wed, 1 Oct 2025 11:59:46 +0530 Subject: [PATCH 04/12] Fix heading --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1dda6e9..4c945c3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Rimmel is a powerful, fast and lightweight JavaScript UI library for creating we It implements [RML](https://github.com/ReactiveHTML/reactive-markup), the Reactive Markup which makes your HTML work with Streams in a seamless way. -## To Busy to read, here the [TL;DR 📜](./QUICKSTART.md)
+## To Busy to read?, Check [TL;DR 📜](./QUICKSTART.md)
## Getting started If you are new to reactive streams, there is a [3m crash-course](https://medium.com/@fourtyeighthours/the-mostly-inaccurate-crash-course-for-reactive-ui-development-w-rxjs-ddbb7e5e526e) tailored for UI development with Rimmel, arguably the simplest RxJS introduction around to get you started. From f248b5affcda45fabd40233f6e5bb55668377110 Mon Sep 17 00:00:00 2001 From: 3mindedscholar <10akhil.t@gmail.com> Date: Wed, 1 Oct 2025 12:30:41 +0530 Subject: [PATCH 05/12] Added Unit Test for Swap-Source.ts --- src/sources/swap-source.test.ts | 126 ++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/sources/swap-source.test.ts diff --git a/src/sources/swap-source.test.ts b/src/sources/swap-source.test.ts new file mode 100644 index 0000000..2cbfefd --- /dev/null +++ b/src/sources/swap-source.test.ts @@ -0,0 +1,126 @@ +import type { Observable } from 'rxjs'; + +import { Subject } from 'rxjs'; +import { MockElement, MockEvent } from '../test-support'; +import { Swap, swap } from './swap-source'; + +describe('Swap Event Adapter', () => { + + it('Swaps a value in an element with a static string', () => { + const oldValue = 'old data'; + const newValue = 'new data'; + + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const handlerSpy = jest.fn(); + const source = Swap(newValue)(handlerSpy); + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(newValue); + }); + + it('Swaps a value in an element using a function', () => { + const oldValue = 'old data'; + + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const replaceFn = (v: string) => v.toUpperCase(); + + const handlerSpy = jest.fn(); + const source = Swap(replaceFn)(handlerSpy); + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual('OLD DATA'); + }); + + it('Swaps a value in an element with empty string by default', () => { + const oldValue = 'old data'; + + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const handlerSpy = jest.fn(); + const source = Swap()(handlerSpy); + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(''); + }); + +}); + +describe('swap Event Operator', () => { + + it('Swaps and emits a value from an element with static string', () => { + const oldValue = 'old data'; + const newValue = 'new data'; + + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(swap(newValue)) as Observable & Subject; + pipeline.subscribe(x => handlerSpy(x)); + pipeline.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(newValue); + }); + + it('Swaps and emits a value from an element using function', () => { + const oldValue = 'old data'; + + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const replaceFn = (v: string) => v.toUpperCase(); + + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(swap(replaceFn)) as Observable & Subject; + pipeline.subscribe(x => handlerSpy(x)); + pipeline.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual('OLD DATA'); + }); + +}); \ No newline at end of file From 241ae5d32d702e4702156edc255b2f7f35154707 Mon Sep 17 00:00:00 2001 From: TanmayRanaware Date: Wed, 1 Oct 2025 01:38:08 -0700 Subject: [PATCH 06/12] Add comprehensive unit test suite for Mixin sink - Created BDD-style test suite following existing patterns - Tests cover plain objects, futures/promises, event listeners - Includes edge cases and complex scenarios - Validates sink configuration structure - Ensures proper attribute application and removal --- src/sinks/mixin-sink.test.ts | 342 +++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/sinks/mixin-sink.test.ts diff --git a/src/sinks/mixin-sink.test.ts b/src/sinks/mixin-sink.test.ts new file mode 100644 index 0000000..5047f4e --- /dev/null +++ b/src/sinks/mixin-sink.test.ts @@ -0,0 +1,342 @@ +import { MockElement } from '../test-support'; +import { Mixin, MIXIN_SINK_TAG } from './mixin-sink'; +import { AttributeObjectSink } from './attribute-sink'; +import { SINK_TAG } from '../constants'; + +describe('Mixin Sink', () => { + + describe('Given a plain object mixin', () => { + + it('creates sink binding configuration with correct properties', () => { + const source = { + 'data-foo': 'bar', + 'class': 'test-class', + 'id': 'test-id' + }; + + const config = Mixin(source); + + expect(config.type).toBe(SINK_TAG); + expect(config.t).toBe(MIXIN_SINK_TAG); + expect(config.source).toBe(source); + expect(config.sink).toBe(AttributeObjectSink); + }); + + it('applies plain object attributes to element immediately', () => { + const el = MockElement(); + const source = { + 'data-foo': 'bar', + 'class': 'test-class', + 'id': 'test-id', + 'title': 'test-title' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.dataset.foo).toBe('bar'); + expect(el.className).toBe('test-class'); + expect(el.id).toBe('test-id'); + expect(el.getAttribute('title')).toBe('test-title'); + }); + + it('handles boolean attributes correctly', () => { + const el = MockElement(); + const source = { + 'disabled': true, + 'readonly': 'readonly', + 'checked': false + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.disabled).toBe(true); + expect(el.readOnly).toBe('readonly'); + expect(el.checked).toBe(false); + }); + + it('removes attributes when set to falsey values', () => { + const el = MockElement(); + + // Set initial attributes + el.setAttribute('data-foo', 'bar'); + el.setAttribute('title', 'initial-title'); + el.className = 'initial-class'; + + const source = { + 'data-foo': false, + 'title': null, + 'class': undefined + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.getAttribute('data-foo')).toBeUndefined(); + expect(el.getAttribute('title')).toBeUndefined(); + expect(el.className).toBe(''); + }); + + }); + + describe('Given a future/promise mixin', () => { + + it('creates sink binding configuration for future source', () => { + const futureSource = Promise.resolve({ + 'data-future': 'value', + 'class': 'future-class' + }); + + const config = Mixin(futureSource); + + expect(config.type).toBe(SINK_TAG); + expect(config.t).toBe(MIXIN_SINK_TAG); + expect(config.source).toBe(futureSource); + expect(config.sink).toBe(AttributeObjectSink); + }); + + it('applies future attributes when promise resolves', async () => { + const el = MockElement(); + const futureSource = Promise.resolve({ + 'data-future': 'resolved-value', + 'class': 'resolved-class', + 'title': 'resolved-title' + }); + + const config = Mixin(futureSource); + const sink = config.sink(el); + + // Apply the sink (this should handle the promise) + sink(config.source); + + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(el.dataset.future).toBe('resolved-value'); + expect(el.className).toBe('resolved-class'); + expect(el.getAttribute('title')).toBe('resolved-title'); + }); + + }); + + describe('Given event listener mixins', () => { + + it('applies event listeners from mixin object', () => { + const el = MockElement(); + const clickHandler = jest.fn(); + const mouseoverHandler = jest.fn(); + + const source = { + 'onclick': clickHandler, + 'onmouseover': mouseoverHandler, + 'class': 'event-class' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.className).toBe('event-class'); + + // Verify event listeners are attached + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(clickHandler).toHaveBeenCalledWith(clickEvent); + + const mouseoverEvent = new Event('mouseover'); + el.dispatchEvent(mouseoverEvent); + expect(mouseoverHandler).toHaveBeenCalledWith(mouseoverEvent); + }); + + it('handles future event listeners', async () => { + const el = MockElement(); + const futureHandler = jest.fn(); + + const futureSource = Promise.resolve({ + 'onclick': futureHandler, + 'data-future-event': 'true' + }); + + const config = Mixin(futureSource); + const sink = config.sink(el); + sink(config.source); + + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(el.dataset.futureEvent).toBe('true'); + + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(futureHandler).toHaveBeenCalledWith(clickEvent); + }); + + }); + + describe('Given complex mixin scenarios', () => { + + it('handles mixed attribute types in single mixin', () => { + const el = MockElement(); + const clickHandler = jest.fn(); + + const source = { + // Regular attributes + 'id': 'complex-mixin', + 'class': 'complex-class', + 'title': 'Complex Mixin', + + // Data attributes + 'data-complex': 'value', + 'data-number': 42, + + // Boolean attributes + 'disabled': false, + 'readonly': true, + + // Event listeners + 'onclick': clickHandler, + + // Style (if supported) + 'style': 'color: red; font-weight: bold;' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.id).toBe('complex-mixin'); + expect(el.className).toBe('complex-class'); + expect(el.getAttribute('title')).toBe('Complex Mixin'); + expect(el.dataset.complex).toBe('value'); + expect(el.dataset.number).toBe('42'); + expect(el.disabled).toBe(false); + expect(el.readOnly).toBe(true); + expect(el.getAttribute('style')).toBe('color: red; font-weight: bold;'); + + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(clickHandler).toHaveBeenCalledWith(clickEvent); + }); + + it('overwrites previous attributes when applied multiple times', () => { + const el = MockElement(); + + const firstSource = { + 'id': 'first-id', + 'class': 'first-class', + 'data-value': 'first' + }; + + const secondSource = { + 'id': 'second-id', + 'class': 'second-class', + 'data-value': 'second' + }; + + const config1 = Mixin(firstSource); + const config2 = Mixin(secondSource); + + const sink1 = config1.sink(el); + const sink2 = config2.sink(el); + + sink1(config1.source); + expect(el.id).toBe('first-id'); + expect(el.className).toBe('first-class'); + expect(el.dataset.value).toBe('first'); + + sink2(config2.source); + expect(el.id).toBe('second-id'); + expect(el.className).toBe('second-class'); + expect(el.dataset.value).toBe('second'); + }); + + }); + + describe('Given edge cases', () => { + + it('handles empty mixin object', () => { + const el = MockElement(); + const source = {}; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + // Should not throw and element should remain unchanged + expect(el.className).toBe(''); + expect(el.id).toBe(''); + }); + + it('handles null and undefined values in mixin', () => { + const el = MockElement(); + + // Set initial attributes + el.setAttribute('data-foo', 'bar'); + el.className = 'initial-class'; + + const source = { + 'data-foo': null, + 'class': undefined, + 'title': '', + 'id': 'valid-id' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.getAttribute('data-foo')).toBeUndefined(); + expect(el.className).toBe(''); + expect(el.getAttribute('title')).toBeUndefined(); + expect(el.id).toBe('valid-id'); + }); + + it('handles string "false" values correctly', () => { + const el = MockElement(); + + const source = { + 'data-false': 'false', + 'data-true': 'true', + 'disabled': 'false', + 'readonly': 'false' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + // String "false" should be treated as falsy for attribute removal + expect(el.getAttribute('data-false')).toBeUndefined(); + expect(el.dataset.true).toBe('true'); + expect(el.disabled).toBe(false); + expect(el.getAttribute('readonly')).toBeUndefined(); + }); + + }); + + describe('Given sink configuration structure', () => { + + it('returns correct sink binding configuration type', () => { + const source = { 'test': 'value' }; + const config = Mixin(source); + + expect(config).toHaveProperty('type', SINK_TAG); + expect(config).toHaveProperty('t', MIXIN_SINK_TAG); + expect(config).toHaveProperty('source', source); + expect(config).toHaveProperty('sink', AttributeObjectSink); + }); + + it('preserves source reference in configuration', () => { + const source = { 'preserved': 'reference' }; + const config = Mixin(source); + + expect(config.source).toBe(source); + }); + + }); + +}); From 24091f1ae079819358d414ba309a4e7db68f2264 Mon Sep 17 00:00:00 2001 From: TanmayRanaware Date: Thu, 2 Oct 2025 23:06:39 -0700 Subject: [PATCH 07/12] Remove QUICKSTART.md - incorrectly added from another PR --- QUICKSTART.md | 112 -------------------------------------------------- 1 file changed, 112 deletions(-) delete mode 100644 QUICKSTART.md diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index f45b320..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,112 +0,0 @@ -## Quick Start - -### Installation - -```bash -# Using npm -npm install rimmel rxjs - -# Using yarn -yarn add rimmel rxjs - -# Using pnpm -pnpm add rimmel rxjs -``` - -### Basic Usage - -```js -import { BehaviorSubject, scan } from 'rxjs'; -import { rml } from 'rimmel'; - -const Component = () => { - const count = new BehaviorSubject(0).pipe( - scan(x=>x+1) - ); - - return rml` - - `; -}; - -const App = () => { - return rml` -

Hello World

- ${Component()} - -
- Starter for Rimmel.js - `; -}; - -document.body.innerHTML = App(); -``` - -## Key Features - -- **Stream-First Architecture**: Everything is a stream - events, data, UI updates -- **No Virtual DOM**: Direct DOM updates via optimized "sinks" -- **Tiny Bundle Size**: Core is just 2.5KB, tree-shakeable imports -- **Zero Build Requirements**: Works with plain JavaScript -- **Automatic Memory Management**: Handles subscription cleanup -- **Built-in Suspense**: Automatic loading states with BehaviorSubject -- **Web Components Support**: Create custom elements easily -- **TypeScript Support**: Full type definitions included - -## Core Concepts - -### Sources (Input) -- DOM Events (`onclick`, `onmousemove`, etc) -- Promises -- RxJS Observables -- Custom Event Sources - -### Sinks (Output) -- DOM Updates -- Class Management -- Style Updates -- Attribute Changes -- Custom Sinks - -## Available Sinks - -The library includes specialized sinks for common UI operations: - -- `InnerHTML` - Update element content -- `InnerText` - Safe text updates -- `Class` - Manage CSS classes -- `Style` - Update styles -- `Value` - Form input values -- `Disabled` - Toggle disabled state -- `Readonly` - Toggle readonly state -- `Removed` - Remove elements -- `Sanitize` - Safe HTML rendering -- `AppendHTML` - Append content -- `PrependHTML` - Prepend content - -## Development - -```bash -# Install dependencies -npm install - -# Run tests -npm run test - -# Build library -npm run build - -# Run demo app -npm run kitchen-sink -``` - -## Examples - -Check out our examples: - -- [Basic Demos](https://stackblitz.com/@dariomannu/collections/rimmel-js-getting-started) -- [Advanced Patterns](https://stackblitz.com/@dariomannu/collections/rimmel-js-experiments) -- [Web Components](https://stackblitz.com/@dariomannu/collections/web-components) -- [Web Workers](https://stackblitz.com/@dariomannu/collections/web-workers) \ No newline at end of file From 6d62ffb83d95c5c9a2c4ba7970451752e954d636 Mon Sep 17 00:00:00 2001 From: TanmayRanaware Date: Thu, 2 Oct 2025 23:09:39 -0700 Subject: [PATCH 08/12] Reorganize mixin-sink tests with Given/When/Then structure - Restructure test organization to use nested describe blocks - Group tests by 'When' scenarios under each 'Given' context - Improve maintainability and extensibility of test structure - Make test scenarios more descriptive and organized --- src/sinks/mixin-sink.test.ts | 584 ++++++++++++++++++----------------- 1 file changed, 298 insertions(+), 286 deletions(-) diff --git a/src/sinks/mixin-sink.test.ts b/src/sinks/mixin-sink.test.ts index 5047f4e..860f2d0 100644 --- a/src/sinks/mixin-sink.test.ts +++ b/src/sinks/mixin-sink.test.ts @@ -6,337 +6,349 @@ import { SINK_TAG } from '../constants'; describe('Mixin Sink', () => { describe('Given a plain object mixin', () => { - - it('creates sink binding configuration with correct properties', () => { - const source = { - 'data-foo': 'bar', - 'class': 'test-class', - 'id': 'test-id' - }; - - const config = Mixin(source); - - expect(config.type).toBe(SINK_TAG); - expect(config.t).toBe(MIXIN_SINK_TAG); - expect(config.source).toBe(source); - expect(config.sink).toBe(AttributeObjectSink); + describe('When creating sink configuration', () => { + it('creates sink binding configuration with correct properties', () => { + const source = { + 'data-foo': 'bar', + 'class': 'test-class', + 'id': 'test-id' + }; + + const config = Mixin(source); + + expect(config.type).toBe(SINK_TAG); + expect(config.t).toBe(MIXIN_SINK_TAG); + expect(config.source).toBe(source); + expect(config.sink).toBe(AttributeObjectSink); + }); }); - it('applies plain object attributes to element immediately', () => { - const el = MockElement(); - const source = { - 'data-foo': 'bar', - 'class': 'test-class', - 'id': 'test-id', - 'title': 'test-title' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.dataset.foo).toBe('bar'); - expect(el.className).toBe('test-class'); - expect(el.id).toBe('test-id'); - expect(el.getAttribute('title')).toBe('test-title'); - }); + describe('When applying attributes to element', () => { + it('applies plain object attributes to element immediately', () => { + const el = MockElement(); + const source = { + 'data-foo': 'bar', + 'class': 'test-class', + 'id': 'test-id', + 'title': 'test-title' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.dataset.foo).toBe('bar'); + expect(el.className).toBe('test-class'); + expect(el.id).toBe('test-id'); + expect(el.getAttribute('title')).toBe('test-title'); + }); - it('handles boolean attributes correctly', () => { - const el = MockElement(); - const source = { - 'disabled': true, - 'readonly': 'readonly', - 'checked': false - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.disabled).toBe(true); - expect(el.readOnly).toBe('readonly'); - expect(el.checked).toBe(false); - }); + it('handles boolean attributes correctly', () => { + const el = MockElement(); + const source = { + 'disabled': true, + 'readonly': 'readonly', + 'checked': false + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.disabled).toBe(true); + expect(el.readOnly).toBe('readonly'); + expect(el.checked).toBe(false); + }); - it('removes attributes when set to falsey values', () => { - const el = MockElement(); - - // Set initial attributes - el.setAttribute('data-foo', 'bar'); - el.setAttribute('title', 'initial-title'); - el.className = 'initial-class'; - - const source = { - 'data-foo': false, - 'title': null, - 'class': undefined - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.getAttribute('data-foo')).toBeUndefined(); - expect(el.getAttribute('title')).toBeUndefined(); - expect(el.className).toBe(''); + it('removes attributes when set to falsey values', () => { + const el = MockElement(); + + // Set initial attributes + el.setAttribute('data-foo', 'bar'); + el.setAttribute('title', 'initial-title'); + el.className = 'initial-class'; + + const source = { + 'data-foo': false, + 'title': null, + 'class': undefined + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.getAttribute('data-foo')).toBeUndefined(); + expect(el.getAttribute('title')).toBeUndefined(); + expect(el.className).toBe(''); + }); }); - }); describe('Given a future/promise mixin', () => { - - it('creates sink binding configuration for future source', () => { - const futureSource = Promise.resolve({ - 'data-future': 'value', - 'class': 'future-class' + describe('When creating sink configuration', () => { + it('creates sink binding configuration for future source', () => { + const futureSource = Promise.resolve({ + 'data-future': 'value', + 'class': 'future-class' + }); + + const config = Mixin(futureSource); + + expect(config.type).toBe(SINK_TAG); + expect(config.t).toBe(MIXIN_SINK_TAG); + expect(config.source).toBe(futureSource); + expect(config.sink).toBe(AttributeObjectSink); }); - - const config = Mixin(futureSource); - - expect(config.type).toBe(SINK_TAG); - expect(config.t).toBe(MIXIN_SINK_TAG); - expect(config.source).toBe(futureSource); - expect(config.sink).toBe(AttributeObjectSink); }); - it('applies future attributes when promise resolves', async () => { - const el = MockElement(); - const futureSource = Promise.resolve({ - 'data-future': 'resolved-value', - 'class': 'resolved-class', - 'title': 'resolved-title' - }); - - const config = Mixin(futureSource); - const sink = config.sink(el); - - // Apply the sink (this should handle the promise) - sink(config.source); + describe('When applying future attributes', () => { + it('applies future attributes when promise resolves', async () => { + const el = MockElement(); + const futureSource = Promise.resolve({ + 'data-future': 'resolved-value', + 'class': 'resolved-class', + 'title': 'resolved-title' + }); + + const config = Mixin(futureSource); + const sink = config.sink(el); + + // Apply the sink (this should handle the promise) + sink(config.source); - // Wait for promise to resolve - await new Promise(resolve => setTimeout(resolve, 10)); + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 10)); - expect(el.dataset.future).toBe('resolved-value'); - expect(el.className).toBe('resolved-class'); - expect(el.getAttribute('title')).toBe('resolved-title'); + expect(el.dataset.future).toBe('resolved-value'); + expect(el.className).toBe('resolved-class'); + expect(el.getAttribute('title')).toBe('resolved-title'); + }); }); - }); describe('Given event listener mixins', () => { - - it('applies event listeners from mixin object', () => { - const el = MockElement(); - const clickHandler = jest.fn(); - const mouseoverHandler = jest.fn(); - - const source = { - 'onclick': clickHandler, - 'onmouseover': mouseoverHandler, - 'class': 'event-class' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.className).toBe('event-class'); - - // Verify event listeners are attached - const clickEvent = new Event('click'); - el.dispatchEvent(clickEvent); - expect(clickHandler).toHaveBeenCalledWith(clickEvent); - - const mouseoverEvent = new Event('mouseover'); - el.dispatchEvent(mouseoverEvent); - expect(mouseoverHandler).toHaveBeenCalledWith(mouseoverEvent); + describe('When applying event listeners from mixin object', () => { + it('applies event listeners from mixin object', () => { + const el = MockElement(); + const clickHandler = jest.fn(); + const mouseoverHandler = jest.fn(); + + const source = { + 'onclick': clickHandler, + 'onmouseover': mouseoverHandler, + 'class': 'event-class' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.className).toBe('event-class'); + + // Verify event listeners are attached + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(clickHandler).toHaveBeenCalledWith(clickEvent); + + const mouseoverEvent = new Event('mouseover'); + el.dispatchEvent(mouseoverEvent); + expect(mouseoverHandler).toHaveBeenCalledWith(mouseoverEvent); + }); }); - it('handles future event listeners', async () => { - const el = MockElement(); - const futureHandler = jest.fn(); + describe('When handling future event listeners', () => { + it('handles future event listeners', async () => { + const el = MockElement(); + const futureHandler = jest.fn(); - const futureSource = Promise.resolve({ - 'onclick': futureHandler, - 'data-future-event': 'true' - }); + const futureSource = Promise.resolve({ + 'onclick': futureHandler, + 'data-future-event': 'true' + }); - const config = Mixin(futureSource); - const sink = config.sink(el); - sink(config.source); + const config = Mixin(futureSource); + const sink = config.sink(el); + sink(config.source); - // Wait for promise to resolve - await new Promise(resolve => setTimeout(resolve, 10)); + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 10)); - expect(el.dataset.futureEvent).toBe('true'); - - const clickEvent = new Event('click'); - el.dispatchEvent(clickEvent); - expect(futureHandler).toHaveBeenCalledWith(clickEvent); + expect(el.dataset.futureEvent).toBe('true'); + + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(futureHandler).toHaveBeenCalledWith(clickEvent); + }); }); - }); describe('Given complex mixin scenarios', () => { + describe('When handling mixed attribute types in single mixin', () => { + it('handles mixed attribute types in single mixin', () => { + const el = MockElement(); + const clickHandler = jest.fn(); + + const source = { + // Regular attributes + 'id': 'complex-mixin', + 'class': 'complex-class', + 'title': 'Complex Mixin', + + // Data attributes + 'data-complex': 'value', + 'data-number': 42, + + // Boolean attributes + 'disabled': false, + 'readonly': true, + + // Event listeners + 'onclick': clickHandler, + + // Style (if supported) + 'style': 'color: red; font-weight: bold;' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.id).toBe('complex-mixin'); + expect(el.className).toBe('complex-class'); + expect(el.getAttribute('title')).toBe('Complex Mixin'); + expect(el.dataset.complex).toBe('value'); + expect(el.dataset.number).toBe('42'); + expect(el.disabled).toBe(false); + expect(el.readOnly).toBe(true); + expect(el.getAttribute('style')).toBe('color: red; font-weight: bold;'); + + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(clickHandler).toHaveBeenCalledWith(clickEvent); + }); + }); - it('handles mixed attribute types in single mixin', () => { - const el = MockElement(); - const clickHandler = jest.fn(); - - const source = { - // Regular attributes - 'id': 'complex-mixin', - 'class': 'complex-class', - 'title': 'Complex Mixin', + describe('When applying multiple mixins to same element', () => { + it('overwrites previous attributes when applied multiple times', () => { + const el = MockElement(); - // Data attributes - 'data-complex': 'value', - 'data-number': 42, + const firstSource = { + 'id': 'first-id', + 'class': 'first-class', + 'data-value': 'first' + }; + + const secondSource = { + 'id': 'second-id', + 'class': 'second-class', + 'data-value': 'second' + }; + + const config1 = Mixin(firstSource); + const config2 = Mixin(secondSource); - // Boolean attributes - 'disabled': false, - 'readonly': true, - - // Event listeners - 'onclick': clickHandler, - - // Style (if supported) - 'style': 'color: red; font-weight: bold;' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.id).toBe('complex-mixin'); - expect(el.className).toBe('complex-class'); - expect(el.getAttribute('title')).toBe('Complex Mixin'); - expect(el.dataset.complex).toBe('value'); - expect(el.dataset.number).toBe('42'); - expect(el.disabled).toBe(false); - expect(el.readOnly).toBe(true); - expect(el.getAttribute('style')).toBe('color: red; font-weight: bold;'); - - const clickEvent = new Event('click'); - el.dispatchEvent(clickEvent); - expect(clickHandler).toHaveBeenCalledWith(clickEvent); - }); - - it('overwrites previous attributes when applied multiple times', () => { - const el = MockElement(); - - const firstSource = { - 'id': 'first-id', - 'class': 'first-class', - 'data-value': 'first' - }; - - const secondSource = { - 'id': 'second-id', - 'class': 'second-class', - 'data-value': 'second' - }; - - const config1 = Mixin(firstSource); - const config2 = Mixin(secondSource); - - const sink1 = config1.sink(el); - const sink2 = config2.sink(el); - - sink1(config1.source); - expect(el.id).toBe('first-id'); - expect(el.className).toBe('first-class'); - expect(el.dataset.value).toBe('first'); - - sink2(config2.source); - expect(el.id).toBe('second-id'); - expect(el.className).toBe('second-class'); - expect(el.dataset.value).toBe('second'); + const sink1 = config1.sink(el); + const sink2 = config2.sink(el); + + sink1(config1.source); + expect(el.id).toBe('first-id'); + expect(el.className).toBe('first-class'); + expect(el.dataset.value).toBe('first'); + + sink2(config2.source); + expect(el.id).toBe('second-id'); + expect(el.className).toBe('second-class'); + expect(el.dataset.value).toBe('second'); + }); }); - }); describe('Given edge cases', () => { - - it('handles empty mixin object', () => { - const el = MockElement(); - const source = {}; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - // Should not throw and element should remain unchanged - expect(el.className).toBe(''); - expect(el.id).toBe(''); + describe('When an empty mixin object is given', () => { + it('handles empty mixin object', () => { + const el = MockElement(); + const source = {}; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + // Should not throw and element should remain unchanged + expect(el.className).toBe(''); + expect(el.id).toBe(''); + }); }); - it('handles null and undefined values in mixin', () => { - const el = MockElement(); - - // Set initial attributes - el.setAttribute('data-foo', 'bar'); - el.className = 'initial-class'; - - const source = { - 'data-foo': null, - 'class': undefined, - 'title': '', - 'id': 'valid-id' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.getAttribute('data-foo')).toBeUndefined(); - expect(el.className).toBe(''); - expect(el.getAttribute('title')).toBeUndefined(); - expect(el.id).toBe('valid-id'); + describe('When null or undefined values are given', () => { + it('handles null and undefined values in mixin', () => { + const el = MockElement(); + + // Set initial attributes + el.setAttribute('data-foo', 'bar'); + el.className = 'initial-class'; + + const source = { + 'data-foo': null, + 'class': undefined, + 'title': '', + 'id': 'valid-id' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.getAttribute('data-foo')).toBeUndefined(); + expect(el.className).toBe(''); + expect(el.getAttribute('title')).toBeUndefined(); + expect(el.id).toBe('valid-id'); + }); }); - it('handles string "false" values correctly', () => { - const el = MockElement(); - - const source = { - 'data-false': 'false', - 'data-true': 'true', - 'disabled': 'false', - 'readonly': 'false' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - // String "false" should be treated as falsy for attribute removal - expect(el.getAttribute('data-false')).toBeUndefined(); - expect(el.dataset.true).toBe('true'); - expect(el.disabled).toBe(false); - expect(el.getAttribute('readonly')).toBeUndefined(); + describe('When string "false" values are given', () => { + it('handles string "false" values correctly', () => { + const el = MockElement(); + + const source = { + 'data-false': 'false', + 'data-true': 'true', + 'disabled': 'false', + 'readonly': 'false' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + // String "false" should be treated as falsy for attribute removal + expect(el.getAttribute('data-false')).toBeUndefined(); + expect(el.dataset.true).toBe('true'); + expect(el.disabled).toBe(false); + expect(el.getAttribute('readonly')).toBeUndefined(); + }); }); - }); describe('Given sink configuration structure', () => { + describe('When creating sink configuration', () => { + it('returns correct sink binding configuration type', () => { + const source = { 'test': 'value' }; + const config = Mixin(source); + + expect(config).toHaveProperty('type', SINK_TAG); + expect(config).toHaveProperty('t', MIXIN_SINK_TAG); + expect(config).toHaveProperty('source', source); + expect(config).toHaveProperty('sink', AttributeObjectSink); + }); - it('returns correct sink binding configuration type', () => { - const source = { 'test': 'value' }; - const config = Mixin(source); - - expect(config).toHaveProperty('type', SINK_TAG); - expect(config).toHaveProperty('t', MIXIN_SINK_TAG); - expect(config).toHaveProperty('source', source); - expect(config).toHaveProperty('sink', AttributeObjectSink); - }); - - it('preserves source reference in configuration', () => { - const source = { 'preserved': 'reference' }; - const config = Mixin(source); + it('preserves source reference in configuration', () => { + const source = { 'preserved': 'reference' }; + const config = Mixin(source); - expect(config.source).toBe(source); + expect(config.source).toBe(source); + }); }); - }); }); From 8a43e29060dc25ffd867de2f95abdef734804ed6 Mon Sep 17 00:00:00 2001 From: TanmayRanaware Date: Thu, 2 Oct 2025 23:15:52 -0700 Subject: [PATCH 09/12] Remove swap-source.test.ts - incorrectly added from another PR This file was added by other contributors (3mindedscholar, Pradeep Kaswan) in commits f248b5a and 178c043, but should not be in this branch which is focused on mixin sink unit tests. --- src/sources/swap-source.test.ts | 157 -------------------------------- 1 file changed, 157 deletions(-) delete mode 100644 src/sources/swap-source.test.ts diff --git a/src/sources/swap-source.test.ts b/src/sources/swap-source.test.ts deleted file mode 100644 index 482e77f..0000000 --- a/src/sources/swap-source.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { Observable } from 'rxjs'; - -import { Subject } from 'rxjs'; -import { Swap, swap } from './swap-source'; -import { MockElement, MockEvent } from '../test-support'; - -describe('Swap Event Adapter', () => { - it('Swaps a value from an element with a static string', () => { - const oldValue = 'old data'; - const newValue = 'new data'; - const el = MockElement({ - tagName: "INPUT", - type: 'text', - value: oldValue, - }); - const eventData = MockEvent('input', { - target: el as HTMLInputElement - }); - const handlerSpy = jest.fn(); - const source = Swap(newValue, handlerSpy); - - source.next(eventData); - - expect(handlerSpy).toHaveBeenCalledWith(oldValue); - expect(el.value).toEqual(newValue); - }) -}) - -it('Swaps a value from an element with empty string by default', () => { - const oldValue = 'old data'; - const el = MockElement({ - tagName: 'INPUT', - type: 'text', - value: oldValue, - }); - const eventData = MockEvent('input', { - target: el as HTMLInputElement - }); - const handlerSpy = jest.fn(); - const source = Swap(undefined, handlerSpy); - - source.next(eventData); - - expect(handlerSpy).toHaveBeenCalledWith(oldValue); - expect(el.value).toEqual(''); -}); - -it('Swaps a value using a function that transforms based on the old value', () => { - const oldValue = '5'; - const replacementFn = (v: string) => String(Number(v) * 2); - const el = MockElement({ - tagName: 'INPUT', - type: 'text', - value: oldValue, - }); - const eventData = MockEvent('input', { - target: el as HTMLInputElement - }); - const handlerSpy = jest.fn(); - const source = Swap(replacementFn, handlerSpy); - - source.next(eventData); - - expect(handlerSpy).toHaveBeenCalledWith(oldValue); - expect(el.value).toEqual('10'); -}); - -describe('swap Event Operator', () => { - it('Swaps and emits a value from an element with a static string', () => { - const oldValue = 'old data'; - const newValue = 'new data'; - const el = MockElement({ - tagName: 'INPUT', - type: 'text', - value: oldValue, - }); - const eventData = MockEvent('input', { - target: el as HTMLInputElement - }); - const handlerSpy = jest.fn(); - const pipeline = new Subject().pipe(swap(newValue)) as Observable & Subject; - - pipeline.subscribe(x => handlerSpy(x)); - pipeline.next(eventData); - - expect(handlerSpy).toHaveBeenCalledWith(oldValue); - expect(el.value).toEqual(newValue); - }); - - it('Swaps and emits a value from an element with empty string', () => { - const oldValue = 'old data'; - const el = MockElement({ - tagName: 'INPUT', - type: 'text', - value: oldValue, - }); - const eventData = MockEvent('input', { - target: el as HTMLInputElement - }); - const handlerSpy = jest.fn(); - const pipeline = new Subject().pipe(swap('')) as Observable & Subject; - - pipeline.subscribe(x => handlerSpy(x)); - pipeline.next(eventData); - - expect(handlerSpy).toHaveBeenCalledWith(oldValue); - expect(el.value).toEqual(''); - }); - - - - it('Swaps a value using a function that generates new value from old', () => { - const oldValue = 'test'; - const replacementFn = (v: string) => `${v}_modified`; - const el = MockElement({ - tagName: 'INPUT', - type: 'text', - value: oldValue, - }); - const eventData = MockEvent('input', { - target: el as HTMLInputElement - }); - const handlerSpy = jest.fn(); - const pipeline = new Subject().pipe(swap(replacementFn)) as Observable & Subject; - - pipeline.subscribe(x => handlerSpy(x)); - pipeline.next(eventData); - - expect(handlerSpy).toHaveBeenCalledWith(oldValue); - expect(el.value).toEqual('test_modified'); - }); - - it('Handles multiple swap operations in sequence', () => { - const values = ['first', 'second', 'third']; - const el = MockElement({ - tagName: 'INPUT', - type: 'text', - value: values[0], - }); - const handlerSpy = jest.fn(); - const pipeline = new Subject().pipe(swap('replacement')) as Observable & Subject; - - pipeline.subscribe(x => handlerSpy(x)); - - values.forEach(val => { - el.value = val; - const eventData = MockEvent('input', { target: el as HTMLInputElement }); - pipeline.next(eventData); - }); - - expect(handlerSpy).toHaveBeenCalledTimes(3); - expect(handlerSpy).toHaveBeenNthCalledWith(1, 'first'); - expect(handlerSpy).toHaveBeenNthCalledWith(2, 'second'); - expect(handlerSpy).toHaveBeenNthCalledWith(3, 'third'); - expect(el.value).toEqual('replacement'); - }); -}); From 52c8bae13cde12d156ffbb97890a9ff6114eb516 Mon Sep 17 00:00:00 2001 From: TanmayRanaware Date: Thu, 2 Oct 2025 23:16:14 -0700 Subject: [PATCH 10/12] Add nested 'when' describe blocks for better test organization - Restructure test organization to use nested describe blocks - Add specific 'when' scenarios under each 'Given' context - Improve maintainability and extensibility of test structure - Make test scenarios more descriptive and organized - Follow best practices for test organization as project grows --- src/sinks/mixin-sink.test.ts | 546 ++++++++++++++++++----------------- 1 file changed, 285 insertions(+), 261 deletions(-) diff --git a/src/sinks/mixin-sink.test.ts b/src/sinks/mixin-sink.test.ts index 860f2d0..935b8d0 100644 --- a/src/sinks/mixin-sink.test.ts +++ b/src/sinks/mixin-sink.test.ts @@ -6,7 +6,7 @@ import { SINK_TAG } from '../constants'; describe('Mixin Sink', () => { describe('Given a plain object mixin', () => { - describe('When creating sink configuration', () => { + describe('when creating sink configuration', () => { it('creates sink binding configuration with correct properties', () => { const source = { 'data-foo': 'bar', @@ -23,70 +23,76 @@ describe('Mixin Sink', () => { }); }); - describe('When applying attributes to element', () => { - it('applies plain object attributes to element immediately', () => { - const el = MockElement(); - const source = { - 'data-foo': 'bar', - 'class': 'test-class', - 'id': 'test-id', - 'title': 'test-title' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.dataset.foo).toBe('bar'); - expect(el.className).toBe('test-class'); - expect(el.id).toBe('test-id'); - expect(el.getAttribute('title')).toBe('test-title'); + describe('when applying attributes to element', () => { + describe('when regular attributes are provided', () => { + it('applies plain object attributes to element immediately', () => { + const el = MockElement(); + const source = { + 'data-foo': 'bar', + 'class': 'test-class', + 'id': 'test-id', + 'title': 'test-title' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.dataset.foo).toBe('bar'); + expect(el.className).toBe('test-class'); + expect(el.id).toBe('test-id'); + expect(el.getAttribute('title')).toBe('test-title'); + }); }); - it('handles boolean attributes correctly', () => { - const el = MockElement(); - const source = { - 'disabled': true, - 'readonly': 'readonly', - 'checked': false - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.disabled).toBe(true); - expect(el.readOnly).toBe('readonly'); - expect(el.checked).toBe(false); + describe('when boolean attributes are provided', () => { + it('handles boolean attributes correctly', () => { + const el = MockElement(); + const source = { + 'disabled': true, + 'readonly': 'readonly', + 'checked': false + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.disabled).toBe(true); + expect(el.readOnly).toBe('readonly'); + expect(el.checked).toBe(false); + }); }); - it('removes attributes when set to falsey values', () => { - const el = MockElement(); - - // Set initial attributes - el.setAttribute('data-foo', 'bar'); - el.setAttribute('title', 'initial-title'); - el.className = 'initial-class'; - - const source = { - 'data-foo': false, - 'title': null, - 'class': undefined - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.getAttribute('data-foo')).toBeUndefined(); - expect(el.getAttribute('title')).toBeUndefined(); - expect(el.className).toBe(''); + describe('when falsey values are provided', () => { + it('removes attributes when set to falsey values', () => { + const el = MockElement(); + + // Set initial attributes + el.setAttribute('data-foo', 'bar'); + el.setAttribute('title', 'initial-title'); + el.className = 'initial-class'; + + const source = { + 'data-foo': false, + 'title': null, + 'class': undefined + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.getAttribute('data-foo')).toBeUndefined(); + expect(el.getAttribute('title')).toBeUndefined(); + expect(el.className).toBe(''); + }); }); }); }); describe('Given a future/promise mixin', () => { - describe('When creating sink configuration', () => { + describe('when creating sink configuration', () => { it('creates sink binding configuration for future source', () => { const futureSource = Promise.resolve({ 'data-future': 'value', @@ -102,251 +108,269 @@ describe('Mixin Sink', () => { }); }); - describe('When applying future attributes', () => { - it('applies future attributes when promise resolves', async () => { - const el = MockElement(); - const futureSource = Promise.resolve({ - 'data-future': 'resolved-value', - 'class': 'resolved-class', - 'title': 'resolved-title' - }); - - const config = Mixin(futureSource); - const sink = config.sink(el); - - // Apply the sink (this should handle the promise) - sink(config.source); + describe('when applying future attributes', () => { + describe('when promise resolves successfully', () => { + it('applies future attributes when promise resolves', async () => { + const el = MockElement(); + const futureSource = Promise.resolve({ + 'data-future': 'resolved-value', + 'class': 'resolved-class', + 'title': 'resolved-title' + }); + + const config = Mixin(futureSource); + const sink = config.sink(el); + + // Apply the sink (this should handle the promise) + sink(config.source); - // Wait for promise to resolve - await new Promise(resolve => setTimeout(resolve, 10)); + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 10)); - expect(el.dataset.future).toBe('resolved-value'); - expect(el.className).toBe('resolved-class'); - expect(el.getAttribute('title')).toBe('resolved-title'); + expect(el.dataset.future).toBe('resolved-value'); + expect(el.className).toBe('resolved-class'); + expect(el.getAttribute('title')).toBe('resolved-title'); + }); }); }); }); describe('Given event listener mixins', () => { - describe('When applying event listeners from mixin object', () => { - it('applies event listeners from mixin object', () => { - const el = MockElement(); - const clickHandler = jest.fn(); - const mouseoverHandler = jest.fn(); - - const source = { - 'onclick': clickHandler, - 'onmouseover': mouseoverHandler, - 'class': 'event-class' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.className).toBe('event-class'); - - // Verify event listeners are attached - const clickEvent = new Event('click'); - el.dispatchEvent(clickEvent); - expect(clickHandler).toHaveBeenCalledWith(clickEvent); - - const mouseoverEvent = new Event('mouseover'); - el.dispatchEvent(mouseoverEvent); - expect(mouseoverHandler).toHaveBeenCalledWith(mouseoverEvent); + describe('when applying event listeners from mixin object', () => { + describe('when multiple event handlers are provided', () => { + it('applies event listeners from mixin object', () => { + const el = MockElement(); + const clickHandler = jest.fn(); + const mouseoverHandler = jest.fn(); + + const source = { + 'onclick': clickHandler, + 'onmouseover': mouseoverHandler, + 'class': 'event-class' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.className).toBe('event-class'); + + // Verify event listeners are attached + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(clickHandler).toHaveBeenCalledWith(clickEvent); + + const mouseoverEvent = new Event('mouseover'); + el.dispatchEvent(mouseoverEvent); + expect(mouseoverHandler).toHaveBeenCalledWith(mouseoverEvent); + }); }); }); - describe('When handling future event listeners', () => { - it('handles future event listeners', async () => { - const el = MockElement(); - const futureHandler = jest.fn(); + describe('when handling future event listeners', () => { + describe('when promise resolves with event handlers', () => { + it('handles future event listeners', async () => { + const el = MockElement(); + const futureHandler = jest.fn(); - const futureSource = Promise.resolve({ - 'onclick': futureHandler, - 'data-future-event': 'true' - }); + const futureSource = Promise.resolve({ + 'onclick': futureHandler, + 'data-future-event': 'true' + }); - const config = Mixin(futureSource); - const sink = config.sink(el); - sink(config.source); + const config = Mixin(futureSource); + const sink = config.sink(el); + sink(config.source); - // Wait for promise to resolve - await new Promise(resolve => setTimeout(resolve, 10)); + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 10)); - expect(el.dataset.futureEvent).toBe('true'); - - const clickEvent = new Event('click'); - el.dispatchEvent(clickEvent); - expect(futureHandler).toHaveBeenCalledWith(clickEvent); + expect(el.dataset.futureEvent).toBe('true'); + + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(futureHandler).toHaveBeenCalledWith(clickEvent); + }); }); }); }); describe('Given complex mixin scenarios', () => { - describe('When handling mixed attribute types in single mixin', () => { - it('handles mixed attribute types in single mixin', () => { - const el = MockElement(); - const clickHandler = jest.fn(); - - const source = { - // Regular attributes - 'id': 'complex-mixin', - 'class': 'complex-class', - 'title': 'Complex Mixin', - - // Data attributes - 'data-complex': 'value', - 'data-number': 42, - - // Boolean attributes - 'disabled': false, - 'readonly': true, - - // Event listeners - 'onclick': clickHandler, - - // Style (if supported) - 'style': 'color: red; font-weight: bold;' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.id).toBe('complex-mixin'); - expect(el.className).toBe('complex-class'); - expect(el.getAttribute('title')).toBe('Complex Mixin'); - expect(el.dataset.complex).toBe('value'); - expect(el.dataset.number).toBe('42'); - expect(el.disabled).toBe(false); - expect(el.readOnly).toBe(true); - expect(el.getAttribute('style')).toBe('color: red; font-weight: bold;'); - - const clickEvent = new Event('click'); - el.dispatchEvent(clickEvent); - expect(clickHandler).toHaveBeenCalledWith(clickEvent); + describe('when handling mixed attribute types in single mixin', () => { + describe('when all attribute types are combined', () => { + it('handles mixed attribute types in single mixin', () => { + const el = MockElement(); + const clickHandler = jest.fn(); + + const source = { + // Regular attributes + 'id': 'complex-mixin', + 'class': 'complex-class', + 'title': 'Complex Mixin', + + // Data attributes + 'data-complex': 'value', + 'data-number': 42, + + // Boolean attributes + 'disabled': false, + 'readonly': true, + + // Event listeners + 'onclick': clickHandler, + + // Style (if supported) + 'style': 'color: red; font-weight: bold;' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.id).toBe('complex-mixin'); + expect(el.className).toBe('complex-class'); + expect(el.getAttribute('title')).toBe('Complex Mixin'); + expect(el.dataset.complex).toBe('value'); + expect(el.dataset.number).toBe('42'); + expect(el.disabled).toBe(false); + expect(el.readOnly).toBe(true); + expect(el.getAttribute('style')).toBe('color: red; font-weight: bold;'); + + const clickEvent = new Event('click'); + el.dispatchEvent(clickEvent); + expect(clickHandler).toHaveBeenCalledWith(clickEvent); + }); }); }); - describe('When applying multiple mixins to same element', () => { - it('overwrites previous attributes when applied multiple times', () => { - const el = MockElement(); - - const firstSource = { - 'id': 'first-id', - 'class': 'first-class', - 'data-value': 'first' - }; - - const secondSource = { - 'id': 'second-id', - 'class': 'second-class', - 'data-value': 'second' - }; - - const config1 = Mixin(firstSource); - const config2 = Mixin(secondSource); - - const sink1 = config1.sink(el); - const sink2 = config2.sink(el); - - sink1(config1.source); - expect(el.id).toBe('first-id'); - expect(el.className).toBe('first-class'); - expect(el.dataset.value).toBe('first'); - - sink2(config2.source); - expect(el.id).toBe('second-id'); - expect(el.className).toBe('second-class'); - expect(el.dataset.value).toBe('second'); + describe('when applying multiple mixins to same element', () => { + describe('when second mixin overwrites first mixin', () => { + it('overwrites previous attributes when applied multiple times', () => { + const el = MockElement(); + + const firstSource = { + 'id': 'first-id', + 'class': 'first-class', + 'data-value': 'first' + }; + + const secondSource = { + 'id': 'second-id', + 'class': 'second-class', + 'data-value': 'second' + }; + + const config1 = Mixin(firstSource); + const config2 = Mixin(secondSource); + + const sink1 = config1.sink(el); + const sink2 = config2.sink(el); + + sink1(config1.source); + expect(el.id).toBe('first-id'); + expect(el.className).toBe('first-class'); + expect(el.dataset.value).toBe('first'); + + sink2(config2.source); + expect(el.id).toBe('second-id'); + expect(el.className).toBe('second-class'); + expect(el.dataset.value).toBe('second'); + }); }); }); }); describe('Given edge cases', () => { - describe('When an empty mixin object is given', () => { - it('handles empty mixin object', () => { - const el = MockElement(); - const source = {}; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - // Should not throw and element should remain unchanged - expect(el.className).toBe(''); - expect(el.id).toBe(''); + describe('when an empty mixin object is given', () => { + describe('when no attributes are provided', () => { + it('handles empty mixin object', () => { + const el = MockElement(); + const source = {}; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + // Should not throw and element should remain unchanged + expect(el.className).toBe(''); + expect(el.id).toBe(''); + }); }); }); - describe('When null or undefined values are given', () => { - it('handles null and undefined values in mixin', () => { - const el = MockElement(); - - // Set initial attributes - el.setAttribute('data-foo', 'bar'); - el.className = 'initial-class'; - - const source = { - 'data-foo': null, - 'class': undefined, - 'title': '', - 'id': 'valid-id' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - expect(el.getAttribute('data-foo')).toBeUndefined(); - expect(el.className).toBe(''); - expect(el.getAttribute('title')).toBeUndefined(); - expect(el.id).toBe('valid-id'); + describe('when null or undefined values are given', () => { + describe('when attributes have null/undefined values', () => { + it('handles null and undefined values in mixin', () => { + const el = MockElement(); + + // Set initial attributes + el.setAttribute('data-foo', 'bar'); + el.className = 'initial-class'; + + const source = { + 'data-foo': null, + 'class': undefined, + 'title': '', + 'id': 'valid-id' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + expect(el.getAttribute('data-foo')).toBeUndefined(); + expect(el.className).toBe(''); + expect(el.getAttribute('title')).toBeUndefined(); + expect(el.id).toBe('valid-id'); + }); }); }); - describe('When string "false" values are given', () => { - it('handles string "false" values correctly', () => { - const el = MockElement(); - - const source = { - 'data-false': 'false', - 'data-true': 'true', - 'disabled': 'false', - 'readonly': 'false' - }; - - const config = Mixin(source); - const sink = config.sink(el); - sink(config.source); - - // String "false" should be treated as falsy for attribute removal - expect(el.getAttribute('data-false')).toBeUndefined(); - expect(el.dataset.true).toBe('true'); - expect(el.disabled).toBe(false); - expect(el.getAttribute('readonly')).toBeUndefined(); + describe('when string "false" values are given', () => { + describe('when attributes contain string "false"', () => { + it('handles string "false" values correctly', () => { + const el = MockElement(); + + const source = { + 'data-false': 'false', + 'data-true': 'true', + 'disabled': 'false', + 'readonly': 'false' + }; + + const config = Mixin(source); + const sink = config.sink(el); + sink(config.source); + + // String "false" should be treated as falsy for attribute removal + expect(el.getAttribute('data-false')).toBeUndefined(); + expect(el.dataset.true).toBe('true'); + expect(el.disabled).toBe(false); + expect(el.getAttribute('readonly')).toBeUndefined(); + }); }); }); }); describe('Given sink configuration structure', () => { - describe('When creating sink configuration', () => { - it('returns correct sink binding configuration type', () => { - const source = { 'test': 'value' }; - const config = Mixin(source); - - expect(config).toHaveProperty('type', SINK_TAG); - expect(config).toHaveProperty('t', MIXIN_SINK_TAG); - expect(config).toHaveProperty('source', source); - expect(config).toHaveProperty('sink', AttributeObjectSink); - }); + describe('when creating sink configuration', () => { + describe('when valid source is provided', () => { + it('returns correct sink binding configuration type', () => { + const source = { 'test': 'value' }; + const config = Mixin(source); + + expect(config).toHaveProperty('type', SINK_TAG); + expect(config).toHaveProperty('t', MIXIN_SINK_TAG); + expect(config).toHaveProperty('source', source); + expect(config).toHaveProperty('sink', AttributeObjectSink); + }); - it('preserves source reference in configuration', () => { - const source = { 'preserved': 'reference' }; - const config = Mixin(source); + it('preserves source reference in configuration', () => { + const source = { 'preserved': 'reference' }; + const config = Mixin(source); - expect(config.source).toBe(source); + expect(config.source).toBe(source); + }); }); }); }); From 4711baf6a77dc51153e290238a7f3cd6f1b7ee91 Mon Sep 17 00:00:00 2001 From: TanmayRanaware Date: Thu, 2 Oct 2025 23:17:58 -0700 Subject: [PATCH 11/12] Remove TL;DR reference from README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'To Busy to read?, Check [TL;DR 📜](./QUICKSTART.md)' line - Clean up README structure and flow - Remove reference to QUICKSTART.md file --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 7c8f3d2..ebc3b9d 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,6 @@ Rimmel is a powerful, fast and lightweight JavaScript UI library for creating we It implements [RML](https://github.com/ReactiveHTML/reactive-markup), the Reactive Markup which makes your HTML work with streams in a seamless way. -## To Busy to read?, Check [TL;DR 📜](./QUICKSTART.md)
- ## Getting started If you are new to reactive streams, there is a [3m crash-course](https://medium.com/@fourtyeighthours/the-mostly-inaccurate-crash-course-for-reactive-ui-development-w-rxjs-ddbb7e5e526e) tailored for UI development with Rimmel, arguably the simplest RxJS introduction around to get you started. From ed6215ac3f0c3c7d522663c447d71018bfc03102 Mon Sep 17 00:00:00 2001 From: TanmayRanaware Date: Thu, 2 Oct 2025 23:21:04 -0700 Subject: [PATCH 12/12] Restore swap-source.test.ts from master branch This file exists in master but was accidentally removed from this branch. Restoring it to maintain consistency with master branch. --- src/sources/swap-source.test.ts | 157 ++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/sources/swap-source.test.ts diff --git a/src/sources/swap-source.test.ts b/src/sources/swap-source.test.ts new file mode 100644 index 0000000..482e77f --- /dev/null +++ b/src/sources/swap-source.test.ts @@ -0,0 +1,157 @@ +import type { Observable } from 'rxjs'; + +import { Subject } from 'rxjs'; +import { Swap, swap } from './swap-source'; +import { MockElement, MockEvent } from '../test-support'; + +describe('Swap Event Adapter', () => { + it('Swaps a value from an element with a static string', () => { + const oldValue = 'old data'; + const newValue = 'new data'; + const el = MockElement({ + tagName: "INPUT", + type: 'text', + value: oldValue, + }); + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + const handlerSpy = jest.fn(); + const source = Swap(newValue, handlerSpy); + + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(newValue); + }) +}) + +it('Swaps a value from an element with empty string by default', () => { + const oldValue = 'old data'; + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + const handlerSpy = jest.fn(); + const source = Swap(undefined, handlerSpy); + + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(''); +}); + +it('Swaps a value using a function that transforms based on the old value', () => { + const oldValue = '5'; + const replacementFn = (v: string) => String(Number(v) * 2); + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + const handlerSpy = jest.fn(); + const source = Swap(replacementFn, handlerSpy); + + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual('10'); +}); + +describe('swap Event Operator', () => { + it('Swaps and emits a value from an element with a static string', () => { + const oldValue = 'old data'; + const newValue = 'new data'; + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(swap(newValue)) as Observable & Subject; + + pipeline.subscribe(x => handlerSpy(x)); + pipeline.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(newValue); + }); + + it('Swaps and emits a value from an element with empty string', () => { + const oldValue = 'old data'; + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(swap('')) as Observable & Subject; + + pipeline.subscribe(x => handlerSpy(x)); + pipeline.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(''); + }); + + + + it('Swaps a value using a function that generates new value from old', () => { + const oldValue = 'test'; + const replacementFn = (v: string) => `${v}_modified`; + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(swap(replacementFn)) as Observable & Subject; + + pipeline.subscribe(x => handlerSpy(x)); + pipeline.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual('test_modified'); + }); + + it('Handles multiple swap operations in sequence', () => { + const values = ['first', 'second', 'third']; + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: values[0], + }); + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(swap('replacement')) as Observable & Subject; + + pipeline.subscribe(x => handlerSpy(x)); + + values.forEach(val => { + el.value = val; + const eventData = MockEvent('input', { target: el as HTMLInputElement }); + pipeline.next(eventData); + }); + + expect(handlerSpy).toHaveBeenCalledTimes(3); + expect(handlerSpy).toHaveBeenNthCalledWith(1, 'first'); + expect(handlerSpy).toHaveBeenNthCalledWith(2, 'second'); + expect(handlerSpy).toHaveBeenNthCalledWith(3, 'third'); + expect(el.value).toEqual('replacement'); + }); +});