w-serv-orm 1.1.15 → 1.1.17
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 +119 -0
- package/dist/w-serv-orm.umd.js +2 -2
- package/dist/w-serv-orm.umd.js.map +1 -1
- package/docs/WServOrm.html +12 -6
- package/docs/WServOrm.mjs.html +96 -4
- package/docs/baseline.mjs.html +624 -0
- package/docs/global.html +1622 -0
- package/docs/index.html +2 -2
- package/package.json +1 -1
- package/src/WServOrm.mjs +94 -2
- package/src/baseline.mjs +552 -0
- package/src/getMapOrm.mjs +503 -54
- package/src/getProc.mjs +13 -4
- package/test/api-guard.test.mjs +401 -0
- package/test/api-keepuser.test.mjs +131 -0
- package/test/api-objcopy.test.mjs +277 -0
- package/test/tools/memOrm.mjs +122 -0
- package/test/unit-baseline.test.mjs +766 -0
- package/test/unit-guard.test.mjs +952 -0
- package/test/unit-keepuser.test.mjs +307 -0
- package/test/unit-objcopy.test.mjs +1522 -26
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import vm from 'vm'
|
|
3
|
+
import assert from 'assert'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { BASELINE_VERSION, BASELINE_REJECT, cyrb53, checkContract, digestRow, buildBaseline, isBaselineValid, compareBaseline, pickWriteRow, getRejectKey } from '../src/baseline.mjs'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
//規格: 多人同時編輯之寫入契約 §5、§6(列摘要h1、基準、比對); 規則帳本 R-BASELINE-01
|
|
9
|
+
//每條正規化與排序規則至少一正一反; 同構(Node與無Node全域之vm)與零import之靜態檢查置於末尾
|
|
10
|
+
//
|
|
11
|
+
//規格句對照(spec = C:\opensrcOper\dspec\多人同時編輯之寫入契約.md):
|
|
12
|
+
// D01 §5「取 contract.fields, 依宣告之序」 D02 §5「orderKey 不在其內」、§3 serverOnly「不入基準」 D03 §5「undefined/null/'' → 同一空值」
|
|
13
|
+
// D04 §5「Date → ISO 字串」 D05 §5「BigInt → 十進位字串」「數字與字串不互轉」 D06 §5「物件鍵遞迴排序,陣列保序」 D07 §5「不用 localeCompare」(鍵序同理)
|
|
14
|
+
// D08 §5「其餘非 JSON 值拋錯,不靜默略過」 D10 §5 巢狀值(規格未定, 見規則帳本 R-BASELINE-01 規則3「依原則自行決定」) D13/D14 §5「'h1:' + 16 進位」、「版本:同版凍結」
|
|
15
|
+
// K01-K07 §3 寫入契約(缺 pk/fields、fields 含 orderKey、serverOnly 與 fields 交集) P01-P03 §5「主鍵:依 schema 型別轉為字串;同批重複或缺漏 → baselineFormat」
|
|
16
|
+
// P05-P06 §5「依 (order 數值, pk 碼元)…不用 localeCompare」 P08-P09 §5「依 scope 過濾」 F01-F04 §5「不識之格式或版本一律拒收 baselineFormat」
|
|
17
|
+
// C01-C14 §6「compareBaseline…相符回 ok:true; 不符回 ok:false, reason, added, removed, changed, reordered」、§5「他人重排會被偵測, 系統重編號(名次不變)不會」(V9)、V2、V8、V10
|
|
18
|
+
// S01-S03 §8「前後端 import 同一檔」、任務完成條件 2「同構: 零 import, 無 Node 全域之環境算出同一摘要」
|
|
19
|
+
|
|
20
|
+
let fpSrc = fileURLToPath(new URL('../src/baseline.mjs', import.meta.url))
|
|
21
|
+
|
|
22
|
+
//單欄契約, 以digest比較單一值之正規化
|
|
23
|
+
let F = { pk: 'id', fields: ['v'] }
|
|
24
|
+
let dg = (v) => digestRow({ id: 'x', v }, F)
|
|
25
|
+
let eq = (a, b, msg) => assert.strict.equal(dg(a), dg(b), msg)
|
|
26
|
+
let ne = (a, b, msg) => assert.notStrictEqual(dg(a), dg(b), msg)
|
|
27
|
+
|
|
28
|
+
//去除註解後之程式碼(區塊註解含JSDoc之/**與行註解), 供靜態檢查
|
|
29
|
+
function stripComments(src) {
|
|
30
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getThrow(fn) {
|
|
34
|
+
try {
|
|
35
|
+
fn()
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return e
|
|
39
|
+
}
|
|
40
|
+
assert.fail('預期拋錯但未拋')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
describe('baseline 列摘要 digestRow(規格 §5)', function() {
|
|
45
|
+
|
|
46
|
+
it('D01 欄位依契約宣告之序: 列內鍵序無關(正), 宣告序不同則摘要不同(反)', function() {
|
|
47
|
+
let C1 = { pk: 'id', fields: ['a', 'b'] }
|
|
48
|
+
let C2 = { pk: 'id', fields: ['b', 'a'] }
|
|
49
|
+
assert.strict.equal(digestRow({ id: 'r', a: 1, b: 2 }, C1), digestRow({ b: 2, id: 'r', a: 1 }, C1))
|
|
50
|
+
assert.notStrictEqual(digestRow({ id: 'r', a: 1, b: 2 }, C1), digestRow({ id: 'r', a: 1, b: 2 }, C2))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('D02 只取fields: 未列欄位(含serverOnly與orderKey與系統欄位)變動不影響摘要(正), fields內欄位變動則不同(反)', function() {
|
|
54
|
+
let C = { pk: 'id', fields: ['a'], serverOnly: ['s'], orderKey: 'order' }
|
|
55
|
+
let base = { id: 'r', a: 1, s: 'old', order: 1, timeUpdate: 't1', userIdUpdate: 'u1', other: 'x' }
|
|
56
|
+
let d0 = digestRow(base, C)
|
|
57
|
+
assert.strict.equal(digestRow({ ...base, s: 'new', order: 9, timeUpdate: 't2', userIdUpdate: 'u2', other: 'y' }, C), d0)
|
|
58
|
+
assert.notStrictEqual(digestRow({ ...base, a: 2 }, C), d0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('D03 undefined、null、空字串與缺欄為同一空值(正); 0、false、空白字串、空陣列、空物件不是空值(反)', function() {
|
|
62
|
+
eq(undefined, null)
|
|
63
|
+
eq(null, '')
|
|
64
|
+
eq('', undefined)
|
|
65
|
+
assert.strict.equal(digestRow({ id: 'x' }, F), dg(undefined), '缺欄同undefined')
|
|
66
|
+
for (let v of [0, false, ' ', [], {}, 'null', '0']) {
|
|
67
|
+
ne(v, null, `${JSON.stringify(v)}不等於空值`)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('D04 Date轉ISO字串: Date與其ISO字串同摘要(正), 不同時刻或不同字串不同(反), 無效Date拋錯', function() {
|
|
72
|
+
let d = new Date('2026-10-22T08:09:10.123Z')
|
|
73
|
+
eq(d, '2026-10-22T08:09:10.123Z')
|
|
74
|
+
ne(d, new Date('2026-10-22T08:09:10.124Z'))
|
|
75
|
+
ne(d, '2026-10-22T08:09:10Z')
|
|
76
|
+
let e = getThrow(() => dg(new Date(NaN)))
|
|
77
|
+
assert.ok(/invalid Date/.test(e.message))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('D05 BigInt轉十進位字串: 5n與\'5\'同摘要(正), 5n與6n不同、數字5與字串\'5\'不同(數字與字串不互轉, 反)', function() {
|
|
81
|
+
eq(5n, '5')
|
|
82
|
+
eq(-12345678901234567890n, '-12345678901234567890')
|
|
83
|
+
ne(5n, 6n)
|
|
84
|
+
ne(5, '5')
|
|
85
|
+
ne(5, 5n)
|
|
86
|
+
ne(true, 'true')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('D06 物件鍵遞迴排序, 陣列保序: 鍵序不同之物件同摘要(正), 陣列次序或值不同則不同(反)', function() {
|
|
90
|
+
eq({ x: 1, y: { p: 1, q: [1, { b: 1, a: 2 }] } }, { y: { q: [1, { a: 2, b: 1 }], p: 1 }, x: 1 })
|
|
91
|
+
ne([1, 2], [2, 1])
|
|
92
|
+
ne({ x: 1, y: 2 }, { x: 2, y: 1 })
|
|
93
|
+
ne({ a: { b: 1 } }, { a: { b: 2 } })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('D07 鍵排序依UTF-16碼元序: 與localeCompare無關', function() {
|
|
97
|
+
//'B'(66)排在'a'(97)之前, 不論區域; 兩物件鍵序相反者同摘要, 且序列化之文字順序固定(以凍結向量檢驗)
|
|
98
|
+
eq({ a: 1, B: 2 }, { B: 2, a: 1 })
|
|
99
|
+
eq({ '😀': 1, '~': 2 }, { '~': 2, '😀': 1 })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('D08 其餘非JSON值拋錯不靜默: function、symbol、NaN、±Infinity、Map、Set、RegExp、類別實例、帶原型之物件(含巢狀)', function() {
|
|
103
|
+
class P {
|
|
104
|
+
constructor() {
|
|
105
|
+
this.a = 1
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
let bads = [
|
|
109
|
+
() => 1, Symbol('s'), NaN, Infinity, -Infinity, new Map(), new Set(), /x/, new P(), Object.create({ inherited: 1 }),
|
|
110
|
+
[1, () => 1], { k: NaN }, { k: { z: Infinity } }, [[Symbol('s')]], new Uint8Array(2), new Error('e'),
|
|
111
|
+
]
|
|
112
|
+
for (let v of bads) {
|
|
113
|
+
let e = getThrow(() => dg(v))
|
|
114
|
+
assert.ok(/unsupported value of field\[v\]/.test(e.message), `${Object.prototype.toString.call(v)} → ${e.message}`)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('D08b 陣列之自有可列舉非索引屬性(如toJSON)拋錯: 其不入摘要卻會在JSON傳輸時改變內容而使兩端摘要不同(反); 空洞為null同JSON(正)', function() {
|
|
119
|
+
let withToJson = [1]
|
|
120
|
+
withToJson.toJSON = () => [2]
|
|
121
|
+
let e = getThrow(() => dg(withToJson))
|
|
122
|
+
assert.ok(/array with extra properties/.test(e.message))
|
|
123
|
+
let withProp = [1, 2]
|
|
124
|
+
withProp.x = 'extra'
|
|
125
|
+
assert.throws(() => dg(withProp), /array with extra properties/)
|
|
126
|
+
assert.throws(() => dg({ k: [withProp] }), /array with extra properties/, '巢狀亦然')
|
|
127
|
+
//空洞同JSON傳輸為null
|
|
128
|
+
// eslint-disable-next-line no-sparse-arrays
|
|
129
|
+
eq([1, , 3], [1, null, 3])
|
|
130
|
+
let arr = [1, 2, 3]
|
|
131
|
+
arr.length = 5
|
|
132
|
+
eq(arr, [1, 2, 3, null, null])
|
|
133
|
+
//不可列舉之額外屬性不被JSON序列化, 不影響
|
|
134
|
+
let hidden = [1]
|
|
135
|
+
Object.defineProperty(hidden, 'meta', { value: 'x', enumerable: false })
|
|
136
|
+
eq(hidden, [1])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('D09 無原型物件與一般物件同視(正); 非物件之列拋錯(反)', function() {
|
|
140
|
+
let o = Object.create(null)
|
|
141
|
+
o.k = 1
|
|
142
|
+
eq(o, { k: 1 })
|
|
143
|
+
for (let row of [null, undefined, 'x', 5, [1], new Map(), new Set(), new Date(), /x/, () => 1, new Uint8Array(1)]) {
|
|
144
|
+
assert.throws(() => digestRow(row, F), /invalid row: need an object/)
|
|
145
|
+
}
|
|
146
|
+
class P {
|
|
147
|
+
constructor() {
|
|
148
|
+
this.id = 'x'
|
|
149
|
+
this.v = 1
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
assert.strict.equal(digestRow(new P(), F), dg(1), '類別實例照常(只讀自有屬性)')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('D10 巢狀值只依JSON語意: 物件鍵之值為undefined者略去, 陣列之undefined為null(正); 巢狀之null與空字串不等價、null與缺鍵不等價(反)', function() {
|
|
156
|
+
eq({ a: undefined, b: 1 }, { b: 1 })
|
|
157
|
+
eq([undefined], [null])
|
|
158
|
+
ne({ a: null }, { a: '' })
|
|
159
|
+
ne({ a: null }, {})
|
|
160
|
+
ne([null], [])
|
|
161
|
+
ne([null], [''])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('D11 走訪上限: 深度超過100、循環參考、節點超過10萬者拋錯(反); 深度50照常(正)', function() {
|
|
165
|
+
let deep = []
|
|
166
|
+
for (let i = 0; i < 200; i++) {
|
|
167
|
+
deep = [deep]
|
|
168
|
+
}
|
|
169
|
+
assert.throws(() => dg(deep), /too deep or circular/)
|
|
170
|
+
let cyc = { k: 1 }
|
|
171
|
+
cyc.self = cyc
|
|
172
|
+
assert.throws(() => dg(cyc), /too deep or circular/)
|
|
173
|
+
assert.throws(() => dg(new Array(100001).fill(1)), /too many nodes/)
|
|
174
|
+
//節點上限為單列(各欄共用), 非單欄
|
|
175
|
+
let C2 = { pk: 'id', fields: ['a', 'b'] }
|
|
176
|
+
assert.throws(() => digestRow({ id: 'x', a: new Array(60000).fill(1), b: new Array(60000).fill(1) }, C2), /too many nodes/)
|
|
177
|
+
assert.ok(/^h1:/.test(digestRow({ id: 'x', a: new Array(40000).fill(1), b: new Array(40000).fill(1) }, C2)))
|
|
178
|
+
let ok = []
|
|
179
|
+
for (let i = 0; i < 50; i++) {
|
|
180
|
+
ok = [ok]
|
|
181
|
+
}
|
|
182
|
+
assert.ok(/^h1:/.test(dg(ok)))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('D12 只讀自有屬性: 原型上之同名欄位視為缺欄(正), 自有屬性則採用(反)', function() {
|
|
186
|
+
let proto = { v: 'inherited' }
|
|
187
|
+
let row = Object.create(proto)
|
|
188
|
+
row.id = 'x'
|
|
189
|
+
assert.strict.equal(digestRow(row, F), dg(undefined))
|
|
190
|
+
row.v = 'own'
|
|
191
|
+
assert.strict.equal(digestRow(row, F), dg('own'))
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('D13 摘要格式與版本: h1前綴加14位小寫16進位; 凍結向量(演算法改動即失敗, 須改開h2而非改此值)', function() {
|
|
195
|
+
let C = { pk: 'id', fields: ['a', 'b', 'c'] }
|
|
196
|
+
assert.strict.equal(BASELINE_VERSION, 'h1')
|
|
197
|
+
assert.strict.equal(digestRow({ id: 'r1', a: 1, b: 'x', c: null }, C), 'h1:1782ae62ff0643')
|
|
198
|
+
assert.strict.equal(digestRow({ id: 'r1' }, C), 'h1:046d255e812572')
|
|
199
|
+
assert.strict.equal(digestRow({ id: 'r1', a: new Date('2026-10-22T00:00:00Z'), b: { y: 2, x: [1, 2] }, c: 5n }, C), 'h1:0b041d267aae0e')
|
|
200
|
+
//鍵序: 混合大小寫、非BMP與BMP之碼元序反例、整數樣鍵('10'與'9'), 序列化依UTF-16碼元序(localeCompare或原生整數鍵序皆會得到不同摘要)
|
|
201
|
+
assert.strict.equal(dg({ 'a': 1, 'B': 2, '😀': 1, '~': 2, '10': 3, '9': 4 }), 'h1:044b949aca9950')
|
|
202
|
+
assert.strict.equal(dg([5, '5', 5.5, true, null, 'a"b\\c\n', { k: [] }]), 'h1:091017815a9a45')
|
|
203
|
+
for (let v of [1, 'a', null, { z: 1 }, 'x'.repeat(1000)]) {
|
|
204
|
+
assert.ok(/^h1:[0-9a-f]{14}$/.test(dg(v)))
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('D14 cyrb53: 與公開向量一致且為0至2^53-1之整數', function() {
|
|
209
|
+
assert.strict.equal(cyrb53(''), 3338908027751811)
|
|
210
|
+
assert.strict.equal(cyrb53('a'), 7929297801672961)
|
|
211
|
+
assert.strict.equal(cyrb53('b'), 8684336938537663)
|
|
212
|
+
assert.notStrictEqual(cyrb53('a', 1), cyrb53('a'))
|
|
213
|
+
for (let s of ['', 'a', '中文😀', 'x'.repeat(5000)]) {
|
|
214
|
+
let n = cyrb53(s)
|
|
215
|
+
assert.ok(Number.isInteger(n) && n >= 0 && n < 2 ** 53)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('D15 -0與0同摘要(JSON語意, 凍結); 1e21與其數值同字串化', function() {
|
|
220
|
+
eq(-0, 0)
|
|
221
|
+
ne(1e21, 1e20)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
describe('baseline 契約驗證 checkContract(規格 §3)', function() {
|
|
228
|
+
|
|
229
|
+
let ok = { table: 't', pk: 'id', fields: ['a', 'b'], serverOnly: ['s'], orderKey: 'order', scope: () => true }
|
|
230
|
+
|
|
231
|
+
it('K01 合法契約通過(正): 最小形、完整形、serverOnly/orderKey/scope/table為null或未給', function() {
|
|
232
|
+
assert.strict.equal(checkContract({ pk: 'id', fields: ['a'] }), true)
|
|
233
|
+
assert.strict.equal(checkContract(ok), true)
|
|
234
|
+
assert.strict.equal(checkContract({ pk: 'id', fields: ['a'], serverOnly: null, orderKey: null, scope: null, table: null }), true)
|
|
235
|
+
assert.strict.equal(checkContract({ pk: 'id', fields: ['id', 'a'] }), true, 'pk可同在fields')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('K02 缺pk或pk非非空字串者拋錯(反)', function() {
|
|
239
|
+
for (let pk of [undefined, null, '', 5, ['id']]) {
|
|
240
|
+
assert.throws(() => checkContract({ ...ok, pk }), /pk needs a non-empty string/)
|
|
241
|
+
}
|
|
242
|
+
let c = { ...ok }
|
|
243
|
+
delete c.pk
|
|
244
|
+
assert.throws(() => checkContract(c), /pk needs a non-empty string/)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('K03 缺fields、非陣列、空陣列、含非字串或空字串或重複者拋錯(反)', function() {
|
|
248
|
+
for (let fields of [undefined, null, 'a', {}, [], [1], [''], ['a', 'a'], [null]]) {
|
|
249
|
+
assert.throws(() => checkContract({ ...ok, fields }), /invalid contract: fields/)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('K04 fields含orderKey者拋錯(反); orderKey等於pk者拋錯(反)', function() {
|
|
254
|
+
assert.throws(() => checkContract({ ...ok, fields: ['a', 'order'] }), /fields must not contain orderKey/)
|
|
255
|
+
assert.throws(() => checkContract({ ...ok, orderKey: 'id' }), /orderKey must not be pk/)
|
|
256
|
+
assert.throws(() => checkContract({ ...ok, orderKey: '' }), /orderKey needs a non-empty string/)
|
|
257
|
+
assert.throws(() => checkContract({ ...ok, orderKey: 5 }), /orderKey needs a non-empty string/)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('K05 serverOnly與fields有交集者拋錯(反); serverOnly非陣列或含重複者拋錯(反)', function() {
|
|
261
|
+
assert.throws(() => checkContract({ ...ok, serverOnly: ['b'] }), /serverOnly and fields must not overlap/)
|
|
262
|
+
assert.throws(() => checkContract({ ...ok, serverOnly: 's' }), /invalid contract: serverOnly/)
|
|
263
|
+
assert.throws(() => checkContract({ ...ok, serverOnly: ['s', 's'] }), /invalid contract: serverOnly/)
|
|
264
|
+
assert.strict.equal(checkContract({ ...ok, serverOnly: [] }), true)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('K06 scope非函數、table非字串、未知鍵、契約非純物件者拋錯(反)', function() {
|
|
268
|
+
assert.throws(() => checkContract({ ...ok, scope: 'x' }), /scope needs a function/)
|
|
269
|
+
assert.throws(() => checkContract({ ...ok, table: 5 }), /table needs a non-empty string/)
|
|
270
|
+
assert.throws(() => checkContract({ ...ok, orderkey: 'order' }), /unsupported key/, '拼錯之鍵不得靜默忽略')
|
|
271
|
+
//不可列舉之鍵與Symbol鍵亦為未知鍵(Object.keys只列可列舉之字串鍵, 會漏掉)
|
|
272
|
+
let hiddenKey = { ...ok }
|
|
273
|
+
Object.defineProperty(hiddenKey, 'orderkey', { value: 'order', enumerable: false })
|
|
274
|
+
assert.throws(() => checkContract(hiddenKey), /unsupported key/)
|
|
275
|
+
assert.throws(() => checkContract({ ...ok, [Symbol('x')]: 1 }), /unsupported key/)
|
|
276
|
+
for (let c of [undefined, null, 'x', 5, [], () => 1, new Map()]) {
|
|
277
|
+
assert.throws(() => checkContract(c), /invalid contract: need an object/)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('K07 digestRow、buildBaseline、compareBaseline皆先驗證契約(反)', function() {
|
|
282
|
+
let bad = { pk: 'id' }
|
|
283
|
+
assert.throws(() => digestRow({ id: 'a' }, bad), /invalid contract/)
|
|
284
|
+
assert.throws(() => buildBaseline([], bad), /invalid contract/)
|
|
285
|
+
assert.throws(() => compareBaseline([], [], bad), /invalid contract/)
|
|
286
|
+
assert.throws(() => compareBaseline([], [], undefined), /invalid contract/)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
describe('baseline 主鍵與建立基準 buildBaseline(規格 §5)', function() {
|
|
293
|
+
|
|
294
|
+
let C = { pk: 'id', fields: ['n'] }
|
|
295
|
+
let ids = (bl) => bl.map((v) => v[0])
|
|
296
|
+
|
|
297
|
+
it('P01 主鍵轉字串: 有限數字與BigInt轉十進位字串(正), 數字5與字串\'5\'同批視為重複(反)', function() {
|
|
298
|
+
assert.deepStrictEqual(ids(buildBaseline([{ id: 5, n: 1 }, { id: 7n, n: 1 }, { id: 'x', n: 1 }], C)), ['5', '7', 'x'])
|
|
299
|
+
let e = getThrow(() => buildBaseline([{ id: 5 }, { id: '5' }], C))
|
|
300
|
+
assert.strict.equal(e.key, BASELINE_REJECT.format)
|
|
301
|
+
assert.ok(/duplicate primary key/.test(e.message))
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('P02 主鍵缺漏者拋baselineFormat: undefined、null、空字串、布林、物件、NaN、Infinity、無此欄', function() {
|
|
305
|
+
for (let id of [undefined, null, '', true, {}, [], NaN, Infinity]) {
|
|
306
|
+
let e = getThrow(() => buildBaseline([{ id, n: 1 }], C))
|
|
307
|
+
assert.strict.equal(e.key, BASELINE_REJECT.format)
|
|
308
|
+
assert.ok(/no valid primary key/.test(e.message))
|
|
309
|
+
}
|
|
310
|
+
let e = getThrow(() => buildBaseline([{ n: 1 }], C))
|
|
311
|
+
assert.strict.equal(e.key, BASELINE_REJECT.format)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('P03 同批重複主鍵拋baselineFormat(反); 不同主鍵照常(正); 訊息不回顯主鍵值', function() {
|
|
315
|
+
let e = getThrow(() => buildBaseline([{ id: 'secret-a' }, { id: 'b' }, { id: 'secret-a' }], C))
|
|
316
|
+
assert.strict.equal(e.key, BASELINE_REJECT.format)
|
|
317
|
+
assert.ok(!/secret-a/.test(e.message))
|
|
318
|
+
assert.ok(/rows\[2\]/.test(e.message))
|
|
319
|
+
assert.strict.equal(buildBaseline([{ id: 'a' }, { id: 'b' }], C).length, 2)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('P04 列非物件或rows非陣列者拋錯(反)', function() {
|
|
323
|
+
assert.throws(() => buildBaseline('x', C), /invalid rows: need an array/)
|
|
324
|
+
assert.throws(() => buildBaseline(null, C), /invalid rows: need an array/)
|
|
325
|
+
for (let row of [null, undefined, 'x', 5, [1]]) {
|
|
326
|
+
assert.throws(() => buildBaseline([{ id: 'a' }, row], C), /invalid row: rows\[1\] needs an object/)
|
|
327
|
+
}
|
|
328
|
+
assert.deepStrictEqual(buildBaseline([], C), [])
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('P05 無orderKey依主鍵碼元序: 大寫在小寫之前、\'10\'在\'9\'之前、碼元序非碼點序(正); 與localeCompare序不同(反)', function() {
|
|
332
|
+
let rows = ['b', 'a', 'B', '9', '10', '~', '😀'].map((id) => ({ id, n: 1 }))
|
|
333
|
+
let got = ids(buildBaseline(rows, C))
|
|
334
|
+
assert.deepStrictEqual(got, ['10', '9', 'B', 'a', 'b', '😀', '~'])
|
|
335
|
+
let loc = [...got].sort((a, b) => a.localeCompare(b))
|
|
336
|
+
assert.notDeepStrictEqual(got, loc, '若改用localeCompare, 結果會不同')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('P06 有orderKey依(order數值, 主鍵碼元序): 數字字串依數值、同order依主鍵、缺漏或無法轉數字者排最後(正); 不依主鍵序(反)', function() {
|
|
340
|
+
let CO = { pk: 'id', fields: ['n'], orderKey: 'order' }
|
|
341
|
+
let rows = [
|
|
342
|
+
{ id: 'a', order: '10' },
|
|
343
|
+
{ id: 'b', order: 9 },
|
|
344
|
+
{ id: 'c', order: 9 },
|
|
345
|
+
{ id: 'd' },
|
|
346
|
+
{ id: 'e', order: 'x' },
|
|
347
|
+
{ id: 'f', order: null },
|
|
348
|
+
{ id: 'g', order: ' ' },
|
|
349
|
+
{ id: 'h', order: 2n },
|
|
350
|
+
{ id: 'i', order: NaN },
|
|
351
|
+
{ id: 'j', order: -1 },
|
|
352
|
+
]
|
|
353
|
+
assert.deepStrictEqual(ids(buildBaseline(rows, CO)), ['j', 'h', 'b', 'c', 'a', 'd', 'e', 'f', 'g', 'i'])
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('P06b 名次之排序值為精確比較: 超過2^53之BigInt與整數字串不轉Number, 相鄰值不被視為同值而使重排漏判(反); 與Number混比亦精確', function() {
|
|
357
|
+
let CO = { pk: 'id', fields: ['n'], orderKey: 'order' }
|
|
358
|
+
//BigInt: 9007199254740992n 與 9007199254740993n 轉Number後相同
|
|
359
|
+
let rows = [{ id: 'a', order: 9007199254740993n }, { id: 'b', order: 9007199254740992n }]
|
|
360
|
+
assert.deepStrictEqual(ids(buildBaseline(rows, CO)), ['b', 'a'])
|
|
361
|
+
let bl = buildBaseline(rows, CO)
|
|
362
|
+
let swapped = [{ id: 'a', order: 9007199254740992n }, { id: 'b', order: 9007199254740993n }]
|
|
363
|
+
let r = compareBaseline(swapped, bl, CO)
|
|
364
|
+
assert.strict.equal(r.ok, false)
|
|
365
|
+
assert.strict.equal(r.reordered, true, '交換兩個超過2^53之order須被偵測為重排')
|
|
366
|
+
//整數字串(例如pg之BIGINT以字串回傳)
|
|
367
|
+
let rowsS = [{ id: 'a', order: '9007199254740993' }, { id: 'b', order: '9007199254740992' }]
|
|
368
|
+
assert.deepStrictEqual(ids(buildBaseline(rowsS, CO)), ['b', 'a'])
|
|
369
|
+
//字串、BigInt、Number混用依數值: '10' > 9 > 8n; 帶符號與前導零
|
|
370
|
+
let mixed = [{ id: 'a', order: '10' }, { id: 'b', order: 9 }, { id: 'c', order: 8n }, { id: 'd', order: '+007' }, { id: 'e', order: '-1' }, { id: 'f', order: 2.5 }]
|
|
371
|
+
assert.deepStrictEqual(ids(buildBaseline(mixed, CO)), ['e', 'f', 'd', 'c', 'b', 'a'])
|
|
372
|
+
//Number與BigInt之精確比較(9007199254740993n大於Number 9007199254740992)
|
|
373
|
+
let nb = [{ id: 'a', order: 9007199254740993n }, { id: 'b', order: 9007199254740992 }]
|
|
374
|
+
assert.deepStrictEqual(ids(buildBaseline(nb, CO)), ['b', 'a'])
|
|
375
|
+
//缺漏仍排最後, 且在BigInt之後
|
|
376
|
+
assert.deepStrictEqual(ids(buildBaseline([{ id: 'x' }, { id: 'y', order: 9007199254740993n }], CO)), ['y', 'x'])
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('P07 逐列基準只含該列; 輸出為[id, digest]之陣列且digest符合格式', function() {
|
|
380
|
+
let bl = buildBaseline([{ id: 'only', n: 1 }], C)
|
|
381
|
+
assert.strict.equal(bl.length, 1)
|
|
382
|
+
assert.strict.equal(bl[0][0], 'only')
|
|
383
|
+
assert.ok(/^h1:[0-9a-f]{14}$/.test(bl[0][1]))
|
|
384
|
+
assert.strict.equal(bl[0][1], digestRow({ id: 'only', n: 1 }, C))
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('P08 scope: 只留scope回傳真值之列(正); 無scope時全留(反); scope收到列物件', function() {
|
|
388
|
+
let rows = [{ id: 'a', n: 1, isApp: 'y' }, { id: 'b', n: 1, isApp: 'n' }, { id: 'c', n: 1 }]
|
|
389
|
+
let seen = []
|
|
390
|
+
let CS = {
|
|
391
|
+
pk: 'id',
|
|
392
|
+
fields: ['n'],
|
|
393
|
+
scope: (r) => {
|
|
394
|
+
seen.push(r.id); return r.isApp === 'y'
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
assert.deepStrictEqual(ids(buildBaseline(rows, CS)), ['a'])
|
|
398
|
+
assert.deepStrictEqual(seen, ['a', 'b', 'c'])
|
|
399
|
+
assert.deepStrictEqual(ids(buildBaseline(rows, C)), ['a', 'b', 'c'])
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('P09 scope外之列不檢查主鍵(範圍外可有缺漏), 範圍內缺漏則拋錯', function() {
|
|
403
|
+
let CS = { pk: 'id', fields: ['n'], scope: (r) => r.isApp === 'y' }
|
|
404
|
+
assert.deepStrictEqual(ids(buildBaseline([{ isApp: 'n' }, { id: 'a', isApp: 'y' }], CS)), ['a'])
|
|
405
|
+
assert.throws(() => buildBaseline([{ isApp: 'y' }], CS), /no valid primary key/)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
describe('baseline 格式與版本 isBaselineValid(規格 §5 版本)', function() {
|
|
412
|
+
|
|
413
|
+
let good = 'h1:0123456789abcd'
|
|
414
|
+
|
|
415
|
+
it('F01 合法基準(正): 空陣列、多列', function() {
|
|
416
|
+
assert.strict.equal(isBaselineValid([]), true)
|
|
417
|
+
assert.strict.equal(isBaselineValid([['a', good], ['b', good]]), true)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('F02 非陣列、項非[字串,字串]、長度不為2、id為空字串者不合法(反)', function() {
|
|
421
|
+
for (let b of [undefined, null, 'x', 5, {}, [null], ['a'], [['a']], [['a', good, 'x']], [['', good]], [[1, good]], [['a', 1]], [['a', null]], [[null, good]], [{ 0: 'a', 1: good, length: 2 }]]) {
|
|
422
|
+
assert.strict.equal(isBaselineValid(b), false, JSON.stringify(b))
|
|
423
|
+
}
|
|
424
|
+
//稀疏陣列之空洞
|
|
425
|
+
let sp = new Array(2)
|
|
426
|
+
sp[1] = ['a', good]
|
|
427
|
+
assert.strict.equal(isBaselineValid(sp), false)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('F03 不識之版本與摘要格式不合法(反): h2、無前綴、長度不符、大寫、非16進位、舊套內格式', function() {
|
|
431
|
+
for (let d of ['h2:0123456789abcd', '0123456789abcd', 'h1:0123456789abc', 'h1:0123456789abcde', 'h1:0123456789ABCD', 'h1:0123456789abcg', 'H1:0123456789abcd', 'h1:', '', 'm5zq9x1k']) {
|
|
432
|
+
assert.strict.equal(isBaselineValid([['a', d]]), false, d)
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('F04 同id出現兩次不合法(反), 即使摘要相同', function() {
|
|
437
|
+
assert.strict.equal(isBaselineValid([['a', good], ['a', good]]), false)
|
|
438
|
+
assert.strict.equal(isBaselineValid([['a', good], ['b', good]]), true)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
describe('baseline 比對 compareBaseline(規格 §6)', function() {
|
|
445
|
+
|
|
446
|
+
let C = { pk: 'id', fields: ['name', 'tags'], orderKey: 'order' }
|
|
447
|
+
let rows0 = () => [
|
|
448
|
+
{ id: 'a', name: 'A', tags: ['x'], order: 1, timeUpdate: 't1' },
|
|
449
|
+
{ id: 'b', name: 'B', tags: [], order: 2, timeUpdate: 't1' },
|
|
450
|
+
{ id: 'c', name: 'C', tags: null, order: 3, timeUpdate: 't1' },
|
|
451
|
+
]
|
|
452
|
+
let bl0 = () => buildBaseline(rows0(), C)
|
|
453
|
+
|
|
454
|
+
it('C01 相符回{ ok: true }(只有此鍵): 同內容、系統欄位與serverOnly變動、order重編號(名次不變)皆相符', function() {
|
|
455
|
+
assert.deepStrictEqual(compareBaseline(rows0(), bl0(), C), { ok: true })
|
|
456
|
+
let rows = rows0().map((r) => ({ ...r, timeUpdate: 't2', userIdUpdate: 'u9', order: r.order * 10 }))
|
|
457
|
+
assert.deepStrictEqual(compareBaseline(rows, bl0(), C), { ok: true }, '重編號不被偵測')
|
|
458
|
+
assert.deepStrictEqual(compareBaseline([...rows0()].reverse(), bl0(), C), { ok: true }, '讀取次序無關')
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('C02 內容改變: changed列出該id, reason為baselineMismatch(反)', function() {
|
|
462
|
+
let rows = rows0()
|
|
463
|
+
rows[1].name = 'B2'
|
|
464
|
+
let r = compareBaseline(rows, bl0(), C)
|
|
465
|
+
assert.deepStrictEqual(r, { ok: false, reason: 'baselineMismatch', added: [], removed: [], changed: ['b'], reordered: false })
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('C03 新增與刪除: added與removed各列出id(反)', function() {
|
|
469
|
+
let rows = rows0().filter((r) => r.id !== 'a')
|
|
470
|
+
rows.push({ id: 'd', name: 'D', tags: [], order: 4 })
|
|
471
|
+
let r = compareBaseline(rows, bl0(), C)
|
|
472
|
+
assert.deepStrictEqual(r, { ok: false, reason: 'baselineMismatch', added: ['d'], removed: ['a'], changed: [], reordered: false })
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('C04 他人重排(名次改變)被偵測: reordered為true, changed為空(反); 無orderKey之表依主鍵排序, 同樣之order變動不算(正)', function() {
|
|
476
|
+
let rows = rows0()
|
|
477
|
+
rows[0].order = 2
|
|
478
|
+
rows[1].order = 1
|
|
479
|
+
let r = compareBaseline(rows, bl0(), C)
|
|
480
|
+
assert.strict.equal(r.ok, false)
|
|
481
|
+
assert.strict.equal(r.reason, 'baselineMismatch')
|
|
482
|
+
assert.strict.equal(r.reordered, true)
|
|
483
|
+
assert.deepStrictEqual(r.changed, [])
|
|
484
|
+
let C2 = { pk: 'id', fields: ['name', 'tags'] }
|
|
485
|
+
assert.deepStrictEqual(compareBaseline(rows, buildBaseline(rows0(), C2), C2), { ok: true })
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('C05 系統重編號(名次不變)不被偵測(正): order值全換但相對次序同; 次序相同之跳號亦同', function() {
|
|
489
|
+
let rows = rows0()
|
|
490
|
+
rows[0].order = 100
|
|
491
|
+
rows[1].order = 200
|
|
492
|
+
rows[2].order = 300
|
|
493
|
+
assert.deepStrictEqual(compareBaseline(rows, bl0(), C), { ok: true })
|
|
494
|
+
rows[2].order = 250
|
|
495
|
+
assert.deepStrictEqual(compareBaseline(rows, bl0(), C), { ok: true })
|
|
496
|
+
rows[2].order = 150
|
|
497
|
+
assert.strict.equal(compareBaseline(rows, bl0(), C).reordered, true, '落在a與b之間則名次改變(反)')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('C06 名次變動附帶增刪: 移動一列至最後仍為reordered(反)', function() {
|
|
501
|
+
let rows = rows0()
|
|
502
|
+
rows[0].order = 99
|
|
503
|
+
let r = compareBaseline(rows, bl0(), C)
|
|
504
|
+
assert.strict.equal(r.reordered, true)
|
|
505
|
+
assert.strict.equal(r.ok, false)
|
|
506
|
+
|
|
507
|
+
//重排同時有增刪: 基準 a,b,c; 目前 b,a,d(c 被刪、d 新增、a 與 b 對調) → added[d]、removed[c]、reordered true、changed 空
|
|
508
|
+
let now = [
|
|
509
|
+
{ id: 'b', name: 'B', tags: [], order: 1 },
|
|
510
|
+
{ id: 'a', name: 'A', tags: ['x'], order: 2 },
|
|
511
|
+
{ id: 'd', name: 'D', tags: [], order: 3 },
|
|
512
|
+
]
|
|
513
|
+
assert.deepStrictEqual(compareBaseline(now, bl0(), C), { ok: false, reason: 'baselineMismatch', added: ['d'], removed: ['c'], changed: [], reordered: true })
|
|
514
|
+
//增刪但共有之列次序不變 → reordered 為 false(增刪不算重排)
|
|
515
|
+
let now2 = [
|
|
516
|
+
{ id: 'a', name: 'A', tags: ['x'], order: 1 },
|
|
517
|
+
{ id: 'd', name: 'D', tags: [], order: 2 },
|
|
518
|
+
{ id: 'b', name: 'B', tags: [], order: 3 },
|
|
519
|
+
]
|
|
520
|
+
assert.deepStrictEqual(compareBaseline(now2, bl0(), C), { ok: false, reason: 'baselineMismatch', added: ['d'], removed: ['c'], changed: [], reordered: false })
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('C07 單列基準且該列已不在、無新增列: reason為rowNotFound(反); 另有新增列或多列基準則為baselineMismatch', function() {
|
|
524
|
+
let bl = buildBaseline([rows0()[0]], C)
|
|
525
|
+
let r = compareBaseline([], bl, C)
|
|
526
|
+
assert.deepStrictEqual(r, { ok: false, reason: 'rowNotFound', added: [], removed: ['a'], changed: [], reordered: false })
|
|
527
|
+
let r2 = compareBaseline([{ id: 'z', name: 'Z' }], bl, C)
|
|
528
|
+
assert.strict.equal(r2.reason, 'baselineMismatch')
|
|
529
|
+
let r3 = compareBaseline([], bl0(), C)
|
|
530
|
+
assert.strict.equal(r3.reason, 'baselineMismatch')
|
|
531
|
+
assert.deepStrictEqual(compareBaseline([rows0()[0]], bl, C), { ok: true }, '該列仍在且未變則相符(正)')
|
|
532
|
+
let rows = rows0().slice(0, 1)
|
|
533
|
+
rows[0].name = 'changed'
|
|
534
|
+
assert.strict.equal(compareBaseline(rows, bl, C).reason, 'baselineMismatch', '該列在但已改: 非rowNotFound')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('C08 baselineFormat(反): 非陣列、null、undefined、項格式錯、不識之版本、重複id; 不比對且各差異欄為空', function() {
|
|
538
|
+
let none = { ok: false, reason: 'baselineFormat', added: [], removed: [], changed: [], reordered: false }
|
|
539
|
+
for (let b of [undefined, null, 'x', {}, [['a']], [['a', 'h2:0123456789abcd']], [['a', 'h1:0123456789abcd'], ['a', 'h1:0123456789abcd']], [['', 'h1:0123456789abcd']]]) {
|
|
540
|
+
assert.deepStrictEqual(compareBaseline(rows0(), b, C), none, JSON.stringify(b))
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('C09 空表: 空基準對空列相符(正), 對有列者為added(反); 有基準而列為空者為removed', function() {
|
|
545
|
+
assert.deepStrictEqual(compareBaseline([], [], C), { ok: true })
|
|
546
|
+
let r = compareBaseline(rows0(), [], C)
|
|
547
|
+
assert.deepStrictEqual(r.added, ['a', 'b', 'c'])
|
|
548
|
+
assert.strict.equal(r.reason, 'baselineMismatch')
|
|
549
|
+
let r2 = compareBaseline([], bl0(), C)
|
|
550
|
+
assert.deepStrictEqual(r2.removed, ['a', 'b', 'c'])
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('C10 庫內列之主鍵缺漏或重複拋key為rowsInvalid之Error(庫內資料異常, 非客戶端之基準格式錯誤, 不得與baselineFormat混為一類), 非回傳值', function() {
|
|
554
|
+
let e = getThrow(() => compareBaseline([{ id: 'a' }, { id: 'a' }], bl0(), C))
|
|
555
|
+
assert.strict.equal(e.key, 'rowsInvalid')
|
|
556
|
+
assert.notStrictEqual(e.key, BASELINE_REJECT.format)
|
|
557
|
+
assert.ok(/invalid rowsNow/.test(e.message))
|
|
558
|
+
let e2 = getThrow(() => compareBaseline([{ name: 'x' }], bl0(), C))
|
|
559
|
+
assert.strict.equal(e2.key, 'rowsInvalid')
|
|
560
|
+
//buildBaseline本身(如前端對畫面所見之列自算)仍為baselineFormat
|
|
561
|
+
assert.strict.equal(getThrow(() => buildBaseline([{ id: 'a' }, { id: 'a' }], C)).key, BASELINE_REJECT.format)
|
|
562
|
+
//客戶端基準之格式錯誤優先回傳baselineFormat(不讀rowsNow), 即使rowsNow異常
|
|
563
|
+
assert.strict.equal(compareBaseline([{ id: 'a' }, { id: 'a' }], [['a']], C).reason, 'baselineFormat')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('C11 scope(V8): 範圍外新增之列不影響(正), 範圍內新增為added(反); 列移出範圍為removed, 移入為added', function() {
|
|
567
|
+
let CS = { pk: 'id', fields: ['name'], scope: (r) => r.isApp === 'y' }
|
|
568
|
+
let base = [{ id: 'a', name: 'A', isApp: 'y' }, { id: 's1', name: 'S', isApp: 'n' }]
|
|
569
|
+
let bl = buildBaseline(base, CS)
|
|
570
|
+
assert.deepStrictEqual(bl.map((v) => v[0]), ['a'])
|
|
571
|
+
assert.deepStrictEqual(compareBaseline([...base, { id: 's2', name: 'S2', isApp: 'n' }], bl, CS), { ok: true })
|
|
572
|
+
assert.deepStrictEqual(compareBaseline([{ id: 'a', name: 'A', isApp: 'y' }, { id: 's1', name: 'S', isApp: 'n' }, { id: 'n', name: 'N', isApp: 'y' }], bl, CS).added, ['n'])
|
|
573
|
+
assert.deepStrictEqual(compareBaseline([{ id: 'a', name: 'A', isApp: 'n' }], bl, CS).removed, ['a'])
|
|
574
|
+
assert.deepStrictEqual(compareBaseline([{ id: 'a', name: 'A', isApp: 'y' }, { id: 's1', name: 'S', isApp: 'y' }], bl, CS).added, ['s1'])
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('C12 傳輸等價(V10之純函數部分): 伺服端列(Date、BigInt、number id、undefined欄)與經JSON傳輸之列算出同一基準(正); 型別真的不同(number對string)則不同(反)', function() {
|
|
578
|
+
let CT = { pk: 'id', fields: ['when', 'big', 'obj', 'gone'], orderKey: 'order' }
|
|
579
|
+
let server = [
|
|
580
|
+
{ id: 1, order: 1, when: new Date('2026-10-22T01:02:03.004Z'), big: 12n, obj: { b: 1, a: { d: undefined, c: [1, undefined] } }, gone: undefined },
|
|
581
|
+
{ id: 2, order: 2, when: null, big: undefined, obj: { z: null }, gone: '' },
|
|
582
|
+
]
|
|
583
|
+
let wire = JSON.parse(JSON.stringify(server, (k, v) => (typeof v === 'bigint' ? v.toString() : v)))
|
|
584
|
+
assert.deepStrictEqual(buildBaseline(wire, CT), buildBaseline(server, CT))
|
|
585
|
+
assert.deepStrictEqual(compareBaseline(server, buildBaseline(wire, CT), CT), { ok: true })
|
|
586
|
+
let wire2 = JSON.parse(JSON.stringify(wire))
|
|
587
|
+
wire2[0].big = 12
|
|
588
|
+
assert.notDeepStrictEqual(buildBaseline(wire2, CT), buildBaseline(server, CT))
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('C13 compareBaseline不改動輸入(rowsNow、baseline、contract)', function() {
|
|
592
|
+
let rows = rows0()
|
|
593
|
+
let bl = bl0()
|
|
594
|
+
let snapRows = JSON.stringify(rows)
|
|
595
|
+
let snapBl = JSON.stringify(bl)
|
|
596
|
+
let C3 = { ...C }
|
|
597
|
+
compareBaseline(rows, bl, C3)
|
|
598
|
+
compareBaseline([], bl, C3)
|
|
599
|
+
assert.strict.equal(JSON.stringify(rows), snapRows)
|
|
600
|
+
assert.strict.equal(JSON.stringify(bl), snapBl)
|
|
601
|
+
assert.deepStrictEqual(C3, C)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('C14 大表: 5000列之基準與比對一致且逐列變動可被找出', function() {
|
|
605
|
+
let rows = []
|
|
606
|
+
for (let i = 0; i < 5000; i++) {
|
|
607
|
+
rows.push({ id: `id-${i}`, name: `n${i}`, tags: [i], order: i })
|
|
608
|
+
}
|
|
609
|
+
let bl = buildBaseline(rows, C)
|
|
610
|
+
assert.strict.equal(bl.length, 5000)
|
|
611
|
+
assert.deepStrictEqual(compareBaseline(rows, bl, C), { ok: true })
|
|
612
|
+
rows[4321].tags = [-1]
|
|
613
|
+
assert.deepStrictEqual(compareBaseline(rows, bl, C).changed, ['id-4321'])
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
describe('baseline 輔助: pickWriteRow(存檔白名單, V5)與getRejectKey', function() {
|
|
620
|
+
|
|
621
|
+
let C = { pk: 'id', fields: ['name', 'blocked'], serverOnly: ['pw'], orderKey: 'order' }
|
|
622
|
+
|
|
623
|
+
it('H01 pickWriteRow只留pk與fields: 伺服器專有欄位(舊值)、系統欄位、orderKey、未知欄位皆不輸出(V5: 客戶端持舊列整列送出時不回寫舊值)', function() {
|
|
624
|
+
let stale = { id: 'u1', name: 'A2', blocked: 'n', pw: 'OLD', order: 3, timeUpdate: 't', userIdUpdate: 'u', other: 'x' }
|
|
625
|
+
assert.deepStrictEqual(pickWriteRow(stale, C), { id: 'u1', name: 'A2', blocked: 'n' })
|
|
626
|
+
//未給或undefined之欄位不輸出(save對未給之欄位保留庫內值), null與空字串照給(使用者清空之意圖)
|
|
627
|
+
assert.deepStrictEqual(pickWriteRow({ id: 'u1', name: undefined, blocked: null }, C), { id: 'u1', blocked: null })
|
|
628
|
+
assert.deepStrictEqual(pickWriteRow({ id: 'u1', name: '' }, C), { id: 'u1', name: '' })
|
|
629
|
+
//只讀自有屬性
|
|
630
|
+
let row = Object.create({ name: 'inherited' })
|
|
631
|
+
row.id = 'u1'
|
|
632
|
+
assert.deepStrictEqual(pickWriteRow(row, C), { id: 'u1' })
|
|
633
|
+
//回傳新物件, 不改輸入
|
|
634
|
+
let src = { id: 'u1', name: 'A' }
|
|
635
|
+
let out = pickWriteRow(src, C)
|
|
636
|
+
assert.notStrictEqual(out, src)
|
|
637
|
+
assert.deepStrictEqual(src, { id: 'u1', name: 'A' })
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('H02 pickWriteRow: pk同在fields內不重複輸出; 鍵為__proto__時為自有屬性而非改設原型; 契約或列無效者拋錯', function() {
|
|
641
|
+
assert.deepStrictEqual(pickWriteRow({ id: 'u1', name: 'A' }, { pk: 'id', fields: ['id', 'name'] }), { id: 'u1', name: 'A' })
|
|
642
|
+
let row = JSON.parse('{"id":"u1","__proto__":{"polluted":1},"name":"A"}')
|
|
643
|
+
let out = pickWriteRow(row, { pk: 'id', fields: ['__proto__', 'name'] })
|
|
644
|
+
assert.strict.equal(Object.getPrototypeOf(out), Object.prototype)
|
|
645
|
+
assert.strict.equal(Object.prototype.hasOwnProperty.call(out, '__proto__'), true)
|
|
646
|
+
assert.strict.equal(({}).polluted, undefined)
|
|
647
|
+
assert.throws(() => pickWriteRow({ id: 'u1' }, { pk: 'id' }), /invalid contract/)
|
|
648
|
+
for (let r of [null, undefined, 'x', [1], new Map()]) {
|
|
649
|
+
assert.throws(() => pickWriteRow(r, C), /invalid row: need an object/)
|
|
650
|
+
}
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('H03 getRejectKey: 字串原樣(procOrm之rowNotFound)、帶字串key之Error取其key(guardWrite)、其餘為null(一般例外)', function() {
|
|
654
|
+
assert.strict.equal(getRejectKey('rowNotFound'), 'rowNotFound')
|
|
655
|
+
assert.strict.equal(getRejectKey('failed to save data'), 'failed to save data')
|
|
656
|
+
let e = new Error('baselineMismatch')
|
|
657
|
+
e.key = 'baselineMismatch'
|
|
658
|
+
assert.strict.equal(getRejectKey(e), 'baselineMismatch')
|
|
659
|
+
assert.strict.equal(getRejectKey(new Error('plain')), null)
|
|
660
|
+
let e2 = new Error('x')
|
|
661
|
+
e2.key = 5
|
|
662
|
+
assert.strict.equal(getRejectKey(e2), null)
|
|
663
|
+
for (let v of [null, undefined, 5, {}, [], () => 1]) {
|
|
664
|
+
assert.strict.equal(getRejectKey(v), null)
|
|
665
|
+
}
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
describe('baseline 同構與零依賴(規格 §8、V10)', function() {
|
|
672
|
+
|
|
673
|
+
it('S01 靜態: 原始碼無import、require、動態import、Node專屬全域', function() {
|
|
674
|
+
let src = fs.readFileSync(fpSrc, 'utf8')
|
|
675
|
+
let code = stripComments(src)
|
|
676
|
+
assert.ok(!/^\s*import\s/m.test(code), '不得有import敘述')
|
|
677
|
+
assert.ok(!/\bimport\s*\(/.test(code), '不得有動態import')
|
|
678
|
+
assert.ok(!/\brequire\s*\(/.test(code), '不得有require')
|
|
679
|
+
for (let name of ['process', 'Buffer', '__dirname', '__filename', 'global', 'module', 'exports', 'setImmediate']) {
|
|
680
|
+
assert.ok(!new RegExp(`(^|[^.\\w$])${name}\\b`).test(code), `不得使用Node專屬全域: ${name}`)
|
|
681
|
+
}
|
|
682
|
+
assert.strict.equal((code.match(/^export\s/gm) || []).length, 1, '末尾須為單一export敘述')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('S02 靜態: 不用舊版打包器無法解析之語法(?.、??、**、BigInt字面值、class欄位、async迭代)', function() {
|
|
686
|
+
let src = fs.readFileSync(fpSrc, 'utf8')
|
|
687
|
+
let code = stripComments(src)
|
|
688
|
+
assert.ok(!/\?\.[A-Za-z_$[(]/.test(code), '不得用?.')
|
|
689
|
+
assert.ok(!/\?\?/.test(code), '不得用??')
|
|
690
|
+
assert.ok(!/\*\*/.test(code), '不得用**')
|
|
691
|
+
assert.ok(!/[^\w$.]\d+n\b/.test(code), '不得用BigInt字面值')
|
|
692
|
+
assert.ok(!/for\s+await/.test(code))
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it('S03 動態: 於只有標準全域之vm執行同一原始碼, 與Node直接import算出完全相同之摘要與基準與比對結果', function() {
|
|
696
|
+
let src = fs.readFileSync(fpSrc, 'utf8')
|
|
697
|
+
let m = src.match(/^export\s*\{([^}]*)\}\s*$/m)
|
|
698
|
+
assert.ok(m, '找不到單一export敘述')
|
|
699
|
+
let code = src.replace(m[0], `globalThis.__B = { ${m[1]} }`)
|
|
700
|
+
let ctx = vm.createContext({})
|
|
701
|
+
vm.runInContext(code, ctx)
|
|
702
|
+
let B = ctx.__B
|
|
703
|
+
|
|
704
|
+
//沙箱無Node全域
|
|
705
|
+
assert.strict.equal(vm.runInContext('typeof process + typeof require + typeof Buffer + typeof module + typeof global', ctx), 'undefinedundefinedundefinedundefinedundefined')
|
|
706
|
+
assert.deepStrictEqual(Object.keys(B).sort(), Object.keys({ BASELINE_VERSION, BASELINE_REJECT, cyrb53, checkContract, digestRow, buildBaseline, isBaselineValid, compareBaseline, pickWriteRow, getRejectKey }).sort())
|
|
707
|
+
|
|
708
|
+
//fixture原始碼, 分別於Node與vm內求值(物件、Date、BigInt、函數各自屬於該realm)
|
|
709
|
+
let fixture = `({
|
|
710
|
+
contract: { table: 't', pk: 'id', fields: ['when', 'big', 'obj', 'arr', 'text', 'n', 'flag', 'gone'], serverOnly: ['s'], orderKey: 'order', scope: (r) => r.isApp !== 'n' },
|
|
711
|
+
rows: [
|
|
712
|
+
{ id: 'b', order: 2, when: new Date(Date.UTC(2026, 9, 22, 1, 2, 3, 4)), big: 12345678901234567890n, obj: { z: 1, a: { y: [1, { q: 2, p: undefined }], x: null } }, arr: [null, undefined, '', 0], text: '中文😀\\ud83d', n: -0, flag: false, gone: undefined, s: 'srv' },
|
|
713
|
+
{ id: 'a', order: 1, when: '2026-10-22T01:02:03.004Z', big: '1', obj: {}, arr: [], text: '', n: 1e21, flag: true, isApp: 'y' },
|
|
714
|
+
{ id: 7, order: '3', when: null, big: undefined, obj: null, arr: undefined, text: 'x'.repeat(3000), n: 0.1, flag: null, isApp: 'n' },
|
|
715
|
+
{ id: 'c', when: new Date(Date.UTC(1, 0, 1)), big: 0n, obj: { 'k': 'v' }, arr: [[]], text: 'a < b', n: 5, flag: true },
|
|
716
|
+
],
|
|
717
|
+
})`
|
|
718
|
+
let fxNode = vm.runInThisContext(fixture)
|
|
719
|
+
let fxCtx = vm.runInContext(fixture, ctx)
|
|
720
|
+
|
|
721
|
+
let toJson = (v) => JSON.stringify(v)
|
|
722
|
+
let blNode = buildBaseline(fxNode.rows, fxNode.contract)
|
|
723
|
+
assert.ok(blNode.length >= 3)
|
|
724
|
+
|
|
725
|
+
//(1)各realm各用自己之物件
|
|
726
|
+
assert.strict.equal(toJson(B.buildBaseline(fxCtx.rows, fxCtx.contract)), toJson(blNode))
|
|
727
|
+
for (let k = 0; k < fxNode.rows.length; k++) {
|
|
728
|
+
assert.strict.equal(B.digestRow(fxCtx.rows[k], fxCtx.contract), digestRow(fxNode.rows[k], fxNode.contract))
|
|
729
|
+
}
|
|
730
|
+
//(2)跨realm: vm內之函數處理Node之物件, Node之函數處理vm內之物件
|
|
731
|
+
assert.strict.equal(toJson(B.buildBaseline(fxNode.rows, fxNode.contract)), toJson(blNode))
|
|
732
|
+
assert.strict.equal(toJson(buildBaseline(fxCtx.rows, fxCtx.contract)), toJson(blNode))
|
|
733
|
+
//(3)比對結果與格式驗證同構
|
|
734
|
+
assert.deepStrictEqual(JSON.parse(toJson(B.compareBaseline(fxCtx.rows, blNode, fxCtx.contract))), { ok: true })
|
|
735
|
+
assert.deepStrictEqual(JSON.parse(toJson(compareBaseline(fxCtx.rows, B.buildBaseline(fxCtx.rows, fxCtx.contract), fxNode.contract))), { ok: true })
|
|
736
|
+
let rowsChanged = vm.runInContext(`(${fixture}).rows.map((r, i) => (i === 0 ? { ...r, n: 2 } : r))`, ctx)
|
|
737
|
+
let rc = JSON.parse(toJson(B.compareBaseline(rowsChanged, blNode, fxCtx.contract)))
|
|
738
|
+
assert.deepStrictEqual(rc.changed, ['b'])
|
|
739
|
+
assert.strict.equal(B.isBaselineValid(blNode), true)
|
|
740
|
+
assert.strict.equal(B.isBaselineValid([['a', 'h2:0123456789abcd']]), false)
|
|
741
|
+
assert.strict.equal(B.cyrb53('中文😀'), cyrb53('中文😀'))
|
|
742
|
+
//pickWriteRow之輸出含BigInt(JSON無法序列化), 以摘要比較; 且輸出之摘要同原列(只動白名單)
|
|
743
|
+
assert.strict.equal(B.digestRow(B.pickWriteRow(fxCtx.rows[0], fxCtx.contract), fxCtx.contract), digestRow(pickWriteRow(fxNode.rows[0], fxNode.contract), fxNode.contract))
|
|
744
|
+
assert.strict.equal(digestRow(pickWriteRow(fxNode.rows[0], fxNode.contract), fxNode.contract), digestRow(fxNode.rows[0], fxNode.contract))
|
|
745
|
+
assert.strict.equal(B.getRejectKey('rowNotFound'), 'rowNotFound')
|
|
746
|
+
//超過2^53之BigInt名次於vm內亦精確(不轉Number)
|
|
747
|
+
let bigRows = vm.runInContext(`[{ id: 'a', order: 9007199254740993n }, { id: 'b', order: 9007199254740992n }]`, ctx)
|
|
748
|
+
let CO = { pk: 'id', fields: ['n'], orderKey: 'order' }
|
|
749
|
+
assert.strict.equal(toJson(B.buildBaseline(bigRows, CO)), toJson(buildBaseline([{ id: 'a', order: 9007199254740993n }, { id: 'b', order: 9007199254740992n }], CO)))
|
|
750
|
+
assert.strict.equal(toJson(B.buildBaseline(bigRows, CO).map((v) => v[0])), '["b","a"]')
|
|
751
|
+
|
|
752
|
+
//(4)凍結向量於vm內亦同
|
|
753
|
+
assert.strict.equal(B.digestRow({ id: 'r1', a: 1, b: 'x', c: null }, { pk: 'id', fields: ['a', 'b', 'c'] }), 'h1:1782ae62ff0643')
|
|
754
|
+
|
|
755
|
+
//(5)非JSON值於vm內亦拋錯(以Error物件判定, 不比較跨realm之建構子)
|
|
756
|
+
let e = null
|
|
757
|
+
try {
|
|
758
|
+
B.digestRow({ id: 'x', v: new Map() }, { pk: 'id', fields: ['v'] })
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
e = err
|
|
762
|
+
}
|
|
763
|
+
assert.ok(e !== null && /unsupported value of field\[v\]/.test(e.message))
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
})
|