Skip to content

Commit d8575ea

Browse files
committed
fix(model,wasm): support quoted strings and fix UTF-8 handling in constraint parser and WASM bindings
Three related bug fixes for non-ASCII and space-containing values: - Constraint parser (C++ and TS): add quoted string literal support (double-quote and single-quote) in tokenizer; previously quote characters caused silent parse failures - Constraint parser (C++): fix ToUpper() corrupting multi-byte UTF-8 sequences (Japanese, emoji) by limiting std::toupper to ASCII bytes only (< 0x80) - WASM bindings: fix ParseTestCase() failing to read non-ASCII object property keys; replaced hasOwnProperty(const char*) with Object.keys() iteration to correctly handle UTF-8 strings through Emscripten Bump version to 1.0.2.
1 parent 280f384 commit d8575ea

9 files changed

Lines changed: 511 additions & 12 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.0.1 LANGUAGES CXX)
2+
project(coverwise VERSION 1.0.2 LANGUAGES CXX)
33

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

js/constraint.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,3 +555,18 @@ describe('double-quote escaping through the builder', () => {
555555
expect(str).toBe('title IN {"say \\"hi\\"", plain}');
556556
});
557557
});
558+
559+
describe('quoted string support', () => {
560+
it('value with spaces produces quoted output', () => {
561+
expect(when('os').eq('Windows 10').toString()).toBe('os = "Windows 10"');
562+
});
563+
564+
it('Japanese value with spaces is quoted', () => {
565+
expect(when('名前').eq('山田 太郎').toString()).toBe('名前 = "山田 太郎"');
566+
});
567+
568+
it('quoted constraint roundtrip in then/else', () => {
569+
const constraint = when('os').eq('Windows 10').then(when('browser').ne('edge'));
570+
expect(constraint.toString()).toContain('"Windows 10"');
571+
});
572+
});

