|
| 1 | +import { expecter } from 'ts-snippet'; |
| 2 | +import { compilerOptions } from './helpers'; |
| 3 | + |
| 4 | +describe('signalState', () => { |
| 5 | + const expectSnippet = expecter( |
| 6 | + (code) => ` |
| 7 | + import { patchState, signalState } from '@ngrx/signals'; |
| 8 | +
|
| 9 | + const initialState = { |
| 10 | + user: { |
| 11 | + age: 30, |
| 12 | + details: { |
| 13 | + first: 'John', |
| 14 | + last: 'Smith', |
| 15 | + }, |
| 16 | + address: ['Belgrade', 'Serbia'], |
| 17 | + }, |
| 18 | + numbers: [1, 2, 3], |
| 19 | + ngrx: 'rocks', |
| 20 | + }; |
| 21 | +
|
| 22 | + ${code} |
| 23 | + `, |
| 24 | + compilerOptions() |
| 25 | + ); |
| 26 | + |
| 27 | + it('allows passing state as a generic argument', () => { |
| 28 | + const snippet = ` |
| 29 | + type FooState = { foo: string; bar: number }; |
| 30 | + const state = signalState<FooState>({ foo: 'bar', bar: 1 }); |
| 31 | + `; |
| 32 | + |
| 33 | + expectSnippet(snippet).toSucceed(); |
| 34 | + |
| 35 | + expectSnippet(snippet).toInfer('state', 'SignalState<FooState>'); |
| 36 | + }); |
| 37 | + |
| 38 | + it('creates deep signals for nested state slices', () => { |
| 39 | + const snippet = ` |
| 40 | + const state = signalState(initialState); |
| 41 | +
|
| 42 | + const user = state.user; |
| 43 | + const age = state.user.age; |
| 44 | + const details = state.user.details; |
| 45 | + const first = state.user.details.first; |
| 46 | + const last = state.user.details.last; |
| 47 | + const address = state.user.address; |
| 48 | + const numbers = state.numbers; |
| 49 | + const ngrx = state.ngrx; |
| 50 | + `; |
| 51 | + |
| 52 | + expectSnippet(snippet).toSucceed(); |
| 53 | + |
| 54 | + expectSnippet(snippet).toInfer( |
| 55 | + 'state', |
| 56 | + 'SignalState<{ user: { age: number; details: { first: string; last: string; }; address: string[]; }; numbers: number[]; ngrx: string; }>' |
| 57 | + ); |
| 58 | + |
| 59 | + expectSnippet(snippet).toInfer( |
| 60 | + 'user', |
| 61 | + 'DeepSignal<{ age: number; details: { first: string; last: string; }; address: string[]; }>' |
| 62 | + ); |
| 63 | + |
| 64 | + expectSnippet(snippet).toInfer( |
| 65 | + 'details', |
| 66 | + 'DeepSignal<{ first: string; last: string; }>' |
| 67 | + ); |
| 68 | + |
| 69 | + expectSnippet(snippet).toInfer('first', 'Signal<string>'); |
| 70 | + |
| 71 | + expectSnippet(snippet).toInfer('last', 'Signal<string>'); |
| 72 | + |
| 73 | + expectSnippet(snippet).toInfer('address', 'Signal<string[]>'); |
| 74 | + |
| 75 | + expectSnippet(snippet).toInfer('numbers', 'Signal<number[]>'); |
| 76 | + |
| 77 | + expectSnippet(snippet).toInfer('ngrx', 'Signal<string>'); |
| 78 | + }); |
| 79 | + |
| 80 | + it('does not create deep signals when state slice type is an interface', () => { |
| 81 | + const snippet = ` |
| 82 | + interface User { |
| 83 | + firstName: string; |
| 84 | + lastName: string; |
| 85 | + } |
| 86 | +
|
| 87 | + type State = { user: User }; |
| 88 | +
|
| 89 | + const state = signalState<State>({ user: { firstName: 'John', lastName: 'Smith' } }); |
| 90 | + const user = state.user; |
| 91 | + `; |
| 92 | + |
| 93 | + expectSnippet(snippet).toSucceed(); |
| 94 | + |
| 95 | + expectSnippet(snippet).toInfer('user', 'Signal<User>'); |
| 96 | + }); |
| 97 | + |
| 98 | + it('does not create deep signals for optional state slices', () => { |
| 99 | + const snippet = ` |
| 100 | + type State = { |
| 101 | + foo?: string; |
| 102 | + bar: { baz?: number }; |
| 103 | + x?: { y: { z?: boolean } }; |
| 104 | + }; |
| 105 | +
|
| 106 | + const state = signalState<State>({ bar: {} }); |
| 107 | + const foo = state.foo; |
| 108 | + const bar = state.bar; |
| 109 | + const baz = state.bar.baz; |
| 110 | + const x = state.x; |
| 111 | + `; |
| 112 | + |
| 113 | + expectSnippet(snippet).toSucceed(); |
| 114 | + |
| 115 | + expectSnippet(snippet).toInfer('state', 'SignalState<State>'); |
| 116 | + |
| 117 | + expectSnippet(snippet).toInfer( |
| 118 | + 'foo', |
| 119 | + 'Signal<string | undefined> | undefined' |
| 120 | + ); |
| 121 | + |
| 122 | + expectSnippet(snippet).toInfer( |
| 123 | + 'bar', |
| 124 | + 'DeepSignal<{ baz?: number | undefined; }>' |
| 125 | + ); |
| 126 | + |
| 127 | + expectSnippet(snippet).toInfer( |
| 128 | + 'baz', |
| 129 | + 'Signal<number | undefined> | undefined' |
| 130 | + ); |
| 131 | + |
| 132 | + expectSnippet(snippet).toInfer( |
| 133 | + 'x', |
| 134 | + 'Signal<{ y: { z?: boolean | undefined; }; } | undefined> | undefined' |
| 135 | + ); |
| 136 | + }); |
| 137 | + |
| 138 | + it('does not create deep signals for unknown records', () => { |
| 139 | + const snippet = ` |
| 140 | + const state1 = signalState<{ [key: string]: number }>({}); |
| 141 | + declare const state1Keys: keyof typeof state1; |
| 142 | +
|
| 143 | + const state2 = signalState<{ [key: number]: { foo: string } }>({ |
| 144 | + 1: { foo: 'bar' }, |
| 145 | + }); |
| 146 | + declare const state2Keys: keyof typeof state2; |
| 147 | +
|
| 148 | + const state3 = signalState<Record<string, { bar: number }>>({}); |
| 149 | + declare const state3Keys: keyof typeof state3; |
| 150 | +
|
| 151 | + const state4 = signalState({ |
| 152 | + foo: {} as Record<string, { bar: boolean } | number>, |
| 153 | + }); |
| 154 | + const foo = state4.foo; |
| 155 | +
|
| 156 | + const state5 = signalState({ |
| 157 | + bar: { baz: {} as Record<number, unknown> } |
| 158 | + }); |
| 159 | + const bar = state5.bar; |
| 160 | + const baz = bar.baz; |
| 161 | + `; |
| 162 | + |
| 163 | + expectSnippet(snippet).toSucceed(); |
| 164 | + |
| 165 | + expectSnippet(snippet).toInfer( |
| 166 | + 'state1', |
| 167 | + 'SignalState<{ [key: string]: number; }>' |
| 168 | + ); |
| 169 | + |
| 170 | + expectSnippet(snippet).toInfer( |
| 171 | + 'state1Keys', |
| 172 | + 'unique symbol | unique symbol' |
| 173 | + ); |
| 174 | + |
| 175 | + expectSnippet(snippet).toInfer( |
| 176 | + 'state2', |
| 177 | + 'SignalState<{ [key: number]: { foo: string; }; }>' |
| 178 | + ); |
| 179 | + |
| 180 | + expectSnippet(snippet).toInfer( |
| 181 | + 'state2Keys', |
| 182 | + 'unique symbol | unique symbol' |
| 183 | + ); |
| 184 | + |
| 185 | + expectSnippet(snippet).toInfer( |
| 186 | + 'state3', |
| 187 | + 'SignalState<Record<string, { bar: number; }>>' |
| 188 | + ); |
| 189 | + |
| 190 | + expectSnippet(snippet).toInfer( |
| 191 | + 'state3Keys', |
| 192 | + 'unique symbol | unique symbol' |
| 193 | + ); |
| 194 | + |
| 195 | + expectSnippet(snippet).toInfer( |
| 196 | + 'state4', |
| 197 | + 'SignalState<{ foo: Record<string, number | { bar: boolean; }>; }>' |
| 198 | + ); |
| 199 | + |
| 200 | + expectSnippet(snippet).toInfer( |
| 201 | + 'foo', |
| 202 | + 'Signal<Record<string, number | { bar: boolean; }>>' |
| 203 | + ); |
| 204 | + |
| 205 | + expectSnippet(snippet).toInfer( |
| 206 | + 'state5', |
| 207 | + 'SignalState<{ bar: { baz: Record<number, unknown>; }; }>' |
| 208 | + ); |
| 209 | + |
| 210 | + expectSnippet(snippet).toInfer( |
| 211 | + 'bar', |
| 212 | + 'DeepSignal<{ baz: Record<number, unknown>; }>' |
| 213 | + ); |
| 214 | + |
| 215 | + expectSnippet(snippet).toInfer('baz', 'Signal<Record<number, unknown>>'); |
| 216 | + }); |
| 217 | + |
| 218 | + it('succeeds when state is an empty object', () => { |
| 219 | + const snippet = `const state = signalState({})`; |
| 220 | + |
| 221 | + expectSnippet(snippet).toSucceed(); |
| 222 | + |
| 223 | + expectSnippet(snippet).toInfer('state', 'SignalState<{}>'); |
| 224 | + }); |
| 225 | + |
| 226 | + it('succeeds when state slices are union types', () => { |
| 227 | + const snippet = ` |
| 228 | + type State = { |
| 229 | + foo: { s: string } | number; |
| 230 | + bar: { baz: { n: number } | null }; |
| 231 | + x: { y: { z: boolean | undefined } }; |
| 232 | + }; |
| 233 | +
|
| 234 | + const state = signalState<State>({ |
| 235 | + foo: { s: 's' }, |
| 236 | + bar: { baz: null }, |
| 237 | + x: { y: { z: undefined } }, |
| 238 | + }); |
| 239 | + const foo = state.foo; |
| 240 | + const bar = state.bar; |
| 241 | + const baz = state.bar.baz; |
| 242 | + const x = state.x; |
| 243 | + const y = state.x.y; |
| 244 | + const z = state.x.y.z; |
| 245 | + `; |
| 246 | + |
| 247 | + expectSnippet(snippet).toSucceed(); |
| 248 | + |
| 249 | + expectSnippet(snippet).toInfer('state', 'SignalState<State>'); |
| 250 | + |
| 251 | + expectSnippet(snippet).toInfer('foo', 'Signal<number | { s: string; }>'); |
| 252 | + |
| 253 | + expectSnippet(snippet).toInfer( |
| 254 | + 'bar', |
| 255 | + 'DeepSignal<{ baz: { n: number; } | null; }>' |
| 256 | + ); |
| 257 | + |
| 258 | + expectSnippet(snippet).toInfer('baz', 'Signal<{ n: number; } | null>'); |
| 259 | + |
| 260 | + expectSnippet(snippet).toInfer( |
| 261 | + 'x', |
| 262 | + 'DeepSignal<{ y: { z: boolean | undefined; }; }>' |
| 263 | + ); |
| 264 | + |
| 265 | + expectSnippet(snippet).toInfer( |
| 266 | + 'y', |
| 267 | + 'DeepSignal<{ z: boolean | undefined; }>' |
| 268 | + ); |
| 269 | + |
| 270 | + expectSnippet(snippet).toInfer('z', 'Signal<boolean | undefined>'); |
| 271 | + }); |
| 272 | + |
| 273 | + it('fails when state contains Function properties', () => { |
| 274 | + expectSnippet(`const state = signalState({ name: '' })`).toFail( |
| 275 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 276 | + ); |
| 277 | + |
| 278 | + expectSnippet( |
| 279 | + `const state = signalState({ foo: { arguments: [] } })` |
| 280 | + ).toFail( |
| 281 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 282 | + ); |
| 283 | + |
| 284 | + expectSnippet(` |
| 285 | + type State = { foo: { bar: { call?: boolean }; baz: number } }; |
| 286 | + const state = signalState<State>({ foo: { bar: {}, baz: 1 } }); |
| 287 | + `).toFail( |
| 288 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 289 | + ); |
| 290 | + |
| 291 | + expectSnippet( |
| 292 | + `const state = signalState({ foo: { apply: 'apply', bar: true } })` |
| 293 | + ).toFail( |
| 294 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 295 | + ); |
| 296 | + |
| 297 | + expectSnippet(` |
| 298 | + type State = { bind?: { foo: string } }; |
| 299 | + const state = signalState<State>({ bind: { foo: 'bar' } }); |
| 300 | + `).toFail( |
| 301 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 302 | + ); |
| 303 | + |
| 304 | + expectSnippet( |
| 305 | + `const state = signalState({ foo: { bar: { prototype: [] }; baz: 1 } })` |
| 306 | + ).toFail( |
| 307 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 308 | + ); |
| 309 | + |
| 310 | + expectSnippet(`const state = signalState({ foo: { length: 10 } })`).toFail( |
| 311 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 312 | + ); |
| 313 | + |
| 314 | + expectSnippet(`const state = signalState({ caller: '' })`).toFail( |
| 315 | + /@ngrx\/signals: signal state cannot contain `Function` property or method names/ |
| 316 | + ); |
| 317 | + }); |
| 318 | + |
| 319 | + it('fails when state is not an object', () => { |
| 320 | + expectSnippet(`const state = signalState(10);`).toFail(); |
| 321 | + |
| 322 | + expectSnippet(`const state = signalState('');`).toFail(); |
| 323 | + |
| 324 | + expectSnippet(`const state = signalState(null);`).toFail(); |
| 325 | + |
| 326 | + expectSnippet(`const state = signalState(true);`).toFail(); |
| 327 | + |
| 328 | + expectSnippet(`const state = signalState(['ng', 'rx']);`).toFail(); |
| 329 | + }); |
| 330 | + |
| 331 | + it('fails when state type is defined as an interface', () => { |
| 332 | + expectSnippet(` |
| 333 | + interface User { |
| 334 | + firstName: string; |
| 335 | + lastName: string; |
| 336 | + } |
| 337 | +
|
| 338 | + const state = signalState<User>({ firstName: 'John', lastName: 'Smith' }); |
| 339 | + `).toFail( |
| 340 | + /Type 'User' does not satisfy the constraint 'Record<string, unknown>'/ |
| 341 | + ); |
| 342 | + }); |
| 343 | + |
| 344 | + it('patches state via sequence of partial state objects and updater functions', () => { |
| 345 | + expectSnippet(` |
| 346 | + const state = signalState(initialState); |
| 347 | +
|
| 348 | + patchState( |
| 349 | + state, |
| 350 | + { numbers: [10, 100, 1000] }, |
| 351 | + (state) => ({ user: { ...state.user, age: state.user.age + 1 } }), |
| 352 | + { ngrx: 'signals' } |
| 353 | + ); |
| 354 | + `).toSucceed(); |
| 355 | + }); |
| 356 | + |
| 357 | + it('fails when state is patched with a non-record', () => { |
| 358 | + expectSnippet(` |
| 359 | + const state = signalState(initialState); |
| 360 | + patchState(state, 10); |
| 361 | + `).toFail(); |
| 362 | + |
| 363 | + expectSnippet(` |
| 364 | + const state = signalState(initialState); |
| 365 | + patchState(state, undefined); |
| 366 | + `).toFail(); |
| 367 | + |
| 368 | + expectSnippet(` |
| 369 | + const state = signalState(initialState); |
| 370 | + patchState(state, [1, 2, 3]); |
| 371 | + `).toFail(); |
| 372 | + }); |
| 373 | + |
| 374 | + it('fails when state is patched with a wrong record', () => { |
| 375 | + expectSnippet(` |
| 376 | + const state = signalState(initialState); |
| 377 | + patchState(state, { ngrx: 10 }); |
| 378 | + `).toFail(/Type 'number' is not assignable to type 'string'/); |
| 379 | + }); |
| 380 | + |
| 381 | + it('fails when state is patched with a wrong updater function', () => { |
| 382 | + expectSnippet(` |
| 383 | + const state = signalState(initialState); |
| 384 | + patchState(state, (state) => ({ user: { ...state.user, age: '30' } })); |
| 385 | + `).toFail(/Type 'string' is not assignable to type 'number'/); |
| 386 | + }); |
| 387 | +}); |
0 commit comments