@criterionx/testing 0.3.1
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 +189 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.js +305 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# @criterionx/testing
|
|
2
|
+
|
|
3
|
+
Testing utilities for Criterion decisions. Provides property-based testing, fuzzing, and coverage analysis.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @criterionx/testing
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @criterionx/testing
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **testDecision** - Test decisions with expected outcomes
|
|
16
|
+
- **fuzz** - Fuzz testing with random inputs
|
|
17
|
+
- **checkProperty** - Property-based testing with fast-check
|
|
18
|
+
- **coverage** - Rule coverage analysis
|
|
19
|
+
- **detectDeadRules** - Dead code detection
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Testing Decisions
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { testDecision } from "@criterionx/testing";
|
|
27
|
+
import { riskDecision } from "./decisions";
|
|
28
|
+
|
|
29
|
+
const result = testDecision(riskDecision, {
|
|
30
|
+
profile: defaultProfile,
|
|
31
|
+
cases: [
|
|
32
|
+
{
|
|
33
|
+
name: "high risk transaction",
|
|
34
|
+
input: { amount: 50000, country: "US" },
|
|
35
|
+
expected: { status: "OK", ruleId: "high-amount" },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "blocked country",
|
|
39
|
+
input: { amount: 100, country: "NK" },
|
|
40
|
+
expected: { status: "OK", ruleId: "blocked-country" },
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
expect: {
|
|
44
|
+
noUnreachableRules: true,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
console.log(result.passed); // true or false
|
|
49
|
+
console.log(result.failures); // Array of test failures
|
|
50
|
+
console.log(result.rulesCovered); // Rules that were exercised
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Fuzz Testing
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { fuzz, fc } from "@criterionx/testing";
|
|
57
|
+
|
|
58
|
+
const result = fuzz(riskDecision, {
|
|
59
|
+
profile: defaultProfile,
|
|
60
|
+
iterations: 1000,
|
|
61
|
+
inputArbitrary: fc.record({
|
|
62
|
+
amount: fc.integer({ min: 0, max: 100000 }),
|
|
63
|
+
country: fc.constantFrom("US", "UK", "NK", "DE"),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
console.log(result.totalRuns); // 1000
|
|
68
|
+
console.log(result.failed); // Number of failures
|
|
69
|
+
console.log(result.ruleDistribution); // Hits per rule
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Property-Based Testing
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { checkProperty, fc } from "@criterionx/testing";
|
|
76
|
+
|
|
77
|
+
const result = checkProperty(riskDecision, {
|
|
78
|
+
profile: defaultProfile,
|
|
79
|
+
inputArbitrary: fc.record({
|
|
80
|
+
amount: fc.integer({ min: 0, max: 100000 }),
|
|
81
|
+
country: fc.constantFrom("US", "UK"),
|
|
82
|
+
}),
|
|
83
|
+
property: (input, result) => {
|
|
84
|
+
// Property: valid inputs should never return INVALID_OUTPUT
|
|
85
|
+
return result.status !== "INVALID_OUTPUT";
|
|
86
|
+
},
|
|
87
|
+
numRuns: 100,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!result.passed) {
|
|
91
|
+
console.log("Property violated with:", result.counterExample);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Coverage Analysis
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { coverage, formatCoverageReport, meetsCoverageThreshold } from "@criterionx/testing";
|
|
99
|
+
|
|
100
|
+
const report = coverage(riskDecision, {
|
|
101
|
+
profile: defaultProfile,
|
|
102
|
+
testCases: [
|
|
103
|
+
{ input: { amount: 100, country: "NK" } },
|
|
104
|
+
{ input: { amount: 50000, country: "US" } },
|
|
105
|
+
{ input: { amount: 100, country: "US" } },
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
console.log(formatCoverageReport(report));
|
|
110
|
+
// === Rule Coverage Report ===
|
|
111
|
+
// Coverage: 3/3 rules (100.0%)
|
|
112
|
+
// Covered rules:
|
|
113
|
+
// ✓ blocked-country (1 hits)
|
|
114
|
+
// ✓ high-amount (1 hits)
|
|
115
|
+
// ✓ default (1 hits)
|
|
116
|
+
|
|
117
|
+
if (!meetsCoverageThreshold(report, 80)) {
|
|
118
|
+
throw new Error("Coverage below 80%");
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Dead Code Detection
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
import { detectDeadRules } from "@criterionx/testing";
|
|
126
|
+
|
|
127
|
+
const deadRules = detectDeadRules(myDecision);
|
|
128
|
+
if (deadRules.length > 0) {
|
|
129
|
+
console.warn("Dead rules detected:", deadRules);
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## API Reference
|
|
134
|
+
|
|
135
|
+
### `testDecision(decision, options)`
|
|
136
|
+
|
|
137
|
+
Test a decision with provided test cases.
|
|
138
|
+
|
|
139
|
+
**Options:**
|
|
140
|
+
- `profile` - Profile to use for evaluation
|
|
141
|
+
- `cases` - Array of test cases with input, profile, and expected outcomes
|
|
142
|
+
- `expect.noUnreachableRules` - Fail if any rules aren't covered
|
|
143
|
+
|
|
144
|
+
**Returns:** `TestDecisionResult` with `passed`, `failures`, `rulesCovered`, `rulesUncovered`
|
|
145
|
+
|
|
146
|
+
### `fuzz(decision, options)`
|
|
147
|
+
|
|
148
|
+
Run fuzz tests on a decision.
|
|
149
|
+
|
|
150
|
+
**Options:**
|
|
151
|
+
- `profile` - Profile to use
|
|
152
|
+
- `iterations` - Number of random inputs (default: 100)
|
|
153
|
+
- `inputArbitrary` - fast-check arbitrary for generating inputs
|
|
154
|
+
- `seed` - Seed for reproducibility
|
|
155
|
+
|
|
156
|
+
**Returns:** `FuzzResult` with `totalRuns`, `passed`, `failed`, `errors`, `ruleDistribution`
|
|
157
|
+
|
|
158
|
+
### `checkProperty(decision, options)`
|
|
159
|
+
|
|
160
|
+
Run property-based tests.
|
|
161
|
+
|
|
162
|
+
**Options:**
|
|
163
|
+
- `profile` - Profile to use
|
|
164
|
+
- `inputArbitrary` - fast-check arbitrary for inputs
|
|
165
|
+
- `property` - Function that returns true if property holds
|
|
166
|
+
- `numRuns` - Number of test runs (default: 100)
|
|
167
|
+
- `seed` - Seed for reproducibility
|
|
168
|
+
|
|
169
|
+
**Returns:** `{ passed, counterExample?, error? }`
|
|
170
|
+
|
|
171
|
+
### `coverage(decision, options)`
|
|
172
|
+
|
|
173
|
+
Analyze rule coverage.
|
|
174
|
+
|
|
175
|
+
**Options:**
|
|
176
|
+
- `profile` - Profile to use
|
|
177
|
+
- `testCases` - Array of inputs to test
|
|
178
|
+
|
|
179
|
+
**Returns:** `CoverageReport` with coverage percentage and rule hit counts
|
|
180
|
+
|
|
181
|
+
### `detectDeadRules(decision)`
|
|
182
|
+
|
|
183
|
+
Detect potentially unreachable rules.
|
|
184
|
+
|
|
185
|
+
**Returns:** Array of rule IDs that appear after catch-all rules
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Decision, Engine } from '@criterionx/core';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
import { Arbitrary } from 'fast-check';
|
|
4
|
+
export { fc };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Test case for a decision
|
|
8
|
+
*/
|
|
9
|
+
interface TestCase<TInput, TOutput, TProfile> {
|
|
10
|
+
name?: string;
|
|
11
|
+
input: TInput;
|
|
12
|
+
profile: TProfile;
|
|
13
|
+
expected?: {
|
|
14
|
+
status?: "OK" | "NO_MATCH" | "INVALID_INPUT" | "INVALID_OUTPUT";
|
|
15
|
+
ruleId?: string;
|
|
16
|
+
output?: Partial<TOutput>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Options for testDecision
|
|
21
|
+
*/
|
|
22
|
+
interface TestDecisionOptions<TInput, TOutput, TProfile> {
|
|
23
|
+
profile: TProfile;
|
|
24
|
+
cases?: TestCase<TInput, TOutput, TProfile>[];
|
|
25
|
+
expect?: {
|
|
26
|
+
/** All rules should be reachable (have at least one matching case) */
|
|
27
|
+
noUnreachableRules?: boolean;
|
|
28
|
+
/** No rules should always fail */
|
|
29
|
+
noDeadCode?: boolean;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Result of testDecision
|
|
34
|
+
*/
|
|
35
|
+
interface TestDecisionResult {
|
|
36
|
+
passed: boolean;
|
|
37
|
+
failures: TestFailure[];
|
|
38
|
+
rulesCovered: string[];
|
|
39
|
+
rulesUncovered: string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A test failure
|
|
43
|
+
*/
|
|
44
|
+
interface TestFailure {
|
|
45
|
+
type: "case_failed" | "unreachable_rule" | "dead_code";
|
|
46
|
+
message: string;
|
|
47
|
+
details?: {
|
|
48
|
+
testCase?: string;
|
|
49
|
+
ruleId?: string;
|
|
50
|
+
expected?: unknown;
|
|
51
|
+
actual?: unknown;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Options for fuzz testing
|
|
56
|
+
*/
|
|
57
|
+
interface FuzzOptions<TInput, TProfile> {
|
|
58
|
+
/** Number of iterations */
|
|
59
|
+
iterations?: number;
|
|
60
|
+
/** Profile to use */
|
|
61
|
+
profile: TProfile;
|
|
62
|
+
/** Custom input arbitrary (for property-based testing) */
|
|
63
|
+
inputArbitrary?: Arbitrary<TInput>;
|
|
64
|
+
/** Seed for reproducibility */
|
|
65
|
+
seed?: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Result of fuzz testing
|
|
69
|
+
*/
|
|
70
|
+
interface FuzzResult<TInput> {
|
|
71
|
+
totalRuns: number;
|
|
72
|
+
passed: number;
|
|
73
|
+
failed: number;
|
|
74
|
+
errors: FuzzError<TInput>[];
|
|
75
|
+
ruleDistribution: Record<string, number>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A fuzz error
|
|
79
|
+
*/
|
|
80
|
+
interface FuzzError<TInput> {
|
|
81
|
+
input: TInput;
|
|
82
|
+
error: string;
|
|
83
|
+
iteration: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Options for coverage analysis
|
|
87
|
+
*/
|
|
88
|
+
interface CoverageOptions<TInput, TProfile> {
|
|
89
|
+
profile: TProfile;
|
|
90
|
+
testCases?: Array<{
|
|
91
|
+
input: TInput;
|
|
92
|
+
}>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Coverage report
|
|
96
|
+
*/
|
|
97
|
+
interface CoverageReport {
|
|
98
|
+
totalRules: number;
|
|
99
|
+
coveredRules: number;
|
|
100
|
+
coveragePercentage: number;
|
|
101
|
+
rulesCovered: string[];
|
|
102
|
+
rulesUncovered: string[];
|
|
103
|
+
ruleHits: Record<string, number>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Test a decision with provided test cases and assertions
|
|
108
|
+
*/
|
|
109
|
+
declare function testDecision<TInput, TOutput, TProfile>(decision: Decision<TInput, TOutput, TProfile>, options: TestDecisionOptions<TInput, TOutput, TProfile>): TestDecisionResult;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fuzz test a decision with random inputs
|
|
113
|
+
*
|
|
114
|
+
* Uses fast-check for property-based testing to discover edge cases
|
|
115
|
+
*/
|
|
116
|
+
declare function fuzz<TInput, TOutput, TProfile>(decision: Decision<TInput, TOutput, TProfile>, options: FuzzOptions<TInput, TProfile>): FuzzResult<TInput>;
|
|
117
|
+
/**
|
|
118
|
+
* Run property-based tests on a decision
|
|
119
|
+
*
|
|
120
|
+
* Allows defining custom properties that should hold for all inputs
|
|
121
|
+
*/
|
|
122
|
+
declare function checkProperty<TInput, TOutput, TProfile>(decision: Decision<TInput, TOutput, TProfile>, options: {
|
|
123
|
+
profile: TProfile;
|
|
124
|
+
inputArbitrary: fc.Arbitrary<TInput>;
|
|
125
|
+
property: (input: TInput, result: ReturnType<Engine["run"]>) => boolean;
|
|
126
|
+
numRuns?: number;
|
|
127
|
+
seed?: number;
|
|
128
|
+
}): {
|
|
129
|
+
passed: boolean;
|
|
130
|
+
counterExample?: TInput;
|
|
131
|
+
error?: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Analyze rule coverage for a decision
|
|
136
|
+
*
|
|
137
|
+
* Reports which rules have been exercised by the provided test cases
|
|
138
|
+
*/
|
|
139
|
+
declare function coverage<TInput, TOutput, TProfile>(decision: Decision<TInput, TOutput, TProfile>, options: CoverageOptions<TInput, TProfile>): CoverageReport;
|
|
140
|
+
/**
|
|
141
|
+
* Generate a coverage report as a formatted string
|
|
142
|
+
*/
|
|
143
|
+
declare function formatCoverageReport(report: CoverageReport): string;
|
|
144
|
+
/**
|
|
145
|
+
* Check if coverage meets a threshold
|
|
146
|
+
*/
|
|
147
|
+
declare function meetsCoverageThreshold(report: CoverageReport, threshold: number): boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Analyze which rules are potentially dead code
|
|
150
|
+
*
|
|
151
|
+
* A rule is considered potentially dead if:
|
|
152
|
+
* 1. It was never matched in fuzzing
|
|
153
|
+
* 2. It comes after a rule that always matches (like a catch-all)
|
|
154
|
+
*/
|
|
155
|
+
declare function detectDeadRules<TInput, TOutput, TProfile>(decision: Decision<TInput, TOutput, TProfile>): string[];
|
|
156
|
+
|
|
157
|
+
export { type CoverageOptions, type CoverageReport, type FuzzError, type FuzzOptions, type FuzzResult, type TestCase, type TestDecisionOptions, type TestDecisionResult, type TestFailure, checkProperty, coverage, detectDeadRules, formatCoverageReport, fuzz, meetsCoverageThreshold, testDecision };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// src/test-decision.ts
|
|
2
|
+
import { Engine } from "@criterionx/core";
|
|
3
|
+
var engine = new Engine();
|
|
4
|
+
function testDecision(decision, options) {
|
|
5
|
+
const failures = [];
|
|
6
|
+
const ruleHits = /* @__PURE__ */ new Map();
|
|
7
|
+
for (const rule of decision.rules) {
|
|
8
|
+
ruleHits.set(rule.id, 0);
|
|
9
|
+
}
|
|
10
|
+
if (options.cases) {
|
|
11
|
+
for (const testCase of options.cases) {
|
|
12
|
+
const profile = testCase.profile ?? options.profile;
|
|
13
|
+
const result = engine.run(decision, testCase.input, { profile });
|
|
14
|
+
if (result.status === "OK" && result.meta.matchedRule) {
|
|
15
|
+
const current = ruleHits.get(result.meta.matchedRule) ?? 0;
|
|
16
|
+
ruleHits.set(result.meta.matchedRule, current + 1);
|
|
17
|
+
}
|
|
18
|
+
if (testCase.expected) {
|
|
19
|
+
const caseFailures = validateExpectations(
|
|
20
|
+
testCase,
|
|
21
|
+
result,
|
|
22
|
+
testCase.name
|
|
23
|
+
);
|
|
24
|
+
failures.push(...caseFailures);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (options.expect?.noUnreachableRules) {
|
|
29
|
+
for (const [ruleId, hits] of ruleHits) {
|
|
30
|
+
if (hits === 0) {
|
|
31
|
+
failures.push({
|
|
32
|
+
type: "unreachable_rule",
|
|
33
|
+
message: `Rule "${ruleId}" was never matched by any test case`,
|
|
34
|
+
details: { ruleId }
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const rulesCovered = [...ruleHits.entries()].filter(([, hits]) => hits > 0).map(([id]) => id);
|
|
40
|
+
const rulesUncovered = [...ruleHits.entries()].filter(([, hits]) => hits === 0).map(([id]) => id);
|
|
41
|
+
return {
|
|
42
|
+
passed: failures.length === 0,
|
|
43
|
+
failures,
|
|
44
|
+
rulesCovered,
|
|
45
|
+
rulesUncovered
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function validateExpectations(testCase, result, caseName) {
|
|
49
|
+
const failures = [];
|
|
50
|
+
const { expected } = testCase;
|
|
51
|
+
const caseLabel = caseName ?? JSON.stringify(testCase.input);
|
|
52
|
+
if (!expected) return failures;
|
|
53
|
+
if (expected.status && result.status !== expected.status) {
|
|
54
|
+
failures.push({
|
|
55
|
+
type: "case_failed",
|
|
56
|
+
message: `Test case "${caseLabel}" expected status "${expected.status}" but got "${result.status}"`,
|
|
57
|
+
details: {
|
|
58
|
+
testCase: caseLabel,
|
|
59
|
+
expected: expected.status,
|
|
60
|
+
actual: result.status
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (expected.ruleId && result.meta.matchedRule !== expected.ruleId) {
|
|
65
|
+
failures.push({
|
|
66
|
+
type: "case_failed",
|
|
67
|
+
message: `Test case "${caseLabel}" expected rule "${expected.ruleId}" but got "${result.meta.matchedRule ?? "none"}"`,
|
|
68
|
+
details: {
|
|
69
|
+
testCase: caseLabel,
|
|
70
|
+
expected: expected.ruleId,
|
|
71
|
+
actual: result.meta.matchedRule
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (expected.output && result.data) {
|
|
76
|
+
for (const [key, value] of Object.entries(expected.output)) {
|
|
77
|
+
const actualValue = result.data[key];
|
|
78
|
+
if (!deepEqual(actualValue, value)) {
|
|
79
|
+
failures.push({
|
|
80
|
+
type: "case_failed",
|
|
81
|
+
message: `Test case "${caseLabel}" expected output.${key} to be ${JSON.stringify(value)} but got ${JSON.stringify(actualValue)}`,
|
|
82
|
+
details: {
|
|
83
|
+
testCase: caseLabel,
|
|
84
|
+
expected: { [key]: value },
|
|
85
|
+
actual: { [key]: actualValue }
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return failures;
|
|
92
|
+
}
|
|
93
|
+
function deepEqual(a, b) {
|
|
94
|
+
if (a === b) return true;
|
|
95
|
+
if (typeof a !== typeof b) return false;
|
|
96
|
+
if (typeof a !== "object" || a === null || b === null) return false;
|
|
97
|
+
const keysA = Object.keys(a);
|
|
98
|
+
const keysB = Object.keys(b);
|
|
99
|
+
if (keysA.length !== keysB.length) return false;
|
|
100
|
+
for (const key of keysA) {
|
|
101
|
+
if (!keysB.includes(key)) return false;
|
|
102
|
+
if (!deepEqual(
|
|
103
|
+
a[key],
|
|
104
|
+
b[key]
|
|
105
|
+
))
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/fuzz.ts
|
|
112
|
+
import { Engine as Engine2 } from "@criterionx/core";
|
|
113
|
+
import * as fc from "fast-check";
|
|
114
|
+
var engine2 = new Engine2();
|
|
115
|
+
function fuzz(decision, options) {
|
|
116
|
+
const iterations = options.iterations ?? 100;
|
|
117
|
+
const errors = [];
|
|
118
|
+
const ruleDistribution = {};
|
|
119
|
+
let passed = 0;
|
|
120
|
+
let failed = 0;
|
|
121
|
+
for (const rule of decision.rules) {
|
|
122
|
+
ruleDistribution[rule.id] = 0;
|
|
123
|
+
}
|
|
124
|
+
ruleDistribution["NO_MATCH"] = 0;
|
|
125
|
+
ruleDistribution["INVALID_INPUT"] = 0;
|
|
126
|
+
const inputArbitrary = options.inputArbitrary ?? createDefaultArbitrary();
|
|
127
|
+
const inputs = [];
|
|
128
|
+
const seed = options.seed ?? Date.now();
|
|
129
|
+
fc.assert(
|
|
130
|
+
fc.property(inputArbitrary, (input) => {
|
|
131
|
+
inputs.push(input);
|
|
132
|
+
return true;
|
|
133
|
+
}),
|
|
134
|
+
{
|
|
135
|
+
numRuns: iterations,
|
|
136
|
+
seed
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
140
|
+
const input = inputs[i];
|
|
141
|
+
try {
|
|
142
|
+
const result = engine2.run(decision, input, { profile: options.profile });
|
|
143
|
+
if (result.status === "OK" && result.meta.matchedRule) {
|
|
144
|
+
ruleDistribution[result.meta.matchedRule]++;
|
|
145
|
+
passed++;
|
|
146
|
+
} else if (result.status === "NO_MATCH") {
|
|
147
|
+
ruleDistribution["NO_MATCH"]++;
|
|
148
|
+
passed++;
|
|
149
|
+
} else if (result.status === "INVALID_INPUT") {
|
|
150
|
+
ruleDistribution["INVALID_INPUT"]++;
|
|
151
|
+
passed++;
|
|
152
|
+
} else {
|
|
153
|
+
failed++;
|
|
154
|
+
errors.push({
|
|
155
|
+
input,
|
|
156
|
+
error: result.meta.explanation,
|
|
157
|
+
iteration: i
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
failed++;
|
|
162
|
+
errors.push({
|
|
163
|
+
input,
|
|
164
|
+
error: String(error),
|
|
165
|
+
iteration: i
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
totalRuns: inputs.length,
|
|
171
|
+
passed,
|
|
172
|
+
failed,
|
|
173
|
+
errors,
|
|
174
|
+
ruleDistribution
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function checkProperty(decision, options) {
|
|
178
|
+
try {
|
|
179
|
+
fc.assert(
|
|
180
|
+
fc.property(options.inputArbitrary, (input) => {
|
|
181
|
+
const result = engine2.run(decision, input, {
|
|
182
|
+
profile: options.profile
|
|
183
|
+
});
|
|
184
|
+
return options.property(input, result);
|
|
185
|
+
}),
|
|
186
|
+
{
|
|
187
|
+
numRuns: options.numRuns ?? 100,
|
|
188
|
+
seed: options.seed
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
return { passed: true };
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof Error) {
|
|
194
|
+
const fcError = error;
|
|
195
|
+
return {
|
|
196
|
+
passed: false,
|
|
197
|
+
counterExample: fcError.counterexample?.[0],
|
|
198
|
+
error: fcError.message
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
passed: false,
|
|
203
|
+
error: String(error)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function createDefaultArbitrary() {
|
|
208
|
+
return fc.dictionary(
|
|
209
|
+
fc.string({ minLength: 1, maxLength: 20 }),
|
|
210
|
+
fc.oneof(
|
|
211
|
+
fc.string(),
|
|
212
|
+
fc.integer(),
|
|
213
|
+
fc.double({ noNaN: true }),
|
|
214
|
+
fc.boolean(),
|
|
215
|
+
fc.constant(null)
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/coverage.ts
|
|
221
|
+
import { Engine as Engine3 } from "@criterionx/core";
|
|
222
|
+
var engine3 = new Engine3();
|
|
223
|
+
function coverage(decision, options) {
|
|
224
|
+
const ruleHits = {};
|
|
225
|
+
const testCases = options.testCases ?? [];
|
|
226
|
+
for (const rule of decision.rules) {
|
|
227
|
+
ruleHits[rule.id] = 0;
|
|
228
|
+
}
|
|
229
|
+
for (const testCase of testCases) {
|
|
230
|
+
const result = engine3.run(decision, testCase.input, {
|
|
231
|
+
profile: options.profile
|
|
232
|
+
});
|
|
233
|
+
if (result.status === "OK" && result.meta.matchedRule) {
|
|
234
|
+
ruleHits[result.meta.matchedRule]++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const totalRules = decision.rules.length;
|
|
238
|
+
const rulesCovered = Object.entries(ruleHits).filter(([, hits]) => hits > 0).map(([id]) => id);
|
|
239
|
+
const rulesUncovered = Object.entries(ruleHits).filter(([, hits]) => hits === 0).map(([id]) => id);
|
|
240
|
+
const coveredRules = rulesCovered.length;
|
|
241
|
+
const coveragePercentage = totalRules > 0 ? coveredRules / totalRules * 100 : 100;
|
|
242
|
+
return {
|
|
243
|
+
totalRules,
|
|
244
|
+
coveredRules,
|
|
245
|
+
coveragePercentage,
|
|
246
|
+
rulesCovered,
|
|
247
|
+
rulesUncovered,
|
|
248
|
+
ruleHits
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function formatCoverageReport(report) {
|
|
252
|
+
const lines = [];
|
|
253
|
+
lines.push("=== Rule Coverage Report ===");
|
|
254
|
+
lines.push("");
|
|
255
|
+
lines.push(
|
|
256
|
+
`Coverage: ${report.coveredRules}/${report.totalRules} rules (${report.coveragePercentage.toFixed(1)}%)`
|
|
257
|
+
);
|
|
258
|
+
lines.push("");
|
|
259
|
+
if (report.rulesCovered.length > 0) {
|
|
260
|
+
lines.push("Covered rules:");
|
|
261
|
+
for (const ruleId of report.rulesCovered) {
|
|
262
|
+
const hits = report.ruleHits[ruleId];
|
|
263
|
+
lines.push(` \u2713 ${ruleId} (${hits} hits)`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (report.rulesUncovered.length > 0) {
|
|
267
|
+
lines.push("");
|
|
268
|
+
lines.push("Uncovered rules:");
|
|
269
|
+
for (const ruleId of report.rulesUncovered) {
|
|
270
|
+
lines.push(` \u2717 ${ruleId}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
function meetsCoverageThreshold(report, threshold) {
|
|
276
|
+
return report.coveragePercentage >= threshold;
|
|
277
|
+
}
|
|
278
|
+
function detectDeadRules(decision) {
|
|
279
|
+
const deadRules = [];
|
|
280
|
+
let foundCatchAll = false;
|
|
281
|
+
for (const rule of decision.rules) {
|
|
282
|
+
if (foundCatchAll) {
|
|
283
|
+
deadRules.push(rule.id);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const whenStr = rule.when.toString();
|
|
287
|
+
if (whenStr.includes("=> true") || whenStr.includes("return true") || whenStr.includes("()=>true")) {
|
|
288
|
+
foundCatchAll = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return deadRules;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/index.ts
|
|
295
|
+
import * as fc2 from "fast-check";
|
|
296
|
+
export {
|
|
297
|
+
checkProperty,
|
|
298
|
+
coverage,
|
|
299
|
+
detectDeadRules,
|
|
300
|
+
fc2 as fc,
|
|
301
|
+
formatCoverageReport,
|
|
302
|
+
fuzz,
|
|
303
|
+
meetsCoverageThreshold,
|
|
304
|
+
testDecision
|
|
305
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@criterionx/testing",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Testing utilities for Criterion decisions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
15
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"test:coverage": "vitest run --coverage",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"criterion",
|
|
23
|
+
"testing",
|
|
24
|
+
"decision-engine",
|
|
25
|
+
"fuzzing",
|
|
26
|
+
"property-based-testing"
|
|
27
|
+
],
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Tomas Maritano",
|
|
30
|
+
"url": "https://github.com/tomymaritano"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/tomymaritano/criterionx.git",
|
|
36
|
+
"directory": "packages/testing"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/tomymaritano/criterionx/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/tomymaritano/criterionx#readme",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@criterionx/core": "workspace:*",
|
|
44
|
+
"fast-check": "^3.23.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^20.0.0",
|
|
48
|
+
"tsup": "^8.0.0",
|
|
49
|
+
"typescript": "^5.3.0",
|
|
50
|
+
"vitest": "^4.0.0",
|
|
51
|
+
"zod": "^3.23.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"zod": "^3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
}
|
|
59
|
+
}
|