js/index.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,3 +752,173 @@ describe('Coverwise class', () => {
752752
expect(r1.tests).toEqual(r2.tests);
753753
});
754754
});
755+
756+
describe('Japanese and emoji support', () => {
757+
it('generates with Japanese parameter names', () => {
758+
const result = generate({
759+
parameters: [
760+
{ name: 'OS', values: ['win', 'mac'] },
761+
{ name: 'ブラウザ', values: ['chrome', 'firefox'] },
762+
],
763+
});
764+
expect(result.coverage).toBe(1.0);
765+
expect(result.tests.length).toBeGreaterThanOrEqual(2);
766+
for (const tc of result.tests) {
767+
expect(tc).toHaveProperty('ブラウザ');
768+
}
769+
});
770+
771+
it('generates with Japanese values', () => {
772+
const result = generate({
773+
parameters: [
774+
{ name: 'OS', values: ['ウィンドウズ', 'マック'] },
775+
{ name: 'ブラウザ', values: ['クローム', 'ファイアフォックス'] },
776+
],
777+
});
778+
expect(result.coverage).toBe(1.0);
779+
for (const tc of result.tests) {
780+
expect(['ウィンドウズ', 'マック']).toContain(tc['OS']);
781+
expect(['クローム', 'ファイアフォックス']).toContain(tc['ブラウザ']);
782+
}
783+
});
784+
785+
it('constraints with Japanese parameter names (unquoted)', () => {
786+
const result = generate({
787+
parameters: [
788+
{ name: 'OS', values: ['win', 'mac', 'linux'] },
789+
{ name: 'ブラウザ', values: ['chrome', 'safari', 'edge'] },
790+
],
791+
constraints: ['IF OS = mac THEN ブラウザ != edge'],
792+
});
793+
expect(result.coverage).toBe(1.0);
794+
for (const tc of result.tests) {
795+
if (tc['OS'] === 'mac') {
796+
expect(tc['ブラウザ']).not.toBe('edge');
797+
}
798+
}
799+
});
800+
801+
it('constraints with quoted values', () => {
802+
const result = generate({
803+
parameters: [
804+
{ name: 'OS', values: ['win', 'mac', 'linux'] },
805+
{ name: 'browser', values: ['chrome', 'safari', 'edge'] },
806+
],
807+
constraints: ['IF OS = "mac" THEN browser != "edge"'],
808+
});
809+
expect(result.coverage).toBe(1.0);
810+
for (const tc of result.tests) {
811+
if (tc['OS'] === 'mac') {
812+
expect(tc['browser']).not.toBe('edge');
813+
}
814+
}
815+
});
816+
817+
it('constraints with quoted Japanese values', () => {
818+
const result = generate({
819+
parameters: [
820+
{ name: 'OS', values: ['ウィンドウズ', 'マック', 'リナックス'] },
821+
{ name: 'ブラウザ', values: ['クローム', 'サファリ', 'エッジ'] },
822+
],
823+
constraints: ['IF OS = "マック" THEN ブラウザ != "エッジ"'],
824+
});
825+
expect(result.coverage).toBe(1.0);
826+
for (const tc of result.tests) {
827+
if (tc['OS'] === 'マック') {
828+
expect(tc['ブラウザ']).not.toBe('エッジ');
829+
}
830+
}
831+
});
832+
833+
it('constraints with values containing spaces (quoted)', () => {
834+
const result = generate({
835+
parameters: [
836+
{ name: 'OS', values: ['Windows 10', 'macOS', 'Ubuntu'] },
837+
{ name: 'browser', values: ['chrome', 'edge'] },
838+
],
839+
constraints: ['IF OS = "Windows 10" THEN browser != "edge"'],
840+
});
841+
expect(result.coverage).toBe(1.0);
842+
for (const tc of result.tests) {
843+
if (tc['OS'] === 'Windows 10') {
844+
expect(tc['browser']).not.toBe('edge');
845+
}
846+
}
847+
});
848+
849+
it('generates with emoji parameter names and values', () => {
850+
const result = generate({
851+
parameters: [
852+
{ name: '🖥️', values: ['💻', '🖥️'] },
853+
{ name: '🌐', values: ['🔥', '🧊'] },
854+
],
855+
});
856+
expect(result.coverage).toBe(1.0);
857+
for (const tc of result.tests) {
858+
expect(tc).toHaveProperty('🖥️');
859+
expect(tc).toHaveProperty('🌐');
860+
}
861+
});
862+
863+
it('analyzeCoverage with Japanese parameter names', () => {
864+
const params: Parameter[] = [
865+
{ name: 'OS', values: ['win', 'mac'] },
866+
{ name: 'ブラウザ', values: ['chrome', 'firefox'] },
867+
];
868+
const tests: TestCase[] = [
869+
{ OS: 'win', ブラウザ: 'chrome' },
870+
{ OS: 'win', ブラウザ: 'firefox' },
871+
{ OS: 'mac', ブラウザ: 'chrome' },
872+
{ OS: 'mac', ブラウザ: 'firefox' },
873+
];
874+
const report = analyzeCoverage(params, tests, 2);
875+
expect(report.coverageRatio).toBe(1.0);
876+
expect(report.uncovered).toHaveLength(0);
877+
});
878+
879+
it('analyzeCoverage with emoji keys', () => {
880+
const params: Parameter[] = [
881+
{ name: '🎯', values: ['hit', 'miss'] },
882+
{ name: '🎲', values: ['1', '2'] },
883+
];
884+
const tests: TestCase[] = [
885+
{ '🎯': 'hit', '🎲': '1' },
886+
{ '🎯': 'miss', '🎲': '2' },
887+
{ '🎯': 'hit', '🎲': '2' },
888+
{ '🎯': 'miss', '🎲': '1' },
889+
];
890+
const report = analyzeCoverage(params, tests, 2);
891+
expect(report.coverageRatio).toBe(1.0);
892+
});
893+
894+
it('extendTests with Japanese parameter names', () => {
895+
const input = {
896+
parameters: [
897+
{ name: 'OS', values: ['win', 'mac'] },
898+
{ name: 'ブラウザ', values: ['chrome', 'firefox'] },
899+
{ name: '言語', values: ['日本語', 'English'] },
900+
],
901+
seed: 1,
902+
};
903+
const existing: TestCase[] = [{ OS: 'win', ブラウザ: 'chrome', 言語: '日本語' }];
904+
const result = extendTests(existing, input);
905+
expect(result.coverage).toBe(1.0);
906+
expect(result.tests[0]).toEqual(existing[0]);
907+
});
908+
909+
it('single-quoted values in constraints', () => {
910+
const result = generate({
911+
parameters: [
912+
{ name: 'os', values: ['win', 'mac'] },
913+
{ name: 'browser', values: ['chrome', 'edge'] },
914+
],
915+
constraints: ["IF os = 'mac' THEN browser != 'edge'"],
916+
});
917+
expect(result.coverage).toBe(1.0);
918+
for (const tc of result.tests) {
919+
if (tc['os'] === 'mac') {
920+
expect(tc['browser']).not.toBe('edge');
921+
}
922+
}
923+
});
924+
});

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.0.1",
3+
"version": "1.0.2",
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/model/constraint_parser.cpp

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ struct Token {
5050

5151
std::string ToUpper(const std::string& s) {
5252
std::string result = s;
53-
std::transform(result.begin(), result.end(), result.begin(),
54-
[](unsigned char c) { return std::toupper(c); });
53+
for (auto& c : result) {
54+
auto uc = static_cast<unsigned char>(c);
55+
if (uc < 0x80) {
56+
c = static_cast<char>(std::toupper(uc));
57+
}
58+
}
5559
return result;
5660
}
5761

@@ -221,6 +225,26 @@ TokenizeResult Tokenize(const std::string& expr) {
221225
}
222226
}
223227

