Skip to content

Commit 7e4cf31

Browse files
committed
feat(core,wasm,js): release v1.1.1 with seed validation, invalid-flag parity, and stricter input checks
- Validate seed test cases in C++ generator (src/core/generator.cpp) and TS generator (src/ts/core/generator.ts): drop seeds with out-of-range values, invalid-marked values, or constraint violations, emitting per-seed warnings instead of silently propagating bad state - Fix maxTests-overflow warning to emit once after the loop, not inside it, and unify warning text across C++ and TS implementations - Propagate invalid flag from Parameter to GenerateOptions in pure TS adapter (js/pure/adapter.ts), closing a parity gap where pure TS silently ignored invalid values while WASM correctly excluded them from positive coverage - Throw on unknown seed values in toInternalTestCase instead of producing UNASSIGNED indices - Add ParseUint32Option helper in src/wasm/bindings.cpp to validate strength and maxTests with descriptive errors before passing to C++ core - Add input validation (parameters, strength, maxTests, seed) to all public entry points in js/index.ts and js/pure/index.ts; relax empty-array check in pure entry to match WASM behavior; add estimateModel validation that was missing - Replace wasmModule! non-null assertion with getModule() call in Coverwise.create() - New tests: C++ (tests/core/generator_test.cpp), TS (src/ts/core/generator.test.ts), WASM (tests/wasm/generate.test.ts), and compat (js/compat.test.ts) cover seed rejection, maxTests capping, numeric option rejection, and negativeTests parity - Bump version to 1.1.1 in package.json and CMakeLists.txt
1 parent 1565c46 commit 7e4cf31

12 files changed

Lines changed: 305 additions & 21 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cmake_minimum_required(VERSION 3.16)
2-
project(coverwise VERSION 1.1.0 LANGUAGES CXX)
2+
project(coverwise VERSION 1.1.1 LANGUAGES CXX)
33

44
set(CMAKE_CXX_STANDARD 17)
55
set(CMAKE_CXX_STANDARD_REQUIRED ON)

