Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/good-parts-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codebelt/classy-store": patch
---

add `withHistory`, `devtools`, and `subscribeKey` utilities for enhanced state management
155 changes: 155 additions & 0 deletions src/collections/collections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,158 @@ describe('collections inside class store', () => {
expect(s.labels.has('bug')).toBe(true);
});
});

// ── ReactiveMap — additional edge cases ────────────────────────────────────

describe('reactiveMap() — edge cases', () => {
test('supports numeric keys', () => {
const m = reactiveMap<number, string>();
m.set(1, 'one');
m.set(2, 'two');
expect(m.get(1)).toBe('one');
expect(m.size).toBe(2);
expect(m.delete(1)).toBe(true);
expect(m.size).toBe(1);
});

test('supports object keys with Object.is comparison', () => {
const key1 = {id: 1};
const key2 = {id: 2};
const m = reactiveMap<object, string>();
m.set(key1, 'first');
m.set(key2, 'second');

expect(m.get(key1)).toBe('first');
expect(m.get(key2)).toBe('second');

// Different object with same shape is NOT the same key
expect(m.get({id: 1})).toBeUndefined();
});

test('set() returns this for chaining', () => {
const m = reactiveMap<string, number>();
const result = m.set('a', 1);
expect(result).toBe(m);

// Chaining
m.set('b', 2).set('c', 3);
expect(m.size).toBe(3);
});

test('handles NaN as key (Object.is(NaN, NaN) is true)', () => {
const m = reactiveMap<number, string>();
m.set(Number.NaN, 'nan-value');

expect(m.has(Number.NaN)).toBe(true);
expect(m.get(Number.NaN)).toBe('nan-value');
expect(m.size).toBe(1);

// Overwriting NaN key
m.set(Number.NaN, 'updated');
expect(m.size).toBe(1);
expect(m.get(Number.NaN)).toBe('updated');
});

test('delete returns false for non-existent key', () => {
const m = reactiveMap<string, number>();
expect(m.delete('missing')).toBe(false);
});

test('forEach receives the map as third argument', () => {
const m = reactiveMap([['a', 1]] as [string, number][]);
m.forEach((_v, _k, map) => {
expect(map).toBe(m);
});
});

test('snapshot of mutated ReactiveMap reflects latest state', async () => {
const s = createClassyStore({m: reactiveMap<string, number>()});
s.m.set('x', 10);
s.m.set('y', 20);
await flush();

const snap = snapshot(s);
expect(snap.m._entries).toEqual([
['x', 10],
['y', 20],
]);
});
});

// ── ReactiveSet — additional edge cases ────────────────────────────────────

describe('reactiveSet() — edge cases', () => {
test('add() returns this for chaining', () => {
const s = reactiveSet<number>();
const result = s.add(1);
expect(result).toBe(s);

s.add(2).add(3);
expect(s.size).toBe(3);
});

test('handles NaN values (Object.is(NaN, NaN) is true)', () => {
const s = reactiveSet<number>();
s.add(Number.NaN);

expect(s.has(Number.NaN)).toBe(true);
expect(s.size).toBe(1);

// Adding NaN again should be no-op
s.add(Number.NaN);
expect(s.size).toBe(1);

expect(s.delete(Number.NaN)).toBe(true);
expect(s.size).toBe(0);
});

test('delete returns false for non-existent value', () => {
const s = reactiveSet<string>();
expect(s.delete('missing')).toBe(false);
});

test('forEach receives the set as third argument', () => {
const s = reactiveSet([1]);
s.forEach((_v, _k, set) => {
expect(set).toBe(s);
});
});

test('entries returns [value, value] pairs matching Set spec', () => {
const s = reactiveSet([10, 20]);
expect([...s.entries()]).toEqual([
[10, 10],
[20, 20],
]);
});

test('keys and values return the same sequence', () => {
const s = reactiveSet(['a', 'b', 'c']);
expect([...s.keys()]).toEqual([...s.values()]);
});

test('clear on empty set does not throw', () => {
const s = reactiveSet<number>();
expect(() => s.clear()).not.toThrow();
});

test('snapshot of mutated ReactiveSet reflects latest state', async () => {
const s = createClassyStore({tags: reactiveSet<string>()});
s.tags.add('a');
s.tags.add('b');
s.tags.delete('a');
await flush();

const snap = snapshot(s);
expect(snap.tags._items).toEqual(['b']);
});

test('add duplicate after delete re-adds the value', () => {
const s = reactiveSet([1, 2, 3]);
s.delete(2);
expect(s.has(2)).toBe(false);
s.add(2);
expect(s.has(2)).toBe(true);
expect(s.size).toBe(3);
});
});
224 changes: 224 additions & 0 deletions src/core/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,4 +462,228 @@ describe('createClassyStore() — core reactivity', () => {
expect(listener).toHaveBeenCalledTimes(1); // all batched
});
});

// ── Computed getter memoization ───────────────────────────────────────

