import { describe, expect, it, vi } from 'vitest' import { ArrayEvent, isArrayInitEvent, SubscribableArrayImpl, SubscribableImpl } from './subscribable.ts' describe('SubscribableImpl', () => { it('provides value synchronously', () => { const s = new SubscribableImpl(42) expect(s.value).toBe(42) }) it('does NOT fire callback on subscribe', () => { const s = new SubscribableImpl('hello') const calls: string[] = [] s.subscribe(() => calls.push(s.value)) expect(calls).toEqual([]) }) it('notifies on _set', () => { const s = new SubscribableImpl(1) const values: number[] = [] s.subscribe(() => values.push(s.value)) s._set(2) s._set(3) expect(values).toEqual([2, 3]) expect(s.value).toBe(3) }) it('activates upstream lazily on first subscribe', () => { const deactivate = vi.fn() const activate = vi.fn(() => deactivate) const s = new SubscribableImpl(0, activate) expect(activate).not.toHaveBeenCalled() const unsub = s.subscribe(() => {}) expect(activate).toHaveBeenCalledOnce() // Second subscriber doesn't re-activate const unsub2 = s.subscribe(() => {}) expect(activate).toHaveBeenCalledOnce() // Unsubscribe one — upstream stays active unsub2() expect(deactivate).not.toHaveBeenCalled() // Unsubscribe last — upstream deactivates unsub() expect(deactivate).toHaveBeenCalledOnce() }) it('re-activates upstream after full unsubscribe + re-subscribe', () => { const deactivate = vi.fn() const activate = vi.fn(() => deactivate) const s = new SubscribableImpl(0, activate) const unsub1 = s.subscribe(() => {}) unsub1() expect(activate).toHaveBeenCalledTimes(1) expect(deactivate).toHaveBeenCalledTimes(1) const unsub2 = s.subscribe(() => {}) expect(activate).toHaveBeenCalledTimes(2) unsub2() expect(deactivate).toHaveBeenCalledTimes(2) }) it('snapshots subscribers to handle unsubscribe during notification', () => { const s = new SubscribableImpl(0) const events: string[] = [] let unsub2: (() => void) | null = null s.subscribe(() => { events.push('sub1') unsub2?.() }) unsub2 = s.subscribe(() => { events.push('sub2') }) // No immediate callbacks — nothing fired yet expect(events).toEqual([]) // _set — sub1 runs and unsubscribes sub2, but sub2 still fires (snapshot) s._set(1) expect(events).toEqual(['sub1', 'sub2']) }) it('dispose tears down upstream and clears subscribers', () => { const deactivate = vi.fn() const activate = vi.fn(() => deactivate) const s = new SubscribableImpl(0, activate) s.subscribe(() => {}) expect(activate).toHaveBeenCalledOnce() s.dispose() expect(deactivate).toHaveBeenCalledOnce() // _set after dispose — no error, no subscribers s._set(99) expect(s.value).toBe(99) }) it('works with complex types', () => { const s = new SubscribableImpl<{ name: string } | null>(null) const values: ({ name: string } | null)[] = [] s.subscribe(() => values.push(s.value)) s._set({ name: 'Alice' }) s._set(null) expect(values).toEqual([{ name: 'Alice' }, null]) }) }) describe('SubscribableArrayImpl', () => { it('provides items synchronously', () => { const arr = new SubscribableArrayImpl([1, 2, 3]) expect(arr.items).toEqual([1, 2, 3]) expect(arr.length).toBe(3) }) it('does NOT send init event on subscribe', () => { const arr = new SubscribableArrayImpl([1, 2, 3]) const events: ArrayEvent[] = [] arr.subscribe(e => events.push(e)) expect(events).toEqual([]) // Current state is available via .items expect(arr.items).toEqual([1, 2, 3]) }) it('notifies on _push', () => { const arr = new SubscribableArrayImpl([1]) const events: ArrayEvent[] = [] arr.subscribe(e => events.push(e)) arr._push(2, 3) expect(arr.items).toEqual([1, 2, 3]) expect(events).toEqual([ { added: [2, 3], removed: null }, ]) }) it('notifies on _remove', () => { const arr = new SubscribableArrayImpl([1, 2, 3]) const events: ArrayEvent[] = [] arr.subscribe(e => events.push(e)) arr._remove([2]) expect(arr.items).toEqual([1, 3]) expect(events).toEqual([ { added: [], removed: [2] }, ]) }) it('notifies on _reset', () => { const arr = new SubscribableArrayImpl([1, 2]) const events: ArrayEvent[] = [] arr.subscribe(e => events.push(e)) arr._reset([10, 20]) expect(arr.items).toEqual([10, 20]) expect(events).toEqual([ { init: [10, 20] }, ]) }) it('activates upstream lazily on first subscribe', () => { const deactivate = vi.fn() const activate = vi.fn(() => deactivate) const arr = new SubscribableArrayImpl([1], activate) expect(activate).not.toHaveBeenCalled() const unsub = arr.subscribe(() => {}) expect(activate).toHaveBeenCalledOnce() // Second subscriber doesn't re-activate const unsub2 = arr.subscribe(() => {}) expect(activate).toHaveBeenCalledOnce() // Unsubscribe one — upstream stays active unsub2() expect(deactivate).not.toHaveBeenCalled() // Unsubscribe last — upstream deactivates unsub() expect(deactivate).toHaveBeenCalledOnce() }) it('re-activates upstream after full unsubscribe + re-subscribe', () => { const deactivate = vi.fn() const activate = vi.fn(() => deactivate) const arr = new SubscribableArrayImpl([1], activate) const unsub1 = arr.subscribe(() => {}) unsub1() expect(activate).toHaveBeenCalledTimes(1) expect(deactivate).toHaveBeenCalledTimes(1) // Re-subscribe should re-activate const unsub2 = arr.subscribe(() => {}) expect(activate).toHaveBeenCalledTimes(2) unsub2() expect(deactivate).toHaveBeenCalledTimes(2) }) it('snapshots subscribers to handle unsubscribe during notification', () => { const arr = new SubscribableArrayImpl([]) const events: string[] = [] let unsub2: (() => void) | null = null arr.subscribe(() => { events.push('sub1') // Unsubscribe sub2 during notification unsub2?.() }) unsub2 = arr.subscribe(() => { events.push('sub2') }) // No init events — nothing fired yet expect(events).toEqual([]) // Push — sub1 runs and unsubscribes sub2, but sub2 should still receive this event // (because we snapshot subscribers before iterating) arr._push(1) expect(events).toEqual(['sub1', 'sub2']) }) it('dispose tears down upstream and clears subscribers', () => { const deactivate = vi.fn() const activate = vi.fn(() => deactivate) const arr = new SubscribableArrayImpl([1], activate) arr.subscribe(() => {}) expect(activate).toHaveBeenCalledOnce() arr.dispose() expect(deactivate).toHaveBeenCalledOnce() // Push after dispose — no subscribers to notify (no error) arr._push(2) expect(arr.items).toEqual([1, 2]) }) }) describe('isArrayInitEvent', () => { it('identifies init events', () => { expect(isArrayInitEvent({ init: [1, 2] })).toBe(true) expect(isArrayInitEvent({ added: [1], removed: null })).toBe(false) }) })