1
0

refactor: expect 类型模型重构,Raw/Resolved 双层分离与断言基础设施内聚

- 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations
- 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照
- HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body
- 新增 displayValueExpectation() 解包 failure.expected 用户可读展示
- 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema
- 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts
- 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts
- 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用
- 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks)
This commit is contained in:
2026-05-20 16:12:48 +08:00
parent 6098be2d9e
commit 60a54b483f
90 changed files with 2487 additions and 1493 deletions

View File

@@ -0,0 +1,140 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import type { CheckFailure } from "../types";
import type { ExpectationResult, RawValueExpectation, ValueExpectation, ValueMatcher } from "./types";
import { mismatchFailure } from "./failure";
import { MATCHER_KEY_SET } from "./keys";
export function applyValueMatcher(
actual: unknown,
matcher: ValueMatcher,
options: { stringifyNonString?: boolean } = {},
): boolean {
for (const [key, expected] of Object.entries(matcher)) {
if (expected === undefined) continue;
switch (key) {
case "contains":
if (!stringValue(actual, options).includes(expected as string)) return false;
break;
case "empty": {
const empty = isEmptyValue(actual);
if (expected !== empty) return false;
break;
}
case "equals":
if (!isEqual(actual, expected)) return false;
break;
case "exists":
if (expected) {
if (actual === undefined) return false;
} else {
if (actual !== undefined) return false;
}
break;
case "gt":
if (!compareNumber(actual, expected as number, (left, right) => left > right)) return false;
break;
case "gte":
if (!compareNumber(actual, expected as number, (left, right) => left >= right)) return false;
break;
case "lt":
if (!compareNumber(actual, expected as number, (left, right) => left < right)) return false;
break;
case "lte":
if (!compareNumber(actual, expected as number, (left, right) => left <= right)) return false;
break;
case "regex":
if (!new RegExp(expected as string).test(stringValue(actual, options))) return false;
break;
}
}
return true;
}
export function checkValueExpectation(
actual: unknown,
expectation: undefined | ValueExpectation,
options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean },
): ExpectationResult {
if (expectation === undefined) return { failure: null, matched: true };
if (applyValueMatcher(actual, expectation, { stringifyNonString: options.stringifyNonString })) {
return { failure: null, matched: true };
}
return {
failure: mismatchFailure(
options.phase,
options.path,
displayValueExpectation(expectation),
actual,
options.message ?? `${options.path} mismatch`,
),
matched: false,
};
}
export function displayValueExpectation(expectation: ValueExpectation): unknown {
const entries = Object.entries(expectation).filter(([, value]) => value !== undefined);
if (entries.length === 1 && entries[0]?.[0] === "equals") return entries[0][1];
return expectation;
}
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
const segments = path.slice(2).split(".");
let current: unknown = json;
for (const seg of segments) {
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch) {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!Array.isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[seg];
}
}
return current;
}
export function isValueMatcherObject(value: unknown): value is ValueMatcher {
return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key));
}
export function isValueMatcherPrimitive(value: unknown): value is boolean | null | number | string {
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
export function resolveValueExpectation(raw: RawValueExpectation): ValueExpectation;
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation;
export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation {
if (raw === undefined) return undefined;
if (isValueMatcherObject(raw)) return raw;
return { equals: raw };
}
function compareNumber(
actual: unknown,
expected: number,
compare: (actual: number, expected: number) => boolean,
): boolean {
const value = Number(actual);
return Number.isFinite(value) && compare(value, expected);
}
function isEmptyValue(value: unknown): boolean {
return isNil(value) || value === "" || (Array.isArray(value) && value.length === 0) || isEmptyObject(value);
}
function stringValue(actual: unknown, options: { stringifyNonString?: boolean }): string {
if (!options.stringifyNonString || typeof actual === "string") return String(actual);
if (actual !== null && typeof actual === "object") return JSON.stringify(actual);
return String(actual);
}