wenay-common2 1.0.69 → 1.0.70
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 +10 -1
- package/demo/client.ts +100 -0
- package/demo/index.html +28 -0
- package/demo/server.ts +43 -0
- package/doc/changes/1.0.70.md +4 -0
- package/observe/PLAN.md +131 -0
- package/observe/README.md +226 -0
- package/observe/hot-write.test.ts +95 -0
- package/observe/listen-core.test.ts +66 -0
- package/observe/listen-store.test.ts +56 -0
- package/observe/listen.test.ts +92 -0
- package/observe/reactive.test.ts +270 -0
- package/observe/reactive.ts +1 -0
- package/observe/store-manager.test.ts +118 -0
- package/observe/store-mirror.example.ts +74 -0
- package/observe/store.test.ts +235 -0
- package/observe/store.ts +1 -0
- package/observe/usage-real-socket.ts +174 -0
- package/observe/usage.ts +200 -0
- package/oracle/PASSED.md +27 -0
- package/oracle/README.md +12 -0
- package/oracle/fixes-primitives.spec.ts +90 -0
- package/oracle/realsocket/_rs.ts +106 -0
- package/oracle/realsocket/auth.spec.ts +91 -0
- package/oracle/realsocket/callbacks.spec.ts +122 -0
- package/oracle/realsocket/caps.spec.ts +124 -0
- package/oracle/realsocket/core.spec.ts +49 -0
- package/oracle/realsocket/dedupe.spec.ts +142 -0
- package/oracle/realsocket/errors.spec.ts +121 -0
- package/oracle/realsocket/lifecycle.spec.ts +128 -0
- package/oracle/realsocket/limits.spec.ts +98 -0
- package/oracle/realsocket/pipe.spec.ts +101 -0
- package/oracle/realsocket/shape.spec.ts +124 -0
- package/oracle/realsocket/slimv2.spec.ts +116 -0
- package/oracle/realsocket/stress.spec.ts +132 -0
- package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
- package/oracle/regression/async-queues.spec.ts +254 -0
- package/oracle/regression/bytestream.spec.ts +152 -0
- package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
- package/oracle/regression/core-clone-equal.spec.ts +124 -0
- package/oracle/regression/data-structures.spec.ts +135 -0
- package/oracle/regression/listen-events.spec.ts +206 -0
- package/oracle/regression/observe-core.spec.ts +278 -0
- package/oracle/regression/package-export.spec.ts +120 -0
- package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
- package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
- package/oracle/regression/store-each.spec.ts +209 -0
- package/package.json +6 -1
- package/replay/PLAN.md +171 -0
- package/replay/canvas-socket.test.ts +187 -0
- package/replay/coalesce.test.ts +260 -0
- package/replay/conflate-socket.test.ts +288 -0
- package/replay/conflate.test.ts +225 -0
- package/replay/history.test.ts +222 -0
- package/replay/media-socket.test.ts +157 -0
- package/replay/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -0
- package/replay/peer-sdk.test.ts +228 -0
- package/replay/replay-listen.test.ts +156 -0
- package/replay/route-coordinator.test.ts +314 -0
- package/replay/route-handoff.test.ts +150 -0
- package/replay/route-webrtc.test.ts +321 -0
- package/replay/rpc-auto.test.ts +256 -0
- package/replay/socket-replay.test.ts +199 -0
- package/replay/staleness.test.ts +176 -0
- package/replay/store-replay.test.ts +151 -0
- package/replay/video-socket.demo.ts +200 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {ByteStreamR, ByteStreamW, NumericTypes, nullable} from '../../src/Common/data/ByteStream'
|
|
2
|
+
|
|
3
|
+
type TestCase = {
|
|
4
|
+
name: string
|
|
5
|
+
run: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const numericTypes: NumericTypes[] = [
|
|
9
|
+
'int8',
|
|
10
|
+
'uint8',
|
|
11
|
+
'int16',
|
|
12
|
+
'uint16',
|
|
13
|
+
'int24',
|
|
14
|
+
'uint24',
|
|
15
|
+
'int32',
|
|
16
|
+
'uint32',
|
|
17
|
+
'int48',
|
|
18
|
+
'uint48',
|
|
19
|
+
'int64',
|
|
20
|
+
'uint64',
|
|
21
|
+
'float',
|
|
22
|
+
'double',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
function assert(condition: unknown, message: string): asserts condition {
|
|
26
|
+
if (!condition) throw new Error(message)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertDeepEqual(actual: unknown, expected: unknown, message: string) {
|
|
30
|
+
const actualText = JSON.stringify(actual)
|
|
31
|
+
const expectedText = JSON.stringify(expected)
|
|
32
|
+
if (actualText !== expectedText) {
|
|
33
|
+
throw new Error(`${message}: expected ${expectedText}, got ${actualText}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertThrows(fn: () => unknown, message: string) {
|
|
38
|
+
let threw = false
|
|
39
|
+
try {
|
|
40
|
+
fn()
|
|
41
|
+
} catch {
|
|
42
|
+
threw = true
|
|
43
|
+
}
|
|
44
|
+
assert(threw, message)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tests: TestCase[] = [
|
|
48
|
+
{
|
|
49
|
+
name: 'nullable numeric null writes and reads null for every numeric type',
|
|
50
|
+
run() {
|
|
51
|
+
const stream = new ByteStreamW()
|
|
52
|
+
for (const type of numericTypes) {
|
|
53
|
+
assert(stream.pushNumber(null as any, nullable(type)) !== null, `write ${type} null`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const reader = new ByteStreamR(stream.data)
|
|
57
|
+
for (const type of numericTypes) {
|
|
58
|
+
assert(reader.readNumber(nullable(type)) === null, `read ${type} nullable null`)
|
|
59
|
+
}
|
|
60
|
+
assert(stream.length === numericTypes.length, 'nullable null values occupy only presence bytes')
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'truncated uint8 array throws and noThrow returns null',
|
|
65
|
+
run() {
|
|
66
|
+
const data = new DataView(new ArrayBuffer(6))
|
|
67
|
+
data.setUint32(0, 3)
|
|
68
|
+
data.setUint8(4, 10)
|
|
69
|
+
data.setUint8(5, 20)
|
|
70
|
+
|
|
71
|
+
assertThrows(() => new ByteStreamR(data).readArray('uint8'), 'truncated uint8 array throws')
|
|
72
|
+
assert(new ByteStreamR(data).noThrow().readArray('uint8') === null, 'truncated uint8 noThrow returns null')
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'truncated int8 array throws and noThrow returns null',
|
|
77
|
+
run() {
|
|
78
|
+
const data = new DataView(new ArrayBuffer(5))
|
|
79
|
+
data.setUint32(0, 2)
|
|
80
|
+
data.setInt8(4, -1)
|
|
81
|
+
|
|
82
|
+
assertThrows(() => new ByteStreamR(data).readArray('int8'), 'truncated int8 array throws')
|
|
83
|
+
assert(new ByteStreamR(data).noThrow().readArray('int8') === null, 'truncated int8 noThrow returns null')
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'pushArray writes into a DataView byteOffset and data keeps that byteOffset',
|
|
88
|
+
run() {
|
|
89
|
+
const backing = new ArrayBuffer(32)
|
|
90
|
+
const offset = 7
|
|
91
|
+
const stream = new ByteStreamW(new DataView(backing, offset, 20))
|
|
92
|
+
stream.pushArray(new Uint8Array([9, 8, 7]))
|
|
93
|
+
|
|
94
|
+
const data = stream.data
|
|
95
|
+
assert(data.byteOffset === offset, `data byteOffset is preserved: ${data.byteOffset}`)
|
|
96
|
+
assert(data.byteLength === 7, `data byteLength is written length: ${data.byteLength}`)
|
|
97
|
+
assertDeepEqual(Array.from(new Uint8Array(backing, 0, offset)), [0, 0, 0, 0, 0, 0, 0], 'bytes before offset remain untouched')
|
|
98
|
+
assertDeepEqual(Array.from(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)), [0, 0, 0, 3, 9, 8, 7], 'array bytes are written at offset')
|
|
99
|
+
assertDeepEqual(new ByteStreamR(data).readArray('uint8'), [9, 8, 7], 'offset data reads back')
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'numeric arrays round-trip signed, unsigned, floating and nullable values',
|
|
104
|
+
run() {
|
|
105
|
+
const stream = new ByteStreamW()
|
|
106
|
+
stream.pushArrayNumeric([-32768, -1, 0, 32767], 'int16')
|
|
107
|
+
stream.pushArrayNumeric([0, 1, 0x123456, 0xffffff], 'uint24')
|
|
108
|
+
stream.pushArrayNumeric([1.5, null as any, -2.25], nullable('double'))
|
|
109
|
+
|
|
110
|
+
const reader = new ByteStreamR(stream.data)
|
|
111
|
+
assertDeepEqual(reader.readArray('int16'), [-32768, -1, 0, 32767], 'int16 array round-trip')
|
|
112
|
+
assertDeepEqual(reader.readArray('uint24'), [0, 1, 0x123456, 0xffffff], 'uint24 array round-trip')
|
|
113
|
+
assertDeepEqual(reader.readArray(nullable('double')), [1.5, null, -2.25], 'nullable double array round-trip')
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'strings round-trip nulls and null-terminated values',
|
|
118
|
+
run() {
|
|
119
|
+
const stream = new ByteStreamW()
|
|
120
|
+
stream.pushAnsi(null)
|
|
121
|
+
stream.pushAnsi('abc')
|
|
122
|
+
stream.pushUnicode(null)
|
|
123
|
+
stream.pushUnicode('AZ')
|
|
124
|
+
|
|
125
|
+
const data = stream.data
|
|
126
|
+
const bytes = Array.from(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))
|
|
127
|
+
assertDeepEqual(bytes.slice(0, 6), [0, 1, 97, 98, 99, 0], 'ansi null marker and terminator are written')
|
|
128
|
+
assertDeepEqual(bytes.slice(6), [0, 1, 0, 65, 0, 90, 0, 0], 'unicode null marker and terminator are written')
|
|
129
|
+
|
|
130
|
+
const reader = new ByteStreamR(data)
|
|
131
|
+
assert(reader.readAnsi() === null, 'ansi null reads null')
|
|
132
|
+
assert(reader.readAnsi() === 'abc', 'ansi value reads to terminator')
|
|
133
|
+
assert(reader.readUnicode() === null, 'unicode null reads null')
|
|
134
|
+
assert(reader.readUnicode() === 'AZ', 'unicode value reads to terminator')
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
let failures = 0
|
|
140
|
+
for (const test of tests) {
|
|
141
|
+
try {
|
|
142
|
+
test.run()
|
|
143
|
+
console.log('ok', test.name)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
failures++
|
|
146
|
+
console.error('FAIL', test.name)
|
|
147
|
+
console.error(error)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`\n${tests.length - failures}/${tests.length} ByteStream regression tests passed`)
|
|
152
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Oracle: ClientAPIAll/ClientAPIStrict replay projection is a TYPE-level contract, and
|
|
2
|
+
// tsconfig excludes *.spec.ts while oracles run ts-node --transpile-only — so the check
|
|
3
|
+
// here is an explicit `tsc --noEmit` over the fixture. A negative control compiles a
|
|
4
|
+
// deliberately broken snippet first: if THAT passes, the tsc invocation itself is broken
|
|
5
|
+
// and a green fixture would mean nothing.
|
|
6
|
+
|
|
7
|
+
import {spawnSync} from 'node:child_process'
|
|
8
|
+
import {writeFileSync, rmSync, mkdtempSync} from 'node:fs'
|
|
9
|
+
import {tmpdir} from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
const root = path.resolve(__dirname, '..', '..')
|
|
13
|
+
const tsc = path.join(root, 'node_modules', 'typescript', 'bin', 'tsc')
|
|
14
|
+
const flags = ['--noEmit', '--strict', '--target', 'esnext', '--module', 'commonjs',
|
|
15
|
+
'--moduleResolution', 'node', '--esModuleInterop', '--skipLibCheck']
|
|
16
|
+
|
|
17
|
+
function typecheck(file: string) {
|
|
18
|
+
const res = spawnSync(process.execPath, [tsc, ...flags, file],
|
|
19
|
+
{cwd: root, encoding: 'utf8', timeout: 240_000, maxBuffer: 16 * 1024 * 1024})
|
|
20
|
+
return {ok: res.status === 0, out: (res.stdout ?? '') + (res.stderr ?? '')}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let failures = 0
|
|
24
|
+
function report(name: string, ok: boolean, detail = '') {
|
|
25
|
+
if (ok) console.log(`OK ${name}`)
|
|
26
|
+
else {
|
|
27
|
+
failures++
|
|
28
|
+
console.error(`FAIL ${name}`)
|
|
29
|
+
if (detail) console.error(detail.trim().split(/\r?\n/).slice(0, 20).join('\n'))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// negative control — a broken file MUST fail
|
|
34
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'wenay-types-'))
|
|
35
|
+
const bad = path.join(dir, 'bad.ts')
|
|
36
|
+
writeFileSync(bad, 'const x: number = "not a number"\nexport {x}\n')
|
|
37
|
+
const badRes = typecheck(bad)
|
|
38
|
+
report('negative control: broken snippet fails to compile', !badRes.ok)
|
|
39
|
+
rmSync(dir, {recursive: true, force: true})
|
|
40
|
+
|
|
41
|
+
// the fixture — replay projection with no casts must compile clean
|
|
42
|
+
const fixture = path.join(root, 'oracle', 'regression', '_clientapiall-replay.fixture.ts')
|
|
43
|
+
const fixRes = typecheck(fixture)
|
|
44
|
+
report('ClientAPIAll/Strict replay projection compiles with no casts', fixRes.ok, fixRes.out)
|
|
45
|
+
|
|
46
|
+
console.log(failures === 0 ? 'ALL GREEN (2)' : `${failures} FAILURE(S) / 2`)
|
|
47
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CancelablePromise,
|
|
3
|
+
MapExt,
|
|
4
|
+
StructMap,
|
|
5
|
+
StructSet,
|
|
6
|
+
clone,
|
|
7
|
+
deepEqual,
|
|
8
|
+
} from '../../src/Common/core/common'
|
|
9
|
+
|
|
10
|
+
let fails = 0
|
|
11
|
+
|
|
12
|
+
function assert(cond: any, msg: string) {
|
|
13
|
+
if (cond) console.log(' ok :', msg)
|
|
14
|
+
else { fails++; console.log(' FAIL:', msg) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function assertRejects(promise: Promise<any>, expected: any, msg: string) {
|
|
18
|
+
try {
|
|
19
|
+
await promise
|
|
20
|
+
assert(false, msg + ' (resolved)')
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
assert(e === expected, msg)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
// clone: cycles are preserved without reusing original objects.
|
|
29
|
+
{
|
|
30
|
+
const src: any = { name: 'root' }
|
|
31
|
+
src.self = src
|
|
32
|
+
src.child = { parent: src }
|
|
33
|
+
|
|
34
|
+
const copy = clone(src) as any
|
|
35
|
+
|
|
36
|
+
assert(copy !== src, 'clone creates a new root object')
|
|
37
|
+
assert(copy.self === copy, 'clone preserves direct object cycle')
|
|
38
|
+
assert(copy.child !== src.child, 'clone creates a new nested object')
|
|
39
|
+
assert(copy.child.parent === copy, 'clone rewires nested cycle to cloned root')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// clone: Map and Set entries are deeply cloned.
|
|
43
|
+
{
|
|
44
|
+
const key = { id: 1 }
|
|
45
|
+
const val = { nested: { n: 2 } }
|
|
46
|
+
const setItem = { tag: 'set-item' }
|
|
47
|
+
const src = {
|
|
48
|
+
map: new Map<any, any>([[key, val]]),
|
|
49
|
+
set: new Set<any>([setItem]),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const copy = clone(src)
|
|
53
|
+
const [[copyKey, copyVal]] = copy.map.entries()
|
|
54
|
+
const [copySetItem] = copy.set.values()
|
|
55
|
+
|
|
56
|
+
assert(copy.map !== src.map, 'clone creates a new Map')
|
|
57
|
+
assert(copy.set !== src.set, 'clone creates a new Set')
|
|
58
|
+
assert(copyKey !== key && copyKey.id === key.id, 'clone deep-clones Map keys')
|
|
59
|
+
assert(copyVal !== val && copyVal.nested !== val.nested && copyVal.nested.n === 2, 'clone deep-clones Map values')
|
|
60
|
+
assert(copySetItem !== setItem && copySetItem.tag === setItem.tag, 'clone deep-clones Set values')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// deepEqual: null, Date, Map, and Set equality.
|
|
64
|
+
{
|
|
65
|
+
assert(deepEqual(null, null), 'deepEqual treats null values as equal')
|
|
66
|
+
assert(!deepEqual(null, {}), 'deepEqual treats null and object as different')
|
|
67
|
+
assert(deepEqual(new Date('2024-01-02T03:04:05.000Z'), new Date('2024-01-02T03:04:05.000Z')), 'deepEqual compares Date time values')
|
|
68
|
+
assert(!deepEqual(new Date('2024-01-02T03:04:05.000Z'), new Date('2024-01-02T03:04:06.000Z')), 'deepEqual rejects different Date time values')
|
|
69
|
+
assert(deepEqual(new Map([['a', { n: 1 }]]), new Map([['a', { n: 1 }]])), 'deepEqual compares Map values structurally')
|
|
70
|
+
assert(!deepEqual(new Map([['a', { n: 1 }]]), new Map([['a', { n: 2 }]])), 'deepEqual rejects different Map values')
|
|
71
|
+
assert(deepEqual(new Set(['a', 'b']), new Set(['b', 'a'])), 'deepEqual compares Set membership')
|
|
72
|
+
assert(!deepEqual(new Set(['a', 'b']), new Set(['a', 'c'])), 'deepEqual rejects different Set membership')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// StructMap/StructSet: missing composite lookup over null/primitive leaves stays safe.
|
|
76
|
+
{
|
|
77
|
+
const map = new StructMap<readonly number[], null>()
|
|
78
|
+
map.set([1], null)
|
|
79
|
+
|
|
80
|
+
const set = new StructSet<readonly number[]>()
|
|
81
|
+
set.add([1])
|
|
82
|
+
|
|
83
|
+
assert(map.has([1]), 'StructMap.has finds a null leaf value')
|
|
84
|
+
assert(!map.has([1, 2]), 'StructMap.has returns false when a null leaf is used as a prefix')
|
|
85
|
+
assert(set.has([1]), 'StructSet.has finds an entry stored as a null value')
|
|
86
|
+
assert(!set.has([1, 2]), 'StructSet.has returns false when its null value is used as a prefix')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// MapExt: clear invalidates valuesArrayImmutable cache.
|
|
90
|
+
{
|
|
91
|
+
const map = new MapExt<string, number>([['a', 1], ['b', 2]])
|
|
92
|
+
const before = map.valuesArrayImmutable()
|
|
93
|
+
map.clear()
|
|
94
|
+
map.set('c', 3)
|
|
95
|
+
const after = map.valuesArrayImmutable()
|
|
96
|
+
|
|
97
|
+
assert(before !== after, 'MapExt.clear invalidates valuesArrayImmutable cache')
|
|
98
|
+
assert(after.length === 1 && after[0] === 3, 'MapExt valuesArrayImmutable reflects entries after clear')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// JSON.stringify patch: array replacer keeps native allow-list semantics.
|
|
102
|
+
{
|
|
103
|
+
const text = JSON.stringify({ a: 1, b: 2, nested: { a: 3, b: 4 } }, ['a', 'nested'])
|
|
104
|
+
assert(text === '{"a":1,"nested":{"a":3}}', 'JSON.stringify supports array replacer allow-list')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// CancelablePromise: nested cancel can cascade through the onCancel hook.
|
|
108
|
+
{
|
|
109
|
+
let innerCancelled = false
|
|
110
|
+
const inner = new CancelablePromise<string>(() => {}, () => { innerCancelled = true })
|
|
111
|
+
const outer = new CancelablePromise<string>((resolve) => resolve(inner), () => inner.cancel('inner-cancelled'))
|
|
112
|
+
|
|
113
|
+
const rejection = assertRejects(outer, 'inner-cancelled', 'CancelablePromise nested cancel rejects the outer promise through the inner promise')
|
|
114
|
+
outer.cancel('outer-cancelled')
|
|
115
|
+
await rejection
|
|
116
|
+
await assertRejects(inner, 'inner-cancelled', 'CancelablePromise nested cancel rejects the inner promise')
|
|
117
|
+
assert(innerCancelled, 'CancelablePromise nested cancel runs the inner onCancel hook')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`\n${fails === 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
121
|
+
process.exit(fails === 0 ? 0 : 1)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {CList} from "../../src/Common/data/List";
|
|
2
|
+
import {CListNodeAnd} from "../../src/Common/data/ListNodeAnd";
|
|
3
|
+
import {objectGet, objectSet, objectUnset} from "../../src/Common/data/objectPath";
|
|
4
|
+
import {createRateWindow} from "../../src/Common/funcTimeWait";
|
|
5
|
+
|
|
6
|
+
type Test = {
|
|
7
|
+
name: string;
|
|
8
|
+
fn: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const tests: Test[] = [];
|
|
12
|
+
|
|
13
|
+
function test(name: string, fn: () => void) {
|
|
14
|
+
tests.push({name, fn});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assert(condition: unknown, message: string): asserts condition {
|
|
18
|
+
if (!condition) throw new Error(message);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertEq<T>(actual: T, expected: T, message: string) {
|
|
22
|
+
if (actual !== expected) {
|
|
23
|
+
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertArrayEq<T>(actual: readonly T[], expected: readonly T[], message: string) {
|
|
28
|
+
const same = actual.length === expected.length && actual.every((value, index) => value === expected[index]);
|
|
29
|
+
if (!same) {
|
|
30
|
+
throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function captureThrow(fn: () => void) {
|
|
35
|
+
try {
|
|
36
|
+
fn();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return error;
|
|
39
|
+
}
|
|
40
|
+
throw new Error("expected function to throw");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("CList iteration survives deleting the next node", () => {
|
|
44
|
+
const list = new CList([1, 2, 3, 4]);
|
|
45
|
+
const node2 = list.find(2);
|
|
46
|
+
const visited: number[] = [];
|
|
47
|
+
|
|
48
|
+
assert(node2, "node to delete exists");
|
|
49
|
+
|
|
50
|
+
for (const node of list) {
|
|
51
|
+
visited.push(node.value);
|
|
52
|
+
if (node.value === 1) {
|
|
53
|
+
list.delete(node2);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
assertArrayEq(visited, [1, 3, 4], "iterator skips the deleted next node and continues");
|
|
58
|
+
assertArrayEq([...list.values()], [1, 3, 4], "list contents are linked after deleting next node");
|
|
59
|
+
assertEq(list.size, 3, "list size reflects deleted next node");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("CList reversed iteration walks from last to first", () => {
|
|
63
|
+
const list = new CList(["first", "second", "third"]);
|
|
64
|
+
|
|
65
|
+
assertArrayEq([...list.reversedValues()], ["third", "second", "first"], "reversedValues returns tail-to-head order");
|
|
66
|
+
assertArrayEq([...list.reversedNodes()].map(node => node.value), ["third", "second", "first"], "reversedNodes returns tail-to-head nodes");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("CListNodeAnd keeps falsy values", () => {
|
|
70
|
+
const list = new CListNodeAnd<0 | false | "">();
|
|
71
|
+
|
|
72
|
+
list.AddEnd(0);
|
|
73
|
+
list.AddEnd(false);
|
|
74
|
+
list.AddEnd("");
|
|
75
|
+
|
|
76
|
+
assertArrayEq(list.GetArray(), [0, false, ""], "legacy list stores falsy values passed to AddEnd");
|
|
77
|
+
assertEq(list.dataFirst, 0, "dataFirst preserves 0");
|
|
78
|
+
assertEq(list.dataEnd, "", "dataEnd preserves empty string");
|
|
79
|
+
assertEq(list.find(node => node.data === false)?.data, false, "find can observe false data");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("objectPath null traversal throws domain errors, not native TypeError", () => {
|
|
83
|
+
const getError = captureThrow(() => objectGet({a: null} as any, ["a", "b"]));
|
|
84
|
+
const setError = captureThrow(() => objectSet({a: null} as any, ["a", "b"], 1));
|
|
85
|
+
const unsetError = captureThrow(() => objectUnset({a: null} as any, ["a", "b"]));
|
|
86
|
+
|
|
87
|
+
assert(!(getError instanceof TypeError), "objectGet null traversal is not a native TypeError");
|
|
88
|
+
assert(!(setError instanceof TypeError), "objectSet null traversal is not a native TypeError");
|
|
89
|
+
assert(!(unsetError instanceof TypeError), "objectUnset null traversal is not a native TypeError");
|
|
90
|
+
assertEq(String(getError), "value is not an object: null", "objectGet reports null traversal");
|
|
91
|
+
assertEq(String(setError), "value is not an object: null", "objectSet reports null traversal");
|
|
92
|
+
assertEq(String(unsetError), "value is not an object: null", "objectUnset reports null traversal");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("rate window handles out-of-order timestamps", () => {
|
|
96
|
+
const originalNow = Date.now;
|
|
97
|
+
const window = createRateWindow();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
Date.now = () => 350;
|
|
101
|
+
window.add({type: "UID", timeStamp: 300, weight: 3});
|
|
102
|
+
window.add({type: "UID", timeStamp: 100, weight: 1});
|
|
103
|
+
window.add({type: "UID", timeStamp: 200, weight: 2});
|
|
104
|
+
|
|
105
|
+
assertArrayEq(window.dStatic.UID.map(([time]) => time), [100, 200, 300], "add keeps timestamps sorted");
|
|
106
|
+
assertEq(window.sumWeight("UID", 175), 5, "sumWeight includes only entries inside the active window");
|
|
107
|
+
assertArrayEq(window.dStatic.UID.map(([time]) => time), [200, 300], "sumWeight prunes old sorted entries");
|
|
108
|
+
|
|
109
|
+
window.add({type: "UID", timeStamp: 250, weight: 4});
|
|
110
|
+
assertArrayEq(window.dStatic.UID.map(([time]) => time), [200, 250, 300], "later out-of-order add is inserted in timestamp order");
|
|
111
|
+
assertEq(window.readyAt("UID", 5), 300, "readyAt uses sorted time order when weights cross the limit");
|
|
112
|
+
assertEq(window.byWeightTimeNow("UID", 250, 5), 250, "byWeightTimeNow ignores future entries after sorting");
|
|
113
|
+
} finally {
|
|
114
|
+
Date.now = originalNow;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
let failed = 0;
|
|
119
|
+
|
|
120
|
+
for (const {name, fn} of tests) {
|
|
121
|
+
try {
|
|
122
|
+
fn();
|
|
123
|
+
console.log(`ok - ${name}`);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
failed++;
|
|
126
|
+
console.error(`not ok - ${name}`);
|
|
127
|
+
console.error(error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (failed > 0) {
|
|
132
|
+
throw new Error(`${failed} data-structures regression test(s) failed`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(`${tests.length} data-structures regression tests passed`);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {listen as createListenPair} from "../../src/Common/events/Listen";
|
|
2
|
+
import {CObjectEventsArr, CObjectEventsList, tListEvent} from "../../src/Common/events/event";
|
|
3
|
+
|
|
4
|
+
type Test = {
|
|
5
|
+
name: string;
|
|
6
|
+
fn: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const tests: Test[] = [];
|
|
10
|
+
|
|
11
|
+
function test(name: string, fn: () => void) {
|
|
12
|
+
tests.push({name, fn});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assert(condition: unknown, message: string): asserts condition {
|
|
16
|
+
if (!condition) throw new Error(message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertEq<T>(actual: T, expected: T, message: string) {
|
|
20
|
+
if (actual !== expected) {
|
|
21
|
+
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("duplicate same callback has independent off handles", () => {
|
|
26
|
+
const [emit, listen] = createListenPair<[number]>();
|
|
27
|
+
let sum = 0;
|
|
28
|
+
const cb = (value: number) => {
|
|
29
|
+
sum += value;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const off1 = listen.on(cb);
|
|
33
|
+
const off2 = listen.on(cb);
|
|
34
|
+
|
|
35
|
+
assertEq(listen.count(), 2, "same callback can be registered twice");
|
|
36
|
+
emit(2);
|
|
37
|
+
assertEq(sum, 4, "both duplicate subscriptions receive events");
|
|
38
|
+
|
|
39
|
+
off1();
|
|
40
|
+
assertEq(listen.count(), 1, "first off removes only one duplicate subscription");
|
|
41
|
+
emit(3);
|
|
42
|
+
assertEq(sum, 7, "second duplicate remains active after first off");
|
|
43
|
+
|
|
44
|
+
off2();
|
|
45
|
+
assertEq(listen.count(), 0, "second off removes remaining duplicate subscription");
|
|
46
|
+
emit(5);
|
|
47
|
+
assertEq(sum, 7, "no duplicate subscriptions remain active");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("keyed overwrite cleans previous cbClose registration", () => {
|
|
51
|
+
const [emit, listen] = createListenPair<[string]>();
|
|
52
|
+
const calls: string[] = [];
|
|
53
|
+
let oldClosed = 0;
|
|
54
|
+
let newClosed = 0;
|
|
55
|
+
|
|
56
|
+
listen.on(value => calls.push(`old:${value}`), {
|
|
57
|
+
key: "slot",
|
|
58
|
+
cbClose: () => oldClosed++,
|
|
59
|
+
});
|
|
60
|
+
listen.on(value => calls.push(`new:${value}`), {
|
|
61
|
+
key: "slot",
|
|
62
|
+
cbClose: () => newClosed++,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assertEq(listen.count(), 1, "key overwrite keeps one subscription");
|
|
66
|
+
emit("event");
|
|
67
|
+
assertEq(calls.join(","), "new:event", "key overwrite replaces callback");
|
|
68
|
+
|
|
69
|
+
listen.close();
|
|
70
|
+
|
|
71
|
+
assertEq(oldClosed, 0, "overwritten cbClose is removed from close registry");
|
|
72
|
+
assertEq(newClosed, 1, "current keyed cbClose runs on close");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("off(cb) removes all entries for the callback", () => {
|
|
76
|
+
const [emit, listen] = createListenPair<[number]>();
|
|
77
|
+
let removedCallbackCalls = 0;
|
|
78
|
+
let otherCallbackCalls = 0;
|
|
79
|
+
const cb = () => {
|
|
80
|
+
removedCallbackCalls++;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
listen.on(cb);
|
|
84
|
+
listen.on(cb);
|
|
85
|
+
listen.on(() => {
|
|
86
|
+
otherCallbackCalls++;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assertEq(listen.count(), 3, "initial subscriptions are registered");
|
|
90
|
+
listen.off(cb);
|
|
91
|
+
assertEq(listen.count(), 1, "off(cb) removes every subscription for cb");
|
|
92
|
+
|
|
93
|
+
emit(1);
|
|
94
|
+
assertEq(removedCallbackCalls, 0, "removed callback is not called");
|
|
95
|
+
assertEq(otherCallbackCalls, 1, "unrelated callback remains active");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("close handlers support off and close clears listeners", () => {
|
|
99
|
+
const [emit, listen] = createListenPair<[void]>();
|
|
100
|
+
let listenerCalls = 0;
|
|
101
|
+
let closeOffCalls = 0;
|
|
102
|
+
let onCloseCalls = 0;
|
|
103
|
+
let cbCloseCalls = 0;
|
|
104
|
+
|
|
105
|
+
const offListener = listen.on(() => {
|
|
106
|
+
listenerCalls++;
|
|
107
|
+
}, {
|
|
108
|
+
cbClose: () => cbCloseCalls++,
|
|
109
|
+
});
|
|
110
|
+
const offEventClose = listen.onClose(() => {
|
|
111
|
+
closeOffCalls++;
|
|
112
|
+
});
|
|
113
|
+
listen.onClose(() => {
|
|
114
|
+
onCloseCalls++;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
offEventClose();
|
|
118
|
+
listen.close();
|
|
119
|
+
|
|
120
|
+
assertEq(listenerCalls, 0, "close does not emit regular listeners");
|
|
121
|
+
assertEq(cbCloseCalls, 1, "per-subscription cbClose runs on close");
|
|
122
|
+
assertEq(closeOffCalls, 0, "onClose off prevents close callback");
|
|
123
|
+
assertEq(onCloseCalls, 1, "onClose callback runs on close");
|
|
124
|
+
assertEq(listen.count(), 0, "close clears regular listeners");
|
|
125
|
+
|
|
126
|
+
offListener();
|
|
127
|
+
emit();
|
|
128
|
+
assertEq(listenerCalls, 0, "off after close remains harmless");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("once unsubscribes after first event and supports early off", () => {
|
|
132
|
+
const [emit, listen] = createListenPair<[number]>();
|
|
133
|
+
let onceCalls = 0;
|
|
134
|
+
let earlyOffCalls = 0;
|
|
135
|
+
|
|
136
|
+
listen.once(value => {
|
|
137
|
+
onceCalls += value;
|
|
138
|
+
});
|
|
139
|
+
emit(2);
|
|
140
|
+
emit(3);
|
|
141
|
+
|
|
142
|
+
assertEq(onceCalls, 2, "once callback receives only the first event");
|
|
143
|
+
assertEq(listen.count(), 0, "once subscription removes itself after firing");
|
|
144
|
+
|
|
145
|
+
const off = listen.once(() => {
|
|
146
|
+
earlyOffCalls++;
|
|
147
|
+
});
|
|
148
|
+
off();
|
|
149
|
+
emit(4);
|
|
150
|
+
|
|
151
|
+
assertEq(earlyOffCalls, 0, "off returned from once removes subscription before event");
|
|
152
|
+
assertEq(listen.count(), 0, "early once off leaves no subscriptions");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("CObjectEventsArr Clean calls OnDel and removes every item", () => {
|
|
156
|
+
const events = new CObjectEventsArr<object>();
|
|
157
|
+
const deleted: string[] = [];
|
|
158
|
+
const first: tListEvent = {OnDel: () => deleted.push("first")};
|
|
159
|
+
const second: tListEvent = {OnDel: () => deleted.push("second")};
|
|
160
|
+
|
|
161
|
+
events.Add(first);
|
|
162
|
+
events.Add(second);
|
|
163
|
+
events.Clean();
|
|
164
|
+
|
|
165
|
+
assertEq(events.count(), 0, "array event Clean removes every item");
|
|
166
|
+
assertEq(deleted.join(","), "second,first", "array event Clean calls OnDel during deletion");
|
|
167
|
+
|
|
168
|
+
events.Clean();
|
|
169
|
+
assertEq(deleted.join(","), "second,first", "array event Clean after empty does not call OnDel again");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("CObjectEventsList Clean calls OnDel and removes every item", () => {
|
|
173
|
+
const events = new CObjectEventsList<object>(false);
|
|
174
|
+
const deleted: string[] = [];
|
|
175
|
+
const first: tListEvent<object> = {OnDel: () => deleted.push("first")};
|
|
176
|
+
const second: tListEvent<object> = {OnDel: () => deleted.push("second")};
|
|
177
|
+
|
|
178
|
+
events.Add(first);
|
|
179
|
+
events.Add(second);
|
|
180
|
+
events.Clean();
|
|
181
|
+
|
|
182
|
+
assertEq(events.count(), 0, "list event Clean removes every item");
|
|
183
|
+
assertEq(deleted.join(","), "second,first", "list event Clean calls OnDel during deletion");
|
|
184
|
+
|
|
185
|
+
events.Clean();
|
|
186
|
+
assertEq(deleted.join(","), "second,first", "list event Clean after empty does not call OnDel again");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let failed = 0;
|
|
190
|
+
|
|
191
|
+
for (const {name, fn} of tests) {
|
|
192
|
+
try {
|
|
193
|
+
fn();
|
|
194
|
+
console.log(`ok - ${name}`);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
failed++;
|
|
197
|
+
console.error(`not ok - ${name}`);
|
|
198
|
+
console.error(error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (failed > 0) {
|
|
203
|
+
throw new Error(`${failed} listen/event regression test(s) failed`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log(`${tests.length} listen/event regression tests passed`);
|