From 412af0fa7affe5eab6fee04dc8a9a1ec0ed8f3cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:55:58 +0000 Subject: [PATCH] =?UTF-8?q?test(objectql):=20=E6=8A=8A=20`now`=20=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E6=97=B6=E5=88=BB=20pin=20=E4=BB=8E=E5=80=BC=E7=9B=B8?= =?UTF-8?q?=E7=AD=89=E6=94=B9=E6=88=90=E6=8C=89=E6=9E=84=E9=80=A0=E6=96=AD?= =?UTF-8?q?=E8=A8=80=20(#5896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `engine.test.ts` 的 `pins \`now\` once per find (#1979)` 用值相等钉 「一次 find 只取一个 `new Date()`」,而回归形态是逐次求值各取一次时钟 —— 同毫秒内的两个 Date 值相等、对象不同,故该断言只在三次求值恰好跨毫秒 边界时才红。实测(复刻 PR #5894 探针 B):整文件跑 10 次里它绿了 3 次, 单进程 200 次 find 里 145 次三值全等 —— 按运气报警。 改为对 `ExpressionEngine.evaluate` 收到的上下文插桩:先钉求值次数(3 = 1 formula 字段 × 3 行,防止空调用列表下的空绿),再按对象同一性断言三次 求值拿到同一个 `now`。值相等保留为「调用方可见的症状」,但不再是报警来源。 保留 `#1979` 出处标记(issue 处置选项 1:加强而非退休)。 test-only:未触碰任何生产文件。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- packages/objectql/src/engine.test.ts | 29 +++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index b9cafe5c55..f36e628e9a 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, onTestFinished } from 'vitest'; import { ObjectQL } from './engine'; +import { ExpressionEngine } from '@objectstack/formula'; import { SchemaRegistry } from './registry'; import type { IDataDriver } from '@objectstack/spec/contracts'; @@ -1929,6 +1930,19 @@ describe('ObjectQL Engine', () => { }); it('pins `now` once per find so every row sees the same instant (#1979)', async () => { + // Asserted by CONSTRUCTION rather than by value (#5896). The regression + // this guards is a per-evaluation `new Date()`, and two such reads + // inside the same millisecond are equal in value while being distinct + // objects — so a value comparison only fails when the three + // evaluations happen to straddle a millisecond boundary. Measured + // against that exact regression, the value form passed through it in + // 3 of 10 full-file runs (and in 145 of 200 finds within one warm + // process): it reported by luck. Spying on the eval context pins the + // mechanism instead — ONE clock read, handed to every evaluation by + // identity — which fails whatever the millisecond happens to be. + const evaluate = vi.spyOn(ExpressionEngine, 'evaluate'); + onTestFinished(() => { evaluate.mockRestore(); }); + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'ping', fields: { @@ -1946,8 +1960,17 @@ describe('ObjectQL Engine', () => { const result = await engine.find('ping', { fields: ['id', 'ts'] } as any); - // Determinism: a single operation snapshots one `now`, shared across - // every row — not a fresh wall-clock read per evaluation. + // 1 formula field × 3 rows: the evaluations the identity claim is over. + // Without this count the claim below could pass vacuously on an empty + // call list. + expect(evaluate).toHaveBeenCalledTimes(3); + const nows = (evaluate.mock.calls as unknown as Array<[unknown, { now?: Date }]>) + .map(([, ctx]) => ctx.now); + expect(nows[0]).toBeInstanceOf(Date); + expect(nows.every((n) => n === nows[0])).toBe(true); + + // …and the consequence a caller can see. Kept as the caller-visible + // symptom, but it is no longer what makes this test report. expect(result[0].ts).toEqual(result[1].ts); expect(result[1].ts).toEqual(result[2].ts); });