describe('computed getter memoization', () => {
it('memoizes getter result when deps have not changed', () => {
let callCount = 0;

class Store {
count = 5;
get expensive() {
callCount++;
return this.count * 2;
}
}

const s = createClassyStore(new Store());

expect(s.expensive).toBe(10);
expect(callCount).toBe(1);

// Accessing again without mutation should use cache
expect(s.expensive).toBe(10);
expect(callCount).toBe(1);
});

it('recomputes getter when dependency changes', async () => {
let callCount = 0;

class Store {
count = 5;
get doubled() {
callCount++;
return this.count * 2;
}
}

const s = createClassyStore(new Store());
expect(s.doubled).toBe(10);
expect(callCount).toBe(1);

s.count = 10;
// Getter should recompute because count changed
expect(s.doubled).toBe(20);
expect(callCount).toBe(2);
});

it('getter that reads another getter (nested computed)', () => {
class Store {
count = 3;
get doubled() {
return this.count * 2;
}
get quadrupled() {
return this.doubled * 2;
}
}

const s = createClassyStore(new Store());
expect(s.quadrupled).toBe(12);

s.count = 5;
expect(s.quadrupled).toBe(20);
});

it('getter with nested object dependency recomputes on child mutation', async () => {
class Store {
items = [1, 2, 3];
get total() {
return this.items.reduce((a: number, b: number) => a + b, 0);
}
}

const s = createClassyStore(new Store());
expect(s.total).toBe(6);

s.items.push(4);
expect(s.total).toBe(10);
});

it('getter invalidates when property is replaced entirely', async () => {
class Store {
data = {value: 1};
get label() {
return `value: ${this.data.value}`;
}
}

const s = createClassyStore(new Store());
expect(s.label).toBe('value: 1');

// Replace the entire object
s.data = {value: 99};
expect(s.label).toBe('value: 99');
});
});

// ── Error handling ────────────────────────────────────────────────────

describe('error handling', () => {
it('getInternal throws for a non-store object', () => {
const plainObject = {count: 0};
expect(() => subscribe(plainObject, () => {})).toThrow(
/not a store proxy/,
);
});

it('getInternal throws for a primitive wrapper', () => {
expect(() => subscribe({} as object, () => {})).toThrow(
/not a store proxy/,
);
});

it('getVersion throws for a non-store object', () => {
expect(() => getVersion({})).toThrow(/not a store proxy/);
});
});

// ── Child proxy management ────────────────────────────────────────────

describe('child proxy management', () => {
it('replacing a nested object creates a new child proxy', async () => {
const s = createClassyStore({nested: {a: 1}});
const listener = mock(() => {});
subscribe(s, listener);

const oldRef = s.nested;
s.nested = {a: 2};
const newRef = s.nested;

// Should be different proxy references
expect(oldRef).not.toBe(newRef);
expect(newRef.a).toBe(2);

await flush();
expect(listener).toHaveBeenCalledTimes(1);
});

it('mutations on old child proxy after replacement do not trigger notifications', async () => {
const s = createClassyStore({nested: {a: 1}});
const listener = mock(() => {});

const oldNested = s.nested; // get child proxy
s.nested = {a: 2}; // replace — old child proxy detached

subscribe(s, listener);

// Mutate the old detached proxy reference (directly on target)
// This shouldn't crash, but won't trigger listener on the store
// because the child is no longer linked.
// Note: old proxy still has its own internal, so mutations work on it
// but the store's root won't be notified since the child is orphaned.
oldNested.a = 999;
await flush();

// The store's nested should still be the new value
expect(s.nested.a).toBe(2);
});

it('deeply nested replacement triggers root listener', async () => {
const s = createClassyStore({
level1: {level2: {level3: {value: 'deep'}}},
});
const listener = mock(() => {});
subscribe(s, listener);

s.level1.level2.level3.value = 'changed';
await flush();

expect(listener).toHaveBeenCalledTimes(1);
expect(s.level1.level2.level3.value).toBe('changed');
});
});

// ── Version tracking ──────────────────────────────────────────────────

describe('version tracking', () => {
it('version does not change when same value is set', () => {
const s = createClassyStore({count: 0});
const v1 = getVersion(s);

s.count = 0; // noop — same value
const v2 = getVersion(s);

expect(v2).toBe(v1);
});

it('version increments on nested mutation', async () => {
const s = createClassyStore({nested: {value: 1}});
const v1 = getVersion(s);

s.nested.value = 2;
const v2 = getVersion(s);

expect(v2).toBeGreaterThan(v1);
});

it('version increments on delete', async () => {
const s = createClassyStore({a: 1, b: 2} as Record<string, number>);
const v1 = getVersion(s);

delete s.b;
const v2 = getVersion(s);

expect(v2).toBeGreaterThan(v1);
});

it('multiple rapid mutations produce one notification but multiple version bumps', async () => {
const s = createClassyStore({count: 0});
const listener = mock(() => {});
subscribe(s, listener);

const v1 = getVersion(s);
s.count = 1;
s.count = 2;
s.count = 3;
const v2 = getVersion(s);

await flush();

expect(v2).toBeGreaterThan(v1);
expect(listener).toHaveBeenCalledTimes(1); // batched
expect(s.count).toBe(3);
});
});
});
Loading