js/compat.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ const scenarios: Array<{ name: string; input: GenerateInput }> = [
226226
seed: 42,
227227
},
228228
},
229+
{
230+
name: 'with invalid values',
231+
input: {
232+
parameters: [
233+
{ name: 'browser', values: ['chrome', 'firefox', { value: 'ie6', invalid: true }] },
234+
{ name: 'os', values: ['win', 'mac'] },
235+
],
236+
seed: 42,
237+
},
238+
},
229239
{
230240
name: 'with sub-models',
231241
input: {
@@ -321,6 +331,7 @@ describe('WASM / TS compatibility', () => {
321331
expect(tsResult.stats.totalTuples).toBe(wasmResult.stats.totalTuples);
322332
expect(tsResult.stats.coveredTuples).toBe(wasmResult.stats.coveredTuples);
323333
expect(tsResult.uncovered.length).toBe(wasmResult.uncovered.length);
334+
expect(tsResult.negativeTests?.length ?? 0).toBe(wasmResult.negativeTests?.length ?? 0);
324335
// Note: tests.length may differ between engines because the greedy
325336
// algorithm's RNG produces different value orderings in C++ vs TS,
326337
// which can lead to different numbers of tests while still achieving

js/index.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,49 @@ function getModule(): WasmModule {
101101
return wasmModule;
102102
}
103103

104+
// --- Input Validation ---
105+
106+
function validateStrength(strength: unknown): number {
107+
if (strength === undefined || strength === null) {
108+
return 2;
109+
}
110+
if (typeof strength !== 'number' || !Number.isInteger(strength) || strength <= 0) {
111+
throw new Error(`Invalid strength: ${String(strength)}. Must be a positive integer.`);
112+
}
113+
return strength;
114+
}
115+
116+
function validateMaxTests(maxTests: unknown): void {
117+
if (maxTests === undefined || maxTests === null) {
118+
return;
119+
}
120+
if (typeof maxTests !== 'number' || !Number.isInteger(maxTests) || maxTests < 0) {
121+
throw new Error(`Invalid maxTests: ${String(maxTests)}. Must be a non-negative integer.`);
122+
}
123+
}
124+
125+
function validateSeed(seed: unknown): void {
126+
if (seed === undefined || seed === null) {
127+
return;
128+
}
129+
if (typeof seed !== 'number' || !Number.isFinite(seed)) {
130+
throw new Error(`Invalid seed: ${String(seed)}. Must be a finite number.`);
131+
}
132+
}
133+
134+
function validateParameters(parameters: unknown): void {
135+
if (!Array.isArray(parameters)) {
136+
throw new Error('Invalid parameters: must be an array.');
137+
}
138+
}
139+
140+
function validateGenerateInput(input: GenerateInput): void {
141+
validateParameters(input.parameters);
142+
validateStrength(input.strength);
143+
validateMaxTests(input.maxTests);
144+
validateSeed(input.seed);
145+
}
146+
104147
// --- Result Checking ---
105148

106149
function checkResult<T>(result: unknown): T {
@@ -131,6 +174,7 @@ function checkResult<T>(result: unknown): T {
131174
* // result.coverage: 1.0
132175
*/
133176
export function generate(input: GenerateInput): GenerateResult {
177+
validateGenerateInput(input);
134178
const mod = getModule();
135179
const result = checkResult<GenerateResult>(mod.generate(input));
136180
result.negativeTests = result.negativeTests ?? [];
@@ -154,9 +198,11 @@ export function analyzeCoverage(
154198
strength?: number,
155199
constraints?: string[],
156200
): CoverageReport {
201+
validateParameters(parameters);
202+
const s = validateStrength(strength);
157203
const mod = getModule();
158204
const result = checkResult<CoverageReport>(
159-
mod.analyzeCoverage(parameters, tests, strength ?? 2, constraints ?? []),
205+
mod.analyzeCoverage(parameters, tests, s, constraints ?? []),
160206
);
161207
// When there are no tuples (e.g. fewer parameters than strength), coverage is vacuously 1.0.
162208
if (result.totalTuples === 0) {
@@ -172,6 +218,7 @@ export function analyzeCoverage(
172218
* Only "strict" mode is supported (existing tests are kept as-is).
173219
*/
174220
export function extendTests(existing: TestCase[], input: ExtendInput): GenerateResult {
221+
validateGenerateInput(input);
175222
const mod = getModule();
176223
const result = checkResult<GenerateResult>(mod.extendTests(existing, input));
177224
result.negativeTests = result.negativeTests ?? [];
@@ -182,6 +229,7 @@ export function extendTests(existing: TestCase[], input: ExtendInput): GenerateR
182229
* Get model statistics without running generation.
183230
*/
184231
export function estimateModel(input: GenerateInput): ModelStats {
232+
validateGenerateInput(input);
185233
const mod = getModule();
186234
const result = checkResult<ModelStats>(mod.estimateModel(input));
187235
return result;
@@ -214,13 +262,14 @@ export class Coverwise {
214262
*/
215263
static async create(): Promise<Coverwise> {
216264
await init();
217-
return new Coverwise(wasmModule!);
265+
return new Coverwise(getModule());
218266
}
219267

220268
/**
221269
* Generate a covering array. One function, sensible defaults.
222270
*/
223271
generate(input: GenerateInput): GenerateResult {
272+
validateGenerateInput(input);
224273
const result = checkResult<GenerateResult>(this.module.generate(input));
225274
result.negativeTests = result.negativeTests ?? [];
226275
return result;
@@ -235,8 +284,10 @@ export class Coverwise {
235284
strength?: number,
236285
constraints?: string[],
237286
): CoverageReport {
287+
validateParameters(parameters);
288+
const s = validateStrength(strength);
238289
const result = checkResult<CoverageReport>(
239-
this.module.analyzeCoverage(parameters, tests, strength ?? 2, constraints ?? []),
290+
this.module.analyzeCoverage(parameters, tests, s, constraints ?? []),
240291
);
241292
if (result.totalTuples === 0) {
242293
result.coverageRatio = 1.0;
@@ -248,6 +299,7 @@ export class Coverwise {
248299
* Extend an existing test suite with additional tests to improve coverage.
249300
*/
250301
extendTests(existing: TestCase[], input: ExtendInput): GenerateResult {
302+
validateGenerateInput(input);
251303
const result = checkResult<GenerateResult>(this.module.extendTests(existing, input));
252304
result.negativeTests = result.negativeTests ?? [];
253305
return result;
@@ -257,6 +309,7 @@ export class Coverwise {
257309
* Get model statistics without running generation.
258310
*/
259311
estimateModel(input: GenerateInput): ModelStats {
312+
validateGenerateInput(input);
260313
return checkResult<ModelStats>(this.module.estimateModel(input));
261314
}
262315
}

js/pure/adapter.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ export function toInternalTestCase(
108108
const paramName = params[i].name;
109109
if (Object.hasOwn(tc, paramName)) {
110110
const valStr = valueToString(tc[paramName]);
111-
values[i] = params[i].findValueIndex(valStr);
111+
const idx = params[i].findValueIndex(valStr);
112+
if (idx === UNASSIGNED) {
113+
throw new Error(`Unknown value '${valStr}' for parameter '${paramName}'`);
114+
}
115+
values[i] = idx;
112116
}
113117
}
114118
return { values };
@@ -147,7 +151,11 @@ export function toInternalOptions(
147151
const seeds = (input.seeds ?? []).map((tc) => toInternalTestCase(tc, params));
148152

149153
return createGenerateOptions({
150-
parameters: params.map((p) => ({ name: p.name, values: p.values })),
154+
parameters: params.map((p) => ({
155+
name: p.name,
156+
values: p.values,
157+
...(p.hasInvalidValues ? { invalid: p.invalid } : {}),
158+
})),
151159
constraintExpressions: input.constraints ?? [],
152160
strength: input.strength ?? 2,
153161
seed: input.seed ?? 0,

js/pure/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ function validateSeed(seed: unknown): void {
8989
}
9090

9191
function validateParameters(parameters: unknown): void {
92-
if (!Array.isArray(parameters) || parameters.length === 0) {
93-
throw new Error('Invalid parameters: must be a non-empty array.');
92+
if (!Array.isArray(parameters)) {
93+
throw new Error('Invalid parameters: must be an array.');
9494
}
9595
}
9696

@@ -201,6 +201,7 @@ export function extendTests(existing: TestCase[], input: ExtendInput): GenerateR
201201
* Get model statistics without running generation.
202202
*/
203203
export function estimateModel(input: GenerateInput): ModelStats {
204+
validateGenerateInput(input);
204205
const params = toInternalParams(input.parameters);
205206
const opts = toInternalOptions(input, params);
206207
const stats = internalEstimateModel(opts);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@libraz/coverwise",
3-
"version": "1.1.0",
3+
"version": "1.1.1",
44
"type": "module",
55
"packageManager": "yarn@4.12.0",
66
"description": "Combinatorial test coverage engine — analyze, generate, and extend t-wise test suites via WASM",

src/core/generator.cpp

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,35 @@ std::vector<std::vector<bool>> BuildValidOnlyMask(const std::vector<model::Param
8181
return mask;
8282
}
8383

84+
/// @brief Validate that a seed can participate in positive coverage.
85+
std::string ValidatePositiveSeed(const model::TestCase& seed,
86+
const std::vector<model::Parameter>& params,
87+
const std::vector<model::Constraint>& constraints) {
88+
if (seed.values.size() < params.size()) {
89+
return "expected " + std::to_string(params.size()) + " value(s), got " +
90+
std::to_string(seed.values.size());
91+
}
92+
93+
for (uint32_t pi = 0; pi < static_cast<uint32_t>(params.size()); ++pi) {
94+
uint32_t vi = seed.values[pi];
95+
if (vi >= params[pi].size()) {
96+
return "value index " + std::to_string(vi) + " is out of range for parameter " +
97+
params[pi].name;
98+
}
99+
if (params[pi].is_invalid(vi)) {
100+
return "value " + params[pi].name + "=" + params[pi].values[vi] + " is marked invalid";
101+
}
102+
}
103+
104+
for (const auto& constraint : constraints) {
105+
if (constraint->Evaluate(seed.values) == model::ConstraintResult::kFalse) {
106+
return "violates a constraint";
107+
}
108+
}
109+
110+
return {};
111+
}
112+
84113
/// @brief Build an allowed_values mask for negative test generation.
85114
///
86115
/// The fixed parameter is allowed only at the given invalid value index.
@@ -266,19 +295,29 @@ model::GenerateResult Generate(const GenerateOptions& options) {
266295
util::Rng rng(opts.seed);
267296

268297
// Pre-load seed tests into all engines.
269-
for (const auto& seed_test : opts.seeds) {
298+
bool dropped_for_max_tests = false;
299+
for (size_t si = 0; si < opts.seeds.size(); ++si) {
300+
const auto& seed_test = opts.seeds[si];
270301
if (opts.max_tests > 0 && result.tests.size() >= static_cast<size_t>(opts.max_tests)) {
271-
result.warnings.push_back("Seed test count (" + std::to_string(opts.seeds.size()) +
272-
") exceeds max_tests (" + std::to_string(opts.max_tests) +
273-
"); some seeds were dropped");
302+
dropped_for_max_tests = true;
274303
break;
275304
}
305+
auto seed_error = ValidatePositiveSeed(seed_test, opts.parameters, constraints);
306+
if (!seed_error.empty()) {
307+
result.warnings.push_back("Seed test " + std::to_string(si) + " ignored: " + seed_error);
308+
continue;
309+
}
276310
coverage.AddTestCase(seed_test);
277311
for (auto& eng : sub_engines) {
278312
eng.AddTestCase(seed_test);
279313
}
280314
result.tests.push_back(seed_test);
281315
}
316+
if (dropped_for_max_tests) {
317+
result.warnings.push_back("Seed test count (" + std::to_string(opts.seeds.size()) +
318+
") exceeds max_tests (" + std::to_string(opts.max_tests) +
319+
"); some seeds were dropped");
320+
}
282321

283322
// Scoring lambdas: avoid std::function wrapper on the hot path.
284323
auto simple_score_fn = [&](const model::TestCase& partial, uint32_t pi, uint32_t vi) {

src/ts/core/generator.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,51 @@ describe('generate', () => {
154154
expect(result.tests[1].values).toEqual([1, 1]);
155155
});
156156

157+
it('does not count invalid or constraint-violating seeds as positive coverage', () => {
158+
const opts = createGenerateOptions({
159+
parameters: [
160+
{ name: 'os', values: ['win', 'mac'] },
161+
{ name: 'browser', values: ['chrome', 'ie'], invalid: [false, true] },
162+
],
163+
constraintExpressions: ['IF os = mac THEN browser != chrome'],
164+
strength: 2,
165+
seed: 42,
166+
seeds: [
167+
{ values: [0, 1] }, // invalid browser value
168+
{ values: [1, 0] }, // violates constraint
169+
],
170+
});
171+
172+
const result = generate(opts);
173+
174+
expect(result.coverage).toBe(1.0);
175+
expect(result.tests.every((tc) => tc.values[1] !== 1)).toBe(true);
176+
expect(result.tests.every((tc) => !(tc.values[0] === 1 && tc.values[1] === 0))).toBe(true);
177+
expect(result.warnings).toEqual([
178+
'Seed test 0 ignored: value browser=ie is marked invalid',
179+
'Seed test 1 ignored: violates a constraint',
180+
]);
181+
});
182+
183+
it('drops seed tests beyond maxTests', () => {
184+
const opts = createGenerateOptions({
185+
parameters: [
186+
{ name: 'a', values: ['0', '1'] },
187+
{ name: 'b', values: ['0', '1'] },
188+
],
189+
maxTests: 1,
190+
seeds: [{ values: [0, 0] }, { values: [1, 1] }],
191+
});
192+
193+
const result = generate(opts);
194+
195+
expect(result.tests).toHaveLength(1);
196+
expect(result.tests[0].values).toEqual([0, 0]);
197+
expect(result.warnings).toContain(
198+
'Seed test count (2) exceeds maxTests (1); some seeds were dropped',
199+
);
200+
});
201+
157202
it('handles sub-models with mixed strength', () => {
158203
const opts = createGenerateOptions({
159204
parameters: [

0 commit comments

Comments
 (0)