@zeix/cause-effect 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/index.dev.js +517 -392
- package/index.js +1 -1
- package/index.ts +15 -4
- package/package.json +1 -1
- package/src/computed.ts +2 -2
- package/src/diff.ts +57 -44
- package/src/effect.ts +2 -6
- package/src/errors.ts +56 -0
- package/src/match.ts +2 -2
- package/src/resolve.ts +2 -2
- package/src/signal.ts +21 -43
- package/src/state.ts +3 -2
- package/src/store.ts +402 -179
- package/src/util.ts +50 -6
- package/test/computed.test.ts +1 -1
- package/test/diff.test.ts +321 -4
- package/test/effect.test.ts +1 -1
- package/test/match.test.ts +13 -3
- package/test/signal.test.ts +12 -140
- package/test/store.test.ts +963 -20
- package/types/index.d.ts +5 -4
- package/types/src/diff.d.ts +5 -3
- package/types/src/errors.d.ts +19 -0
- package/types/src/match.d.ts +1 -1
- package/types/src/resolve.d.ts +1 -1
- package/types/src/signal.d.ts +12 -19
- package/types/src/store.d.ts +48 -30
- package/types/src/util.d.ts +8 -5
- package/index.d.ts +0 -36
- package/types/test-new-effect.d.ts +0 -1
package/test/store.test.ts
CHANGED
|
@@ -3,12 +3,13 @@ import {
|
|
|
3
3
|
computed,
|
|
4
4
|
effect,
|
|
5
5
|
isStore,
|
|
6
|
+
type State,
|
|
6
7
|
type StoreAddEvent,
|
|
7
8
|
type StoreChangeEvent,
|
|
8
9
|
type StoreRemoveEvent,
|
|
10
|
+
type StoreSortEvent,
|
|
9
11
|
state,
|
|
10
12
|
store,
|
|
11
|
-
toSignal,
|
|
12
13
|
UNSET,
|
|
13
14
|
} from '..'
|
|
14
15
|
|
|
@@ -46,12 +47,7 @@ describe('store', () => {
|
|
|
46
47
|
email: 'hannah@example.com',
|
|
47
48
|
})
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
* store() only accepts object map types for arrays
|
|
51
|
-
*/
|
|
52
|
-
const participants = store<{
|
|
53
|
-
[x: number]: { name: string; tags: string[] }
|
|
54
|
-
}>([
|
|
50
|
+
const participants = store<{ name: string; tags: string[] }[]>([
|
|
55
51
|
{ name: 'Alice', tags: ['friends', 'mates'] },
|
|
56
52
|
{ name: 'Bob', tags: ['friends'] },
|
|
57
53
|
])
|
|
@@ -59,18 +55,6 @@ describe('store', () => {
|
|
|
59
55
|
{ name: 'Alice', tags: ['friends', 'mates'] },
|
|
60
56
|
{ name: 'Bob', tags: ['friends'] },
|
|
61
57
|
])
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* toSignal() converts arrays to object map types when creating stores
|
|
65
|
-
*/
|
|
66
|
-
const participants2 = toSignal<{ name: string; tags: string[] }[]>([
|
|
67
|
-
{ name: 'Alice', tags: ['friends', 'mates'] },
|
|
68
|
-
{ name: 'Bob', tags: ['friends'] },
|
|
69
|
-
])
|
|
70
|
-
expect(participants2.get()).toEqual([
|
|
71
|
-
{ name: 'Alice', tags: ['friends', 'mates'] },
|
|
72
|
-
{ name: 'Bob', tags: ['friends'] },
|
|
73
|
-
])
|
|
74
58
|
})
|
|
75
59
|
})
|
|
76
60
|
|
|
@@ -135,6 +119,19 @@ describe('store', () => {
|
|
|
135
119
|
name: 'Hannah',
|
|
136
120
|
})
|
|
137
121
|
})
|
|
122
|
+
|
|
123
|
+
test('add method prevents null values', () => {
|
|
124
|
+
const user = store<{ name: string; tags?: string[] }>({
|
|
125
|
+
name: 'Alice',
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(() => {
|
|
129
|
+
// @ts-expect-error deliberate test that null values are not allowed
|
|
130
|
+
user.add('tags', null)
|
|
131
|
+
}).toThrow(
|
|
132
|
+
'Nullish signal values are not allowed in store for key "tags"',
|
|
133
|
+
)
|
|
134
|
+
})
|
|
138
135
|
})
|
|
139
136
|
|
|
140
137
|
describe('nested stores', () => {
|
|
@@ -489,7 +486,12 @@ describe('store', () => {
|
|
|
489
486
|
})
|
|
490
487
|
|
|
491
488
|
const originalSize = user.size.get()
|
|
492
|
-
|
|
489
|
+
|
|
490
|
+
expect(() => {
|
|
491
|
+
user.add('email', 'new@example.com')
|
|
492
|
+
}).toThrow(
|
|
493
|
+
'Could not add store key "email" with value "new@example.com" because it already exists',
|
|
494
|
+
)
|
|
493
495
|
|
|
494
496
|
expect(user.email?.get()).toBe('original@example.com')
|
|
495
497
|
expect(user.size.get()).toBe(originalSize)
|
|
@@ -539,6 +541,189 @@ describe('store', () => {
|
|
|
539
541
|
})
|
|
540
542
|
})
|
|
541
543
|
|
|
544
|
+
describe('array-derived stores with computed sum', () => {
|
|
545
|
+
test('computes sum correctly and updates when items are added, removed, or changed', () => {
|
|
546
|
+
// Create a store with an array of numbers
|
|
547
|
+
const numbers = store([1, 2, 3, 4, 5])
|
|
548
|
+
|
|
549
|
+
// Create a computed that calculates the sum by accessing the array via .get()
|
|
550
|
+
// This ensures reactivity to both value changes and structural changes
|
|
551
|
+
const sum = computed(() => {
|
|
552
|
+
const array = numbers.get()
|
|
553
|
+
if (!Array.isArray(array)) return 0
|
|
554
|
+
return array.reduce((acc, num) => acc + num, 0)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// Initial sum should be 15 (1+2+3+4+5)
|
|
558
|
+
expect(sum.get()).toBe(15)
|
|
559
|
+
expect(numbers.size.get()).toBe(5)
|
|
560
|
+
|
|
561
|
+
// Test adding items
|
|
562
|
+
numbers.add(6) // Add 6 at index 5
|
|
563
|
+
expect(sum.get()).toBe(21) // 15 + 6 = 21
|
|
564
|
+
expect(numbers.size.get()).toBe(6)
|
|
565
|
+
|
|
566
|
+
numbers.add(7) // Add 7 at index 6
|
|
567
|
+
expect(sum.get()).toBe(28) // 21 + 7 = 28
|
|
568
|
+
expect(numbers.size.get()).toBe(7)
|
|
569
|
+
|
|
570
|
+
// Test changing a single value
|
|
571
|
+
numbers[2].set(10) // Change index 2 from 3 to 10
|
|
572
|
+
expect(sum.get()).toBe(35) // 28 - 3 + 10 = 35
|
|
573
|
+
|
|
574
|
+
// Test another value change
|
|
575
|
+
numbers[0].set(5) // Change index 0 from 1 to 5
|
|
576
|
+
expect(sum.get()).toBe(39) // 35 - 1 + 5 = 39
|
|
577
|
+
|
|
578
|
+
// Test removing items
|
|
579
|
+
numbers.remove(6) // Remove index 6 (value 7)
|
|
580
|
+
expect(sum.get()).toBe(32) // 39 - 7 = 32
|
|
581
|
+
expect(numbers.size.get()).toBe(6)
|
|
582
|
+
|
|
583
|
+
numbers.remove(0) // Remove index 0 (value 5)
|
|
584
|
+
expect(sum.get()).toBe(27) // 32 - 5 = 27
|
|
585
|
+
expect(numbers.size.get()).toBe(5)
|
|
586
|
+
|
|
587
|
+
// Verify the final array structure using .get()
|
|
588
|
+
const finalArray = numbers.get()
|
|
589
|
+
expect(Array.isArray(finalArray)).toBe(true)
|
|
590
|
+
expect(finalArray).toEqual([2, 10, 4, 5, 6])
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test('handles empty array and single element operations', () => {
|
|
594
|
+
// Start with empty array
|
|
595
|
+
const numbers = store<number[]>([])
|
|
596
|
+
|
|
597
|
+
const sum = computed(() => {
|
|
598
|
+
const array = numbers.get()
|
|
599
|
+
if (!Array.isArray(array)) return 0
|
|
600
|
+
return array.reduce((acc, num) => acc + num, 0)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// Empty array sum should be 0
|
|
604
|
+
expect(sum.get()).toBe(0)
|
|
605
|
+
expect(numbers.size.get()).toBe(0)
|
|
606
|
+
|
|
607
|
+
// Add first element
|
|
608
|
+
numbers.add(42)
|
|
609
|
+
expect(sum.get()).toBe(42)
|
|
610
|
+
expect(numbers.size.get()).toBe(1)
|
|
611
|
+
|
|
612
|
+
// Change the only element
|
|
613
|
+
numbers[0].set(100)
|
|
614
|
+
expect(sum.get()).toBe(100)
|
|
615
|
+
|
|
616
|
+
// Remove the only element
|
|
617
|
+
numbers.remove(0)
|
|
618
|
+
expect(sum.get()).toBe(0)
|
|
619
|
+
expect(numbers.size.get()).toBe(0)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
test('computed sum using store iteration with size tracking', () => {
|
|
623
|
+
const numbers = store([10, 20, 30])
|
|
624
|
+
|
|
625
|
+
// Use iteration but also track size to ensure reactivity to additions/removals
|
|
626
|
+
const sum = computed(() => {
|
|
627
|
+
// Access size to subscribe to structural changes
|
|
628
|
+
numbers.size.get()
|
|
629
|
+
let total = 0
|
|
630
|
+
for (const signal of numbers) {
|
|
631
|
+
total += signal.get()
|
|
632
|
+
}
|
|
633
|
+
return total
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
expect(sum.get()).toBe(60)
|
|
637
|
+
|
|
638
|
+
// Add more numbers
|
|
639
|
+
numbers.add(40)
|
|
640
|
+
expect(sum.get()).toBe(100)
|
|
641
|
+
|
|
642
|
+
// Modify existing values
|
|
643
|
+
numbers[1].set(25) // Change 20 to 25
|
|
644
|
+
expect(sum.get()).toBe(105) // 10 + 25 + 30 + 40
|
|
645
|
+
|
|
646
|
+
// Remove a value
|
|
647
|
+
numbers.remove(2) // Remove 30
|
|
648
|
+
expect(sum.get()).toBe(75) // 10 + 25 + 40
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
test('demonstrates array compaction behavior with remove operations', () => {
|
|
652
|
+
// Create a store with an array
|
|
653
|
+
const numbers = store([10, 20, 30, 40, 50])
|
|
654
|
+
|
|
655
|
+
// Create a computed using iteration approach with size tracking
|
|
656
|
+
const sumWithIteration = computed(() => {
|
|
657
|
+
// Access size to subscribe to structural changes
|
|
658
|
+
numbers.size.get()
|
|
659
|
+
let total = 0
|
|
660
|
+
for (const signal of numbers) {
|
|
661
|
+
total += signal.get()
|
|
662
|
+
}
|
|
663
|
+
return total
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
// Create a computed using .get() approach for comparison
|
|
667
|
+
const sumWithGet = computed(() => {
|
|
668
|
+
const array = numbers.get()
|
|
669
|
+
if (!Array.isArray(array)) return 0
|
|
670
|
+
return array.reduce((acc, num) => acc + num, 0)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
// Initial state: [10, 20, 30, 40, 50], keys [0,1,2,3,4]
|
|
674
|
+
expect(sumWithIteration.get()).toBe(150)
|
|
675
|
+
expect(sumWithGet.get()).toBe(150)
|
|
676
|
+
expect(numbers.size.get()).toBe(5)
|
|
677
|
+
|
|
678
|
+
// Remove items - arrays should compact (not create sparse holes)
|
|
679
|
+
numbers.remove(1) // Remove 20, array becomes [10, 30, 40, 50]
|
|
680
|
+
expect(numbers.size.get()).toBe(4)
|
|
681
|
+
expect(numbers.get()).toEqual([10, 30, 40, 50])
|
|
682
|
+
expect(sumWithIteration.get()).toBe(130) // 10 + 30 + 40 + 50
|
|
683
|
+
expect(sumWithGet.get()).toBe(130)
|
|
684
|
+
|
|
685
|
+
numbers.remove(2) // Remove 40, array becomes [10, 30, 50]
|
|
686
|
+
expect(numbers.size.get()).toBe(3)
|
|
687
|
+
expect(numbers.get()).toEqual([10, 30, 50])
|
|
688
|
+
expect(sumWithIteration.get()).toBe(90) // 10 + 30 + 50
|
|
689
|
+
expect(sumWithGet.get()).toBe(90)
|
|
690
|
+
|
|
691
|
+
// Set a new array of same size (3 elements)
|
|
692
|
+
numbers.set([100, 200, 300])
|
|
693
|
+
expect(numbers.size.get()).toBe(3)
|
|
694
|
+
expect(numbers.get()).toEqual([100, 200, 300])
|
|
695
|
+
|
|
696
|
+
// Both approaches work correctly with compacted arrays
|
|
697
|
+
expect(sumWithGet.get()).toBe(600) // 100 + 200 + 300
|
|
698
|
+
expect(sumWithIteration.get()).toBe(600) // Both work correctly
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
test('verifies root cause: diff works on array representation but reconcile uses sparse keys', () => {
|
|
702
|
+
// Create a sparse array scenario
|
|
703
|
+
const numbers = store([10, 20, 30])
|
|
704
|
+
|
|
705
|
+
// Remove middle element to create sparse structure
|
|
706
|
+
numbers.remove(1) // Now has keys ["0", "2"] with values [10, 30]
|
|
707
|
+
|
|
708
|
+
// Verify the sparse structure
|
|
709
|
+
expect(numbers.get()).toEqual([10, 30])
|
|
710
|
+
expect(numbers.size.get()).toBe(2)
|
|
711
|
+
|
|
712
|
+
// Now set a new array of same length
|
|
713
|
+
// The diff should see [10, 30] -> [100, 200] as:
|
|
714
|
+
// - index 0: 10 -> 100 (change)
|
|
715
|
+
// - index 1: 30 -> 200 (change)
|
|
716
|
+
// But internally the keys are ["0", "2"], not ["0", "1"]
|
|
717
|
+
numbers.set([100, 200])
|
|
718
|
+
|
|
719
|
+
// With the fix: sparse array replacement now works correctly
|
|
720
|
+
const result = numbers.get()
|
|
721
|
+
|
|
722
|
+
// The fix ensures proper sparse array replacement
|
|
723
|
+
expect(result).toEqual([100, 200]) // This now passes with the diff fix!
|
|
724
|
+
})
|
|
725
|
+
})
|
|
726
|
+
|
|
542
727
|
describe('arrays and edge cases', () => {
|
|
543
728
|
test('handles arrays as store values', () => {
|
|
544
729
|
const data = store({ items: [1, 2, 3] })
|
|
@@ -743,4 +928,762 @@ describe('store', () => {
|
|
|
743
928
|
expect(spread.app.name.get()).toBe('UpdatedApp')
|
|
744
929
|
})
|
|
745
930
|
})
|
|
931
|
+
|
|
932
|
+
describe('JSON integration', () => {
|
|
933
|
+
test('seamless integration with JSON.parse() and JSON.stringify() for API workflows', async () => {
|
|
934
|
+
// Simulate loading data from a JSON API response
|
|
935
|
+
const jsonResponse = `{
|
|
936
|
+
"user": {
|
|
937
|
+
"id": 123,
|
|
938
|
+
"name": "John Doe",
|
|
939
|
+
"email": "john@example.com",
|
|
940
|
+
"preferences": {
|
|
941
|
+
"theme": "dark",
|
|
942
|
+
"notifications": true,
|
|
943
|
+
"language": "en"
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
"settings": {
|
|
947
|
+
"autoSave": true,
|
|
948
|
+
"timeout": 5000
|
|
949
|
+
},
|
|
950
|
+
"tags": ["developer", "javascript", "typescript"]
|
|
951
|
+
}`
|
|
952
|
+
|
|
953
|
+
// Parse JSON and create store - works seamlessly
|
|
954
|
+
const apiData = JSON.parse(jsonResponse)
|
|
955
|
+
const userStore = store<{
|
|
956
|
+
user: {
|
|
957
|
+
id: number
|
|
958
|
+
name: string
|
|
959
|
+
email: string
|
|
960
|
+
preferences: {
|
|
961
|
+
theme: string
|
|
962
|
+
notifications: boolean
|
|
963
|
+
language: string
|
|
964
|
+
fontSize?: number
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
settings: {
|
|
968
|
+
autoSave: boolean
|
|
969
|
+
timeout: number
|
|
970
|
+
}
|
|
971
|
+
tags: string[]
|
|
972
|
+
lastLogin?: Date
|
|
973
|
+
}>(apiData)
|
|
974
|
+
|
|
975
|
+
// Verify initial data is accessible and reactive
|
|
976
|
+
expect(userStore.user.name.get()).toBe('John Doe')
|
|
977
|
+
expect(userStore.user.preferences.theme.get()).toBe('dark')
|
|
978
|
+
expect(userStore.settings.autoSave.get()).toBe(true)
|
|
979
|
+
expect(userStore.get().tags).toEqual([
|
|
980
|
+
'developer',
|
|
981
|
+
'javascript',
|
|
982
|
+
'typescript',
|
|
983
|
+
])
|
|
984
|
+
|
|
985
|
+
// Simulate user interactions - update preferences
|
|
986
|
+
userStore.user.preferences.theme.set('light')
|
|
987
|
+
userStore.user.preferences.notifications.set(false)
|
|
988
|
+
|
|
989
|
+
// Add new preference
|
|
990
|
+
userStore.user.preferences.add('fontSize', 14)
|
|
991
|
+
|
|
992
|
+
// Update settings
|
|
993
|
+
userStore.settings.timeout.set(10000)
|
|
994
|
+
|
|
995
|
+
// Add new top-level property
|
|
996
|
+
userStore.add('lastLogin', new Date('2024-01-15T10:30:00Z'))
|
|
997
|
+
|
|
998
|
+
// Verify changes are reflected
|
|
999
|
+
expect(userStore.user.preferences.theme.get()).toBe('light')
|
|
1000
|
+
expect(userStore.user.preferences.notifications.get()).toBe(false)
|
|
1001
|
+
expect(userStore.settings.timeout.get()).toBe(10000)
|
|
1002
|
+
|
|
1003
|
+
// Get current state and verify it's JSON-serializable
|
|
1004
|
+
const currentState = userStore.get()
|
|
1005
|
+
expect(currentState.user.preferences.theme).toBe('light')
|
|
1006
|
+
expect(currentState.user.preferences.notifications).toBe(false)
|
|
1007
|
+
expect(currentState.settings.timeout).toBe(10000)
|
|
1008
|
+
expect(currentState.tags).toEqual([
|
|
1009
|
+
'developer',
|
|
1010
|
+
'javascript',
|
|
1011
|
+
'typescript',
|
|
1012
|
+
])
|
|
1013
|
+
|
|
1014
|
+
// Convert back to JSON - seamless serialization
|
|
1015
|
+
const jsonPayload = JSON.stringify(currentState)
|
|
1016
|
+
|
|
1017
|
+
// Verify the JSON contains our updates
|
|
1018
|
+
const parsedBack = JSON.parse(jsonPayload)
|
|
1019
|
+
expect(parsedBack.user.preferences.theme).toBe('light')
|
|
1020
|
+
expect(parsedBack.user.preferences.notifications).toBe(false)
|
|
1021
|
+
expect(parsedBack.user.preferences.fontSize).toBe(14)
|
|
1022
|
+
expect(parsedBack.settings.timeout).toBe(10000)
|
|
1023
|
+
expect(parsedBack.lastLogin).toBe('2024-01-15T10:30:00.000Z')
|
|
1024
|
+
|
|
1025
|
+
// Demonstrate update() for bulk changes
|
|
1026
|
+
userStore.update(data => ({
|
|
1027
|
+
...data,
|
|
1028
|
+
user: {
|
|
1029
|
+
...data.user,
|
|
1030
|
+
email: 'john.doe@newcompany.com',
|
|
1031
|
+
preferences: {
|
|
1032
|
+
...data.user.preferences,
|
|
1033
|
+
theme: 'auto',
|
|
1034
|
+
language: 'fr',
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
settings: {
|
|
1038
|
+
...data.settings,
|
|
1039
|
+
autoSave: false,
|
|
1040
|
+
},
|
|
1041
|
+
}))
|
|
1042
|
+
|
|
1043
|
+
// Verify bulk update worked
|
|
1044
|
+
expect(userStore.user.email.get()).toBe('john.doe@newcompany.com')
|
|
1045
|
+
expect(userStore.user.preferences.theme.get()).toBe('auto')
|
|
1046
|
+
expect(userStore.user.preferences.language.get()).toBe('fr')
|
|
1047
|
+
expect(userStore.settings.autoSave.get()).toBe(false)
|
|
1048
|
+
|
|
1049
|
+
// Final JSON serialization for sending to server
|
|
1050
|
+
const finalPayload = JSON.stringify(userStore.get())
|
|
1051
|
+
expect(typeof finalPayload).toBe('string')
|
|
1052
|
+
expect(finalPayload).toContain('john.doe@newcompany.com')
|
|
1053
|
+
expect(finalPayload).toContain('"theme":"auto"')
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
test('handles complex nested structures and arrays from JSON', () => {
|
|
1057
|
+
const complexJson = `{
|
|
1058
|
+
"dashboard": {
|
|
1059
|
+
"widgets": [
|
|
1060
|
+
{"id": 1, "type": "chart", "config": {"color": "blue"}},
|
|
1061
|
+
{"id": 2, "type": "table", "config": {"rows": 10}}
|
|
1062
|
+
],
|
|
1063
|
+
"layout": {
|
|
1064
|
+
"columns": 3,
|
|
1065
|
+
"responsive": true
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
"metadata": {
|
|
1069
|
+
"version": "1.0.0",
|
|
1070
|
+
"created": "2024-01-01T00:00:00Z",
|
|
1071
|
+
"tags": null
|
|
1072
|
+
}
|
|
1073
|
+
}`
|
|
1074
|
+
|
|
1075
|
+
const data = JSON.parse(complexJson)
|
|
1076
|
+
|
|
1077
|
+
// Test that null values in initial JSON are filtered out (treated as UNSET)
|
|
1078
|
+
const dashboardStore = store<{
|
|
1079
|
+
dashboard: {
|
|
1080
|
+
widgets: {
|
|
1081
|
+
id: number
|
|
1082
|
+
type: string
|
|
1083
|
+
config: Record<string, string | number | boolean>
|
|
1084
|
+
}[]
|
|
1085
|
+
layout: {
|
|
1086
|
+
columns: number
|
|
1087
|
+
responsive: boolean
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
metadata: {
|
|
1091
|
+
version: string
|
|
1092
|
+
created: string
|
|
1093
|
+
tags?: string[]
|
|
1094
|
+
}
|
|
1095
|
+
}>(data)
|
|
1096
|
+
|
|
1097
|
+
// Access nested array elements
|
|
1098
|
+
expect(dashboardStore.dashboard.widgets.get()).toHaveLength(2)
|
|
1099
|
+
expect(dashboardStore.dashboard.widgets[0].type.get()).toBe('chart')
|
|
1100
|
+
expect(dashboardStore.dashboard.widgets[1].config.rows.get()).toBe(
|
|
1101
|
+
10,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
// Update array element
|
|
1105
|
+
dashboardStore.set({
|
|
1106
|
+
...dashboardStore.get(),
|
|
1107
|
+
dashboard: {
|
|
1108
|
+
...dashboardStore.dashboard.get(),
|
|
1109
|
+
widgets: [
|
|
1110
|
+
...dashboardStore.dashboard.widgets.get(),
|
|
1111
|
+
{ id: 3, type: 'graph', config: { animate: true } },
|
|
1112
|
+
],
|
|
1113
|
+
},
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
// Verify array update
|
|
1117
|
+
expect(dashboardStore.get().dashboard.widgets).toHaveLength(3)
|
|
1118
|
+
expect(dashboardStore.get().dashboard.widgets[2].type).toBe('graph')
|
|
1119
|
+
|
|
1120
|
+
// Test that individual null additions are still prevented via add()
|
|
1121
|
+
expect(() => {
|
|
1122
|
+
// @ts-expect-error deliberate test case
|
|
1123
|
+
dashboardStore.add('newProp', null)
|
|
1124
|
+
}).toThrow(
|
|
1125
|
+
'Nullish signal values are not allowed in store for key "newProp"',
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
// Test that individual property .set() operations prevent null values
|
|
1129
|
+
expect(() => {
|
|
1130
|
+
dashboardStore.update(data => ({
|
|
1131
|
+
...data,
|
|
1132
|
+
metadata: {
|
|
1133
|
+
...data.metadata,
|
|
1134
|
+
// @ts-expect-error deliberate test case
|
|
1135
|
+
tags: null,
|
|
1136
|
+
},
|
|
1137
|
+
}))
|
|
1138
|
+
}).toThrow(
|
|
1139
|
+
'Nullish signal values are not allowed in store for key "tags"',
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
// Update null to actual value (this should work)
|
|
1143
|
+
dashboardStore.update(data => ({
|
|
1144
|
+
...data,
|
|
1145
|
+
metadata: {
|
|
1146
|
+
...data.metadata,
|
|
1147
|
+
tags: ['production', 'v1'],
|
|
1148
|
+
},
|
|
1149
|
+
}))
|
|
1150
|
+
|
|
1151
|
+
expect(dashboardStore.get().metadata.tags).toEqual([
|
|
1152
|
+
'production',
|
|
1153
|
+
'v1',
|
|
1154
|
+
])
|
|
1155
|
+
|
|
1156
|
+
// Verify JSON round-trip
|
|
1157
|
+
const serialized = JSON.stringify(dashboardStore.get())
|
|
1158
|
+
const reparsed = JSON.parse(serialized)
|
|
1159
|
+
expect(reparsed.dashboard.widgets).toHaveLength(3)
|
|
1160
|
+
expect(reparsed.metadata.tags).toEqual(['production', 'v1'])
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
test('demonstrates real-world form data management', () => {
|
|
1164
|
+
// Simulate form data loaded from API
|
|
1165
|
+
const formData = {
|
|
1166
|
+
profile: {
|
|
1167
|
+
firstName: '',
|
|
1168
|
+
lastName: '',
|
|
1169
|
+
email: '',
|
|
1170
|
+
bio: '',
|
|
1171
|
+
},
|
|
1172
|
+
preferences: {
|
|
1173
|
+
emailNotifications: true,
|
|
1174
|
+
pushNotifications: false,
|
|
1175
|
+
marketing: false,
|
|
1176
|
+
},
|
|
1177
|
+
address: {
|
|
1178
|
+
street: '',
|
|
1179
|
+
city: '',
|
|
1180
|
+
country: 'US',
|
|
1181
|
+
zipCode: '',
|
|
1182
|
+
},
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const formStore = store<{
|
|
1186
|
+
profile: {
|
|
1187
|
+
id?: number
|
|
1188
|
+
createdAt?: string
|
|
1189
|
+
firstName: string
|
|
1190
|
+
lastName: string
|
|
1191
|
+
email: string
|
|
1192
|
+
bio: string
|
|
1193
|
+
}
|
|
1194
|
+
preferences: {
|
|
1195
|
+
emailNotifications: boolean
|
|
1196
|
+
pushNotifications: boolean
|
|
1197
|
+
marketing: boolean
|
|
1198
|
+
}
|
|
1199
|
+
address: {
|
|
1200
|
+
street: string
|
|
1201
|
+
city: string
|
|
1202
|
+
country: string
|
|
1203
|
+
zipCode: string
|
|
1204
|
+
}
|
|
1205
|
+
}>(formData)
|
|
1206
|
+
|
|
1207
|
+
// Simulate user filling out form
|
|
1208
|
+
formStore.profile.firstName.set('Jane')
|
|
1209
|
+
formStore.profile.lastName.set('Smith')
|
|
1210
|
+
formStore.profile.email.set('jane.smith@example.com')
|
|
1211
|
+
formStore.profile.bio.set(
|
|
1212
|
+
'Full-stack developer with 5 years experience',
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
// Update address
|
|
1216
|
+
formStore.address.street.set('123 Main St')
|
|
1217
|
+
formStore.address.city.set('San Francisco')
|
|
1218
|
+
formStore.address.zipCode.set('94105')
|
|
1219
|
+
|
|
1220
|
+
// Toggle preferences
|
|
1221
|
+
formStore.preferences.pushNotifications.set(true)
|
|
1222
|
+
formStore.preferences.marketing.set(true)
|
|
1223
|
+
|
|
1224
|
+
// Get form data for submission - ready for JSON.stringify
|
|
1225
|
+
const submissionData = formStore.get()
|
|
1226
|
+
|
|
1227
|
+
expect(submissionData.profile.firstName).toBe('Jane')
|
|
1228
|
+
expect(submissionData.profile.email).toBe('jane.smith@example.com')
|
|
1229
|
+
expect(submissionData.address.city).toBe('San Francisco')
|
|
1230
|
+
expect(submissionData.preferences.pushNotifications).toBe(true)
|
|
1231
|
+
|
|
1232
|
+
// Simulate sending to API
|
|
1233
|
+
const jsonPayload = JSON.stringify(submissionData)
|
|
1234
|
+
expect(jsonPayload).toContain('jane.smith@example.com')
|
|
1235
|
+
expect(jsonPayload).toContain('San Francisco')
|
|
1236
|
+
|
|
1237
|
+
// Simulate receiving updated data back from server
|
|
1238
|
+
const serverResponse = {
|
|
1239
|
+
...submissionData,
|
|
1240
|
+
profile: {
|
|
1241
|
+
...submissionData.profile,
|
|
1242
|
+
id: 456,
|
|
1243
|
+
createdAt: '2024-01-15T12:00:00Z',
|
|
1244
|
+
},
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Update store with server response
|
|
1248
|
+
formStore.set(serverResponse)
|
|
1249
|
+
|
|
1250
|
+
// Verify server data is integrated
|
|
1251
|
+
expect(formStore.profile.id?.get()).toBe(456)
|
|
1252
|
+
expect(formStore.profile.createdAt?.get()).toBe(
|
|
1253
|
+
'2024-01-15T12:00:00Z',
|
|
1254
|
+
)
|
|
1255
|
+
expect(formStore.get().profile.firstName).toBe('Jane') // Original data preserved
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
describe('Symbol.isConcatSpreadable and polymorphic behavior', () => {
|
|
1259
|
+
test('array-like stores have Symbol.isConcatSpreadable true and length property', () => {
|
|
1260
|
+
const numbers = store([1, 2, 3])
|
|
1261
|
+
|
|
1262
|
+
// Should be concat spreadable
|
|
1263
|
+
expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
|
|
1264
|
+
|
|
1265
|
+
// Should have length property
|
|
1266
|
+
expect(numbers.length).toBe(3)
|
|
1267
|
+
expect(typeof numbers.length).toBe('number')
|
|
1268
|
+
|
|
1269
|
+
// Add an item and verify length updates
|
|
1270
|
+
numbers.add(4)
|
|
1271
|
+
expect(numbers.length).toBe(4)
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
test('object-like stores have Symbol.isConcatSpreadable false and no length property', () => {
|
|
1275
|
+
const user = store({ name: 'John', age: 25 })
|
|
1276
|
+
|
|
1277
|
+
// Should not be concat spreadable
|
|
1278
|
+
expect(user[Symbol.isConcatSpreadable]).toBe(false)
|
|
1279
|
+
|
|
1280
|
+
// Should not have length property
|
|
1281
|
+
// @ts-expect-error deliberately accessing non-existent length property
|
|
1282
|
+
expect(user.length).toBeUndefined()
|
|
1283
|
+
expect('length' in user).toBe(false)
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
test('array-like stores iterate over signals only', () => {
|
|
1287
|
+
const numbers = store([10, 20, 30])
|
|
1288
|
+
const signals = [...numbers]
|
|
1289
|
+
|
|
1290
|
+
// Should yield signals, not [key, signal] pairs
|
|
1291
|
+
expect(signals).toHaveLength(3)
|
|
1292
|
+
expect(signals[0].get()).toBe(10)
|
|
1293
|
+
expect(signals[1].get()).toBe(20)
|
|
1294
|
+
expect(signals[2].get()).toBe(30)
|
|
1295
|
+
|
|
1296
|
+
// Verify they are signal objects
|
|
1297
|
+
signals.forEach(signal => {
|
|
1298
|
+
expect(typeof signal.get).toBe('function')
|
|
1299
|
+
})
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
test('object-like stores iterate over [key, signal] pairs', () => {
|
|
1303
|
+
const user = store({ name: 'Alice', age: 30 })
|
|
1304
|
+
const entries = [...user]
|
|
1305
|
+
|
|
1306
|
+
// Should yield [key, signal] pairs
|
|
1307
|
+
expect(entries).toHaveLength(2)
|
|
1308
|
+
|
|
1309
|
+
// Find the name entry
|
|
1310
|
+
const nameEntry = entries.find(([key]) => key === 'name')
|
|
1311
|
+
expect(nameEntry).toBeDefined()
|
|
1312
|
+
expect(nameEntry?.[0]).toBe('name')
|
|
1313
|
+
expect(nameEntry?.[1].get()).toBe('Alice')
|
|
1314
|
+
|
|
1315
|
+
// Find the age entry
|
|
1316
|
+
const ageEntry = entries.find(([key]) => key === 'age')
|
|
1317
|
+
expect(ageEntry).toBeDefined()
|
|
1318
|
+
expect(ageEntry?.[0]).toBe('age')
|
|
1319
|
+
expect(ageEntry?.[1].get()).toBe(30)
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
test('array-like stores support single-parameter add() method', () => {
|
|
1323
|
+
const fruits = store(['apple', 'banana'])
|
|
1324
|
+
|
|
1325
|
+
// Should add to end without specifying key
|
|
1326
|
+
fruits.add('cherry')
|
|
1327
|
+
|
|
1328
|
+
const result = fruits.get()
|
|
1329
|
+
expect(result).toEqual(['apple', 'banana', 'cherry'])
|
|
1330
|
+
expect(fruits.length).toBe(3)
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
test('object-like stores require key parameter for add() method', () => {
|
|
1334
|
+
const config = store<{ debug: boolean; timeout?: number }>({
|
|
1335
|
+
debug: true,
|
|
1336
|
+
})
|
|
1337
|
+
|
|
1338
|
+
// Should require both key and value
|
|
1339
|
+
config.add('timeout', 5000)
|
|
1340
|
+
|
|
1341
|
+
expect(config.get()).toEqual({ debug: true, timeout: 5000 })
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
test('concat works correctly with array-like stores', () => {
|
|
1345
|
+
const numbers = store([2, 3])
|
|
1346
|
+
const prefix = [state(1)]
|
|
1347
|
+
const suffix = [state(4), state(5)]
|
|
1348
|
+
|
|
1349
|
+
// Should spread signals when concat-ed
|
|
1350
|
+
const combined = prefix.concat(
|
|
1351
|
+
numbers as unknown as ConcatArray<State<number>>,
|
|
1352
|
+
suffix,
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
expect(combined).toHaveLength(5)
|
|
1356
|
+
expect(combined[0].get()).toBe(1)
|
|
1357
|
+
expect(combined[1].get()).toBe(2) // from store
|
|
1358
|
+
expect(combined[2].get()).toBe(3) // from store
|
|
1359
|
+
expect(combined[3].get()).toBe(4)
|
|
1360
|
+
expect(combined[4].get()).toBe(5)
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
test('spread operator works correctly with array-like stores', () => {
|
|
1364
|
+
const numbers = store([10, 20])
|
|
1365
|
+
|
|
1366
|
+
// Should spread signals
|
|
1367
|
+
const spread = [state(5), ...numbers, state(30)]
|
|
1368
|
+
|
|
1369
|
+
expect(spread).toHaveLength(4)
|
|
1370
|
+
expect(spread[0].get()).toBe(5)
|
|
1371
|
+
expect(spread[1].get()).toBe(10) // from store
|
|
1372
|
+
expect(spread[2].get()).toBe(20) // from store
|
|
1373
|
+
expect(spread[3].get()).toBe(30)
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
test('array-like stores maintain numeric key ordering', () => {
|
|
1377
|
+
const items = store(['first', 'second', 'third'])
|
|
1378
|
+
|
|
1379
|
+
// Get the keys
|
|
1380
|
+
const keys = Object.keys(items)
|
|
1381
|
+
expect(keys).toEqual(['0', '1', '2', 'length'])
|
|
1382
|
+
|
|
1383
|
+
// Iteration should be in order
|
|
1384
|
+
const signals = [...items]
|
|
1385
|
+
expect(signals[0].get()).toBe('first')
|
|
1386
|
+
expect(signals[1].get()).toBe('second')
|
|
1387
|
+
expect(signals[2].get()).toBe('third')
|
|
1388
|
+
})
|
|
1389
|
+
|
|
1390
|
+
test('polymorphic behavior is determined at creation time', () => {
|
|
1391
|
+
// Created as array - stays array-like
|
|
1392
|
+
const arrayStore = store([1, 2])
|
|
1393
|
+
expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
|
|
1394
|
+
expect(arrayStore.length).toBe(2)
|
|
1395
|
+
|
|
1396
|
+
// Created as object - stays object-like
|
|
1397
|
+
const objectStore = store<{ a: number; b: number; c?: number }>(
|
|
1398
|
+
{
|
|
1399
|
+
a: 1,
|
|
1400
|
+
b: 2,
|
|
1401
|
+
},
|
|
1402
|
+
)
|
|
1403
|
+
expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
|
|
1404
|
+
// @ts-expect-error deliberate access to non-existent length property
|
|
1405
|
+
expect(objectStore.length).toBeUndefined()
|
|
1406
|
+
|
|
1407
|
+
// Even after modifications, behavior doesn't change
|
|
1408
|
+
arrayStore.add(3)
|
|
1409
|
+
expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
|
|
1410
|
+
|
|
1411
|
+
objectStore.add('c', 3)
|
|
1412
|
+
expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
|
|
1413
|
+
})
|
|
1414
|
+
|
|
1415
|
+
test('runtime type detection using typeof length', () => {
|
|
1416
|
+
const arrayStore = store([1, 2, 3])
|
|
1417
|
+
const objectStore = store({ x: 1, y: 2 })
|
|
1418
|
+
|
|
1419
|
+
// Can distinguish at runtime
|
|
1420
|
+
expect(typeof arrayStore.length === 'number').toBe(true)
|
|
1421
|
+
// @ts-expect-error deliberately accessing non-existent length property
|
|
1422
|
+
expect(typeof objectStore.length === 'number').toBe(false)
|
|
1423
|
+
})
|
|
1424
|
+
|
|
1425
|
+
test('empty stores behave correctly', () => {
|
|
1426
|
+
const emptyArray = store([])
|
|
1427
|
+
const emptyObject = store({})
|
|
1428
|
+
|
|
1429
|
+
// Empty array store
|
|
1430
|
+
expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
|
|
1431
|
+
expect(emptyArray.length).toBe(0)
|
|
1432
|
+
expect([...emptyArray]).toEqual([])
|
|
1433
|
+
|
|
1434
|
+
// Empty object store
|
|
1435
|
+
expect(emptyObject[Symbol.isConcatSpreadable]).toBe(false)
|
|
1436
|
+
// @ts-expect-error deliberately accessing non-existent length property
|
|
1437
|
+
expect(emptyObject.length).toBeUndefined()
|
|
1438
|
+
expect([...emptyObject]).toEqual([])
|
|
1439
|
+
})
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
test('debug length property issue', () => {
|
|
1443
|
+
const numbers = store([1, 2, 3])
|
|
1444
|
+
|
|
1445
|
+
// Test length in computed context
|
|
1446
|
+
const lengthComputed = computed(() => numbers.length)
|
|
1447
|
+
numbers.add(4)
|
|
1448
|
+
|
|
1449
|
+
// Test if length property is actually reactive
|
|
1450
|
+
expect(numbers.length).toBe(4)
|
|
1451
|
+
expect(lengthComputed.get()).toBe(4)
|
|
1452
|
+
})
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
describe('sort() method', () => {
|
|
1456
|
+
test('sorts array-like store with numeric compareFn', () => {
|
|
1457
|
+
const numbers = store([3, 1, 4, 1, 5])
|
|
1458
|
+
|
|
1459
|
+
// Capture old signal references
|
|
1460
|
+
const oldSignals = [
|
|
1461
|
+
numbers[0],
|
|
1462
|
+
numbers[1],
|
|
1463
|
+
numbers[2],
|
|
1464
|
+
numbers[3],
|
|
1465
|
+
numbers[4],
|
|
1466
|
+
]
|
|
1467
|
+
|
|
1468
|
+
numbers.sort((a, b) => a - b)
|
|
1469
|
+
|
|
1470
|
+
// Check sorted order
|
|
1471
|
+
expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
|
|
1472
|
+
|
|
1473
|
+
// Verify signal references are preserved (moved, not recreated)
|
|
1474
|
+
expect(numbers[0]).toBe(oldSignals[1]) // first 1 was at index 1
|
|
1475
|
+
expect(numbers[1]).toBe(oldSignals[3]) // second 1 was at index 3
|
|
1476
|
+
expect(numbers[2]).toBe(oldSignals[0]) // 3 was at index 0
|
|
1477
|
+
expect(numbers[3]).toBe(oldSignals[2]) // 4 was at index 2
|
|
1478
|
+
expect(numbers[4]).toBe(oldSignals[4]) // 5 was at index 4
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
test('sorts array-like store with string compareFn', () => {
|
|
1482
|
+
const names = store(['Charlie', 'Alice', 'Bob'])
|
|
1483
|
+
|
|
1484
|
+
names.sort((a, b) => a.localeCompare(b))
|
|
1485
|
+
|
|
1486
|
+
expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
|
|
1487
|
+
})
|
|
1488
|
+
|
|
1489
|
+
test('sorts record-like store by value', () => {
|
|
1490
|
+
const users = store({
|
|
1491
|
+
user1: { name: 'Charlie', age: 25 },
|
|
1492
|
+
user2: { name: 'Alice', age: 30 },
|
|
1493
|
+
user3: { name: 'Bob', age: 20 },
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
// Capture old signal references
|
|
1497
|
+
const oldSignals = {
|
|
1498
|
+
user1: users.user1,
|
|
1499
|
+
user2: users.user2,
|
|
1500
|
+
user3: users.user3,
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Sort by age
|
|
1504
|
+
users.sort((a, b) => a.age - b.age)
|
|
1505
|
+
|
|
1506
|
+
// Check order via iteration
|
|
1507
|
+
const keys = Array.from(users, ([key]) => key)
|
|
1508
|
+
expect(keys).toEqual(['user3', 'user1', 'user2'])
|
|
1509
|
+
|
|
1510
|
+
// Verify signal references are preserved
|
|
1511
|
+
expect(users.user1).toBe(oldSignals.user1)
|
|
1512
|
+
expect(users.user2).toBe(oldSignals.user2)
|
|
1513
|
+
expect(users.user3).toBe(oldSignals.user3)
|
|
1514
|
+
})
|
|
1515
|
+
|
|
1516
|
+
test('emits store-sort event with new order', () => {
|
|
1517
|
+
const numbers = store([30, 10, 20])
|
|
1518
|
+
let sortEvent: StoreSortEvent | null = null
|
|
1519
|
+
|
|
1520
|
+
numbers.addEventListener('store-sort', event => {
|
|
1521
|
+
sortEvent = event
|
|
1522
|
+
})
|
|
1523
|
+
|
|
1524
|
+
numbers.sort((a, b) => a - b)
|
|
1525
|
+
|
|
1526
|
+
expect(sortEvent).not.toBeNull()
|
|
1527
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1528
|
+
expect(sortEvent!.type).toBe('store-sort')
|
|
1529
|
+
// Keys in new sorted order: [10, 20, 30] came from indices [1, 2, 0]
|
|
1530
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1531
|
+
expect(sortEvent!.detail).toEqual(['1', '2', '0'])
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
test('sort is reactive - watchers are notified', () => {
|
|
1535
|
+
const numbers = store([3, 1, 2])
|
|
1536
|
+
let effectCount = 0
|
|
1537
|
+
let lastValue: number[] = []
|
|
1538
|
+
|
|
1539
|
+
effect(() => {
|
|
1540
|
+
lastValue = numbers.get()
|
|
1541
|
+
effectCount++
|
|
1542
|
+
})
|
|
1543
|
+
|
|
1544
|
+
// Initial effect run
|
|
1545
|
+
expect(effectCount).toBe(1)
|
|
1546
|
+
expect(lastValue).toEqual([3, 1, 2])
|
|
1547
|
+
|
|
1548
|
+
numbers.sort((a, b) => a - b)
|
|
1549
|
+
|
|
1550
|
+
// Effect should run again after sort
|
|
1551
|
+
expect(effectCount).toBe(2)
|
|
1552
|
+
expect(lastValue).toEqual([1, 2, 3])
|
|
1553
|
+
})
|
|
1554
|
+
|
|
1555
|
+
test('nested signals remain reactive after sorting', () => {
|
|
1556
|
+
const items = store([
|
|
1557
|
+
{ name: 'Charlie', score: 85 },
|
|
1558
|
+
{ name: 'Alice', score: 95 },
|
|
1559
|
+
{ name: 'Bob', score: 75 },
|
|
1560
|
+
])
|
|
1561
|
+
|
|
1562
|
+
// Sort by score
|
|
1563
|
+
items.sort((a, b) => b.score - a.score) // descending
|
|
1564
|
+
|
|
1565
|
+
// Verify order
|
|
1566
|
+
expect(items.get().map(item => item.name)).toEqual([
|
|
1567
|
+
'Alice',
|
|
1568
|
+
'Charlie',
|
|
1569
|
+
'Bob',
|
|
1570
|
+
])
|
|
1571
|
+
|
|
1572
|
+
// Modify a nested property
|
|
1573
|
+
items[1].score.set(100) // Charlie's score
|
|
1574
|
+
|
|
1575
|
+
// Verify the change is reflected
|
|
1576
|
+
expect(items.get()[1].score).toBe(100)
|
|
1577
|
+
expect(items[1].name.get()).toBe('Charlie')
|
|
1578
|
+
})
|
|
1579
|
+
|
|
1580
|
+
test('sort with complex nested structures', () => {
|
|
1581
|
+
const posts = store([
|
|
1582
|
+
{
|
|
1583
|
+
id: 'post1',
|
|
1584
|
+
title: 'Hello World',
|
|
1585
|
+
meta: { views: 100, likes: 5 },
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
id: 'post2',
|
|
1589
|
+
title: 'Getting Started',
|
|
1590
|
+
meta: { views: 50, likes: 10 },
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
id: 'post3',
|
|
1594
|
+
title: 'Advanced Topics',
|
|
1595
|
+
meta: { views: 200, likes: 3 },
|
|
1596
|
+
},
|
|
1597
|
+
])
|
|
1598
|
+
|
|
1599
|
+
// Sort by likes (ascending)
|
|
1600
|
+
posts.sort((a, b) => a.meta.likes - b.meta.likes)
|
|
1601
|
+
|
|
1602
|
+
const sortedTitles = posts.get().map(post => post.title)
|
|
1603
|
+
expect(sortedTitles).toEqual([
|
|
1604
|
+
'Advanced Topics',
|
|
1605
|
+
'Hello World',
|
|
1606
|
+
'Getting Started',
|
|
1607
|
+
])
|
|
1608
|
+
|
|
1609
|
+
// Verify nested reactivity still works
|
|
1610
|
+
posts[0].meta.likes.set(15)
|
|
1611
|
+
expect(posts.get()[0].meta.likes).toBe(15)
|
|
1612
|
+
})
|
|
1613
|
+
|
|
1614
|
+
test('sort preserves array length and size', () => {
|
|
1615
|
+
const arr = store([5, 2, 8, 1])
|
|
1616
|
+
|
|
1617
|
+
expect(arr.length).toBe(4)
|
|
1618
|
+
expect(arr.size.get()).toBe(4)
|
|
1619
|
+
|
|
1620
|
+
arr.sort((a, b) => a - b)
|
|
1621
|
+
|
|
1622
|
+
expect(arr.length).toBe(4)
|
|
1623
|
+
expect(arr.size.get()).toBe(4)
|
|
1624
|
+
expect(arr.get()).toEqual([1, 2, 5, 8])
|
|
1625
|
+
})
|
|
1626
|
+
|
|
1627
|
+
test('sort with no compareFn uses default string sorting like Array.prototype.sort()', () => {
|
|
1628
|
+
const items = store(['banana', 'cherry', 'apple', '10', '2'])
|
|
1629
|
+
|
|
1630
|
+
items.sort()
|
|
1631
|
+
|
|
1632
|
+
// Default sorting converts to strings and compares in UTF-16 order
|
|
1633
|
+
expect(items.get()).toEqual(
|
|
1634
|
+
['banana', 'cherry', 'apple', '10', '2'].sort(),
|
|
1635
|
+
)
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1638
|
+
test('default sort handles numbers as strings like Array.prototype.sort()', () => {
|
|
1639
|
+
const numbers = store([80, 9, 100])
|
|
1640
|
+
|
|
1641
|
+
numbers.sort()
|
|
1642
|
+
|
|
1643
|
+
// Numbers are converted to strings: "100", "80", "9"
|
|
1644
|
+
// In UTF-16 order: "100" < "80" < "9"
|
|
1645
|
+
expect(numbers.get()).toEqual([80, 9, 100].sort())
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1648
|
+
test('default sort handles mixed values with proper string conversion', () => {
|
|
1649
|
+
const mixed = store(['b', 0, 'a', '', 'c'])
|
|
1650
|
+
|
|
1651
|
+
mixed.sort()
|
|
1652
|
+
|
|
1653
|
+
// String conversion: '' < '0' < 'a' < 'b' < 'c'
|
|
1654
|
+
expect(mixed.get()).toEqual(['', 0, 'a', 'b', 'c'])
|
|
1655
|
+
})
|
|
1656
|
+
|
|
1657
|
+
test('multiple sorts work correctly', () => {
|
|
1658
|
+
const numbers = store([3, 1, 4, 1, 5])
|
|
1659
|
+
|
|
1660
|
+
// Sort ascending
|
|
1661
|
+
numbers.sort((a, b) => a - b)
|
|
1662
|
+
expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
|
|
1663
|
+
|
|
1664
|
+
// Sort descending
|
|
1665
|
+
numbers.sort((a, b) => b - a)
|
|
1666
|
+
expect(numbers.get()).toEqual([5, 4, 3, 1, 1])
|
|
1667
|
+
})
|
|
1668
|
+
|
|
1669
|
+
test('sort event contains correct movement mapping for records', () => {
|
|
1670
|
+
const users = store({
|
|
1671
|
+
alice: { age: 30 },
|
|
1672
|
+
bob: { age: 20 },
|
|
1673
|
+
charlie: { age: 25 },
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
let sortEvent: StoreSortEvent | null = null
|
|
1677
|
+
users.addEventListener('store-sort', event => {
|
|
1678
|
+
sortEvent = event
|
|
1679
|
+
})
|
|
1680
|
+
|
|
1681
|
+
// Sort by age
|
|
1682
|
+
users.sort((a, b) => b.age - a.age)
|
|
1683
|
+
|
|
1684
|
+
expect(sortEvent).not.toBeNull()
|
|
1685
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1686
|
+
expect(sortEvent!.detail).toEqual(['alice', 'charlie', 'bob'])
|
|
1687
|
+
})
|
|
1688
|
+
})
|
|
746
1689
|
})
|