228+
// Quoted string: "..." or '...'
229+
if (expr[i] == '"' || expr[i] == '\'') {
230+
char quote = expr[i];
231+
size_t j = i + 1;
232+
while (j < len && expr[j] != quote) {
233+
++j;
234+
}
235+
if (j >= len) {
236+
return {{},
237+
{Error::Code::kConstraintError,
238+
"Unterminated string literal starting at position " + std::to_string(start),
239+
""}};
240+
}
241+
std::string content = expr.substr(i + 1, j - i - 1);
242+
tokens.push_back({TokenType::kIdentifier, content, start});
243+
i = j + 1;
244+
expect_pattern = false;
245+
continue;
246+
}
247+
224248
if (IsIdentChar(expr[i])) {
225249
size_t j = i;
226250
while (j < len && IsIdentChar(expr[j])) {

src/ts/model/constraint-parser.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,30 @@ function tokenize(expr: string): TokenizeResult {
284284
}
285285
}
286286

287+
// Quoted string: "..." or '...'
288+
if (expr[i] === '"' || expr[i] === "'") {
289+
const quote = expr[i];
290+
let j = i + 1;
291+
while (j < len && expr[j] !== quote) {
292+
j++;
293+
}
294+
if (j >= len) {
295+
return {
296+
tokens: [],
297+
error: {
298+
code: ErrorCode.ConstraintError,
299+
message: `Unterminated string literal starting at position ${start}`,
300+
detail: '',
301+
},
302+
};
303+
}
304+
const content = expr.substring(i + 1, j);
305+
tokens.push({ type: TokenType.Identifier, text: content, position: start });
306+
i = j + 1;
307+
expectPattern = false;
308+
continue;
309+
}
310+
287311
if (isIdentChar(expr[i])) {
288312
let j = i;
289313
while (j < len && isIdentChar(expr[j])) {

src/wasm/bindings.cpp

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,27 @@ coverwise::model::TestCase ParseTestCase(val js_test,
129129
const std::vector<coverwise::model::Parameter>& params) {
130130
coverwise::model::TestCase tc;
131131
tc.values.resize(params.size(), coverwise::model::kUnassigned);
132-
for (uint32_t i = 0; i < params.size(); ++i) {
133-
if (js_test.hasOwnProperty(params[i].name.c_str())) {
134-
std::string val_str = JsValueToString(js_test[params[i].name]);
135-
uint32_t idx = params[i].find_value_index(val_str);
136-
if (idx == UINT32_MAX) {
137-
throw std::runtime_error("Unknown value '" + val_str + "' for parameter '" +
138-
params[i].name + "'");
132+
133+
// Build a map from JS object keys to their values.
134+
// Using Object.keys() avoids hasOwnProperty(const char*) which
135+
// can fail with non-ASCII (UTF-8) property names in Emscripten.
136+
val keys = val::global("Object").call<val>("keys", js_test);
137+
uint32_t key_count = keys["length"].as<uint32_t>();
138+
139+
for (uint32_t k = 0; k < key_count; ++k) {
140+
std::string key = keys[k].as<std::string>();
141+
// Find matching parameter
142+
for (uint32_t i = 0; i < params.size(); ++i) {
143+
if (params[i].name == key) {
144+
std::string val_str = JsValueToString(js_test[key]);
145+
uint32_t idx = params[i].find_value_index(val_str);
146+
if (idx == UINT32_MAX) {
147+
throw std::runtime_error("Unknown value '" + val_str + "' for parameter '" +
148+
params[i].name + "'");
149+
}
150+
tc.values[i] = idx;
151+
break;
139152
}
140-
tc.values[i] = idx;
141153
}
142154
}
143155
return tc;

0 commit comments

Comments
 (0)