CI / pre-commit (push) Successful in 53s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64, true) (push) Successful in 53s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64, false) (push) Successful in 50s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64, true) (push) Successful in 1m36s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64, false) (push) Successful in 1m34s
Reviewed-on: #58
865 lines
32 KiB
Python
865 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for weaseljson_schemagen.py."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
|
|
SCRIPT = os.path.join(os.path.dirname(__file__), "weaseljson_schemagen.py")
|
|
|
|
|
|
class SchemagenEnumTest(unittest.TestCase):
|
|
def run_schemagen(self, schema, args=None):
|
|
"""Run schemagen on a schema dict. Returns (returncode, stdout, stderr)."""
|
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp:
|
|
json.dump(schema, fp)
|
|
schema_path = fp.name
|
|
try:
|
|
cmd = [sys.executable, SCRIPT, schema_path]
|
|
if args:
|
|
cmd.extend(args)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
return result.returncode, result.stdout, result.stderr
|
|
finally:
|
|
os.unlink(schema_path)
|
|
|
|
def test_colliding_enum_values_deduplicate(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"role": {"enum": ["foo-bar", "foo_bar"]}},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertEqual(rc, 0, msg=stderr)
|
|
self.assertIn("enum class Role : int { foo_bar, foo_bar_1 };", stdout)
|
|
self.assertIn(
|
|
'static constexpr const char *Role_names[] = { "foo-bar", "foo_bar" };',
|
|
stdout,
|
|
)
|
|
|
|
def test_distinct_enum_values_generate(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"role": {"enum": ["admin", "user", "guest"]}},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertEqual(rc, 0, msg=stderr)
|
|
self.assertIn("enum class Role : int { admin, user, guest };", stdout)
|
|
|
|
|
|
class SchemagenKeywordTest(unittest.TestCase):
|
|
def run_schemagen(self, schema, args=None):
|
|
"""Run schemagen on a schema dict. Returns (returncode, stdout, stderr)."""
|
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp:
|
|
json.dump(schema, fp)
|
|
schema_path = fp.name
|
|
try:
|
|
cmd = [sys.executable, SCRIPT, schema_path]
|
|
if args:
|
|
cmd.extend(args)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
return result.returncode, result.stdout, result.stderr
|
|
finally:
|
|
os.unlink(schema_path)
|
|
|
|
def test_cpp20_keywords_are_sanitized(self):
|
|
"""C++20 keywords used as JSON property names must be suffixed."""
|
|
keywords = [
|
|
"concept",
|
|
"consteval",
|
|
"constinit",
|
|
"co_await",
|
|
"co_return",
|
|
"co_yield",
|
|
"requires",
|
|
"module",
|
|
"import",
|
|
]
|
|
schema = {"type": "object", "additionalProperties": False, "properties": {}}
|
|
for kw in keywords:
|
|
schema["properties"][kw] = {"type": "string"}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertEqual(rc, 0, msg=stderr)
|
|
for kw in keywords:
|
|
self.assertIn(f"std::optional<std::string> {kw}_;", stdout)
|
|
self.assertNotIn(f"std::optional<std::string> {kw};", stdout)
|
|
|
|
|
|
class SchemagenCollisionTest(unittest.TestCase):
|
|
"""Regression tests for issue #21: generated Root alias / Kind enum collisions."""
|
|
|
|
def setUp(self):
|
|
self.repo_root = os.path.dirname(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
)
|
|
self.include_dir = os.path.join(self.repo_root, "include")
|
|
self.compiler = shutil.which("c++")
|
|
|
|
def generate_and_compile(self, schema):
|
|
"""Run schemagen on schema and syntax-check the resulting header."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
schema_path = os.path.join(tmpdir, "schema.json")
|
|
with open(schema_path, "w") as fp:
|
|
json.dump(schema, fp)
|
|
header_path = os.path.join(tmpdir, "gen.h")
|
|
cmd = [
|
|
sys.executable,
|
|
SCRIPT,
|
|
schema_path,
|
|
"-o",
|
|
header_path,
|
|
"--namespace",
|
|
"test_schema",
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
|
|
if self.compiler:
|
|
cpp_path = os.path.join(tmpdir, "test.cpp")
|
|
with open(cpp_path, "w") as fp:
|
|
fp.write(
|
|
'#include "gen.h"\n'
|
|
"int main() {\n"
|
|
" test_schema::RootBuilder b;\n"
|
|
" test_schema::Root r = b.take();\n"
|
|
" (void)r;\n"
|
|
"}\n"
|
|
)
|
|
comp = subprocess.run(
|
|
[
|
|
self.compiler,
|
|
"-std=c++20",
|
|
"-fsyntax-only",
|
|
"-I",
|
|
self.include_dir,
|
|
"-I",
|
|
tmpdir,
|
|
cpp_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(comp.returncode, 0, msg=comp.stderr)
|
|
|
|
with open(header_path) as fp:
|
|
return fp.read()
|
|
|
|
def test_root_alias_does_not_collide_with_user_type(self):
|
|
schema = {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Root"},
|
|
"$defs": {"Root": {"enum": ["a", "b"]}},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
self.assertIn("enum class Root1 : int { a, b };", out)
|
|
self.assertIn("using Root = std::vector<Root1>;", out)
|
|
self.assertNotIn("using Root = std::vector<Root>;", out)
|
|
|
|
def test_kind_enum_does_not_duplicate_arr0(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"arr": {"type": "array", "items": {"type": "string"}},
|
|
"obj": {"$ref": "#/$defs/Arr0"},
|
|
},
|
|
"$defs": {
|
|
"Arr0": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {},
|
|
}
|
|
},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
self.assertIn("struct Arr0", out)
|
|
m = re.search(r"enum class Kind : uint8_t \{([^}]+)\}", out)
|
|
self.assertIsNotNone(m)
|
|
enumerators = [e.strip() for e in m.group(1).split(",")]
|
|
self.assertIn("Arr0", enumerators)
|
|
self.assertIn("Arr1", enumerators)
|
|
self.assertEqual(len(enumerators), len(set(enumerators)))
|
|
|
|
def test_skip_user_type_is_allowed(self):
|
|
schema = {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Skip"},
|
|
"$defs": {
|
|
"Skip": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {},
|
|
}
|
|
},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
self.assertIn("struct Skip", out)
|
|
self.assertNotIn("struct Skip1", out)
|
|
m = re.search(r"enum class Kind : uint8_t \{([^}]+)\}", out)
|
|
self.assertIsNotNone(m)
|
|
enumerators = [e.strip() for e in m.group(1).split(",")]
|
|
self.assertIn("Skip", enumerators)
|
|
self.assertIn("Arr0", enumerators)
|
|
self.assertEqual(len(enumerators), len(set(enumerators)))
|
|
|
|
def test_non_object_defs_reused_across_refs(self):
|
|
"""Regression test for issue #17: enum and array $defs must be reused."""
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"role1": {"$ref": "#/$defs/Role"},
|
|
"role2": {"$ref": "#/$defs/Role"},
|
|
"roles1": {"$ref": "#/$defs/Roles"},
|
|
"roles2": {"$ref": "#/$defs/Roles"},
|
|
},
|
|
"$defs": {
|
|
"Role": {"enum": ["admin", "user"]},
|
|
"Roles": {"type": "array", "items": {"$ref": "#/$defs/Role"}},
|
|
},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
# Exactly one Role enum is generated.
|
|
self.assertIn("enum class Role : int { admin, user };", out)
|
|
self.assertNotIn("enum class Role1 : int", out)
|
|
self.assertNotIn("enum class Role2 : int", out)
|
|
# The array of enum is represented by a single kind.
|
|
m = re.search(r"enum class Kind : uint8_t \{([^}]+)\}", out)
|
|
self.assertIsNotNone(m)
|
|
enumerators = [e.strip() for e in m.group(1).split(",")]
|
|
self.assertEqual(enumerators.count("Arr0"), 1)
|
|
# All four fields use the same C++ types.
|
|
self.assertIn("std::optional<Role> role1;", out)
|
|
self.assertIn("std::optional<Role> role2;", out)
|
|
self.assertIn("std::optional<std::vector<Role>> roles1;", out)
|
|
self.assertIn("std::optional<std::vector<Role>> roles2;", out)
|
|
|
|
|
|
class SchemagenAdditionalPropertiesTest(unittest.TestCase):
|
|
def run_schemagen(self, schema, args=None):
|
|
"""Run schemagen on a schema dict. Returns (returncode, stdout, stderr)."""
|
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp:
|
|
json.dump(schema, fp)
|
|
schema_path = fp.name
|
|
try:
|
|
cmd = [sys.executable, SCRIPT, schema_path]
|
|
if args:
|
|
cmd.extend(args)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
return result.returncode, result.stdout, result.stderr
|
|
finally:
|
|
os.unlink(schema_path)
|
|
|
|
def test_additional_properties_true_rejected(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": True,
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("additionalProperties: true is not supported", stderr)
|
|
|
|
def test_additional_properties_absent_rejected(self):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("additionalProperties is required for object schemas", stderr)
|
|
|
|
def test_additional_properties_false_accepted(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertEqual(rc, 0, msg=stderr)
|
|
|
|
|
|
class SchemagenCyclicArrayTest(unittest.TestCase):
|
|
"""Regression tests for issue #33: cyclic array $ref targets."""
|
|
|
|
def run_schemagen(self, schema, args=None):
|
|
"""Run schemagen on a schema dict. Returns (returncode, stdout, stderr)."""
|
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp:
|
|
json.dump(schema, fp)
|
|
schema_path = fp.name
|
|
try:
|
|
cmd = [sys.executable, SCRIPT, schema_path]
|
|
if args:
|
|
cmd.extend(args)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
return result.returncode, result.stdout, result.stderr
|
|
finally:
|
|
os.unlink(schema_path)
|
|
|
|
def test_direct_self_referential_array_rejected(self):
|
|
schema = {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Node"},
|
|
"$defs": {
|
|
"Node": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Node"},
|
|
}
|
|
},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("recursive array type is not supported", stderr)
|
|
self.assertIn("Node", stderr)
|
|
|
|
def test_object_field_to_self_referential_array_rejected(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"items": {"$ref": "#/$defs/Items"}},
|
|
"$defs": {
|
|
"Items": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Items"},
|
|
}
|
|
},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("recursive array type is not supported", stderr)
|
|
self.assertIn("Items", stderr)
|
|
|
|
def test_chain_of_array_refs_rejected(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"x": {"$ref": "#/$defs/A"}},
|
|
"$defs": {
|
|
"A": {"type": "array", "items": {"$ref": "#/$defs/B"}},
|
|
"B": {"type": "array", "items": {"$ref": "#/$defs/A"}},
|
|
},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("recursive array type is not supported", stderr)
|
|
|
|
def test_non_recursive_array_refs_still_allowed(self):
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"roles": {"$ref": "#/$defs/Roles"}},
|
|
"$defs": {
|
|
"Role": {"enum": ["admin", "user"]},
|
|
"Roles": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/Role"},
|
|
},
|
|
},
|
|
}
|
|
rc, stdout, stderr = self.run_schemagen(schema)
|
|
self.assertEqual(rc, 0, msg=stderr)
|
|
self.assertIn("std::optional<std::vector<Role>> roles;", stdout)
|
|
|
|
|
|
class SchemagenCyclicNullableObjectTest(unittest.TestCase):
|
|
"""Regression tests for issue #14: nullable cyclic $ref targets."""
|
|
|
|
def setUp(self):
|
|
self.repo_root = os.path.dirname(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
)
|
|
self.include_dir = os.path.join(self.repo_root, "include")
|
|
self.lib_src = os.path.join(self.repo_root, "src", "lib.cpp")
|
|
self.compiler = shutil.which("c++")
|
|
|
|
def _compile_harness(self, tmpdir, schema, harness):
|
|
schema_path = os.path.join(tmpdir, "schema.json")
|
|
with open(schema_path, "w") as fp:
|
|
json.dump(schema, fp)
|
|
header_path = os.path.join(tmpdir, "gen.h")
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
SCRIPT,
|
|
schema_path,
|
|
"-o",
|
|
header_path,
|
|
"--namespace",
|
|
"test_schema",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
|
|
lib_obj = os.path.join(tmpdir, "lib.o")
|
|
comp_lib = subprocess.run(
|
|
[
|
|
self.compiler,
|
|
"-std=c++20",
|
|
"-I",
|
|
self.include_dir,
|
|
"-I",
|
|
os.path.join(self.repo_root, "third_party", "include"),
|
|
"-I",
|
|
os.path.join(self.repo_root, "third_party", "valgrind"),
|
|
"-c",
|
|
self.lib_src,
|
|
"-o",
|
|
lib_obj,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(comp_lib.returncode, 0, msg=comp_lib.stderr)
|
|
|
|
cpp_path = os.path.join(tmpdir, "test.cpp")
|
|
with open(cpp_path, "w") as fp:
|
|
fp.write(harness)
|
|
|
|
exe_path = os.path.join(tmpdir, "test")
|
|
comp = subprocess.run(
|
|
[
|
|
self.compiler,
|
|
"-std=c++20",
|
|
"-I",
|
|
self.include_dir,
|
|
"-I",
|
|
tmpdir,
|
|
lib_obj,
|
|
cpp_path,
|
|
"-o",
|
|
exe_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(comp.returncode, 0, msg=comp.stderr)
|
|
run = subprocess.run([exe_path], capture_output=True, text=True, check=False)
|
|
self.assertEqual(run.returncode, 0, msg=run.stdout + run.stderr)
|
|
|
|
def test_self_referential_nullable_object_accepts_nested_null(self):
|
|
"""A nullable object $def must stay nullable on recursive $refs."""
|
|
if not self.compiler:
|
|
self.skipTest("C++ compiler not available")
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"self": {"$ref": "#/$defs/Self"}},
|
|
"$defs": {
|
|
"Self": {
|
|
"type": ["object", "null"],
|
|
"additionalProperties": False,
|
|
"properties": {"self": {"$ref": "#/$defs/Self"}},
|
|
}
|
|
},
|
|
}
|
|
harness = textwrap.dedent(
|
|
"""
|
|
#include "gen.h"
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
struct Case { const char *s; WeaselJsonStatus expected; };
|
|
int main() {
|
|
Case cases[] = {
|
|
{ R"({"self": null})", WeaselJson_OK },
|
|
{ R"({"self": {"self": null}})", WeaselJson_OK },
|
|
{ R"({"self": {"self": {}}})", WeaselJson_OK },
|
|
};
|
|
for (const auto &c : cases) {
|
|
test_schema::RootBuilder b;
|
|
char buf[256];
|
|
std::strncpy(buf, c.s, sizeof(buf) - 1);
|
|
buf[sizeof(buf) - 1] = '\\0';
|
|
WeaselJsonStatus st = b.feed(buf, std::strlen(buf));
|
|
st = b.finish();
|
|
if (st != c.expected) {
|
|
std::printf("case %s expected %d got %d\\n", c.s, c.expected, st);
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
"""
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
self._compile_harness(tmpdir, schema, harness)
|
|
|
|
|
|
class SchemagenIntegerBoundaryTest(unittest.TestCase):
|
|
"""Regression tests for issue #19: integer slot parsing near int64 boundaries."""
|
|
|
|
def setUp(self):
|
|
self.repo_root = os.path.dirname(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
)
|
|
self.include_dir = os.path.join(self.repo_root, "include")
|
|
self.lib_src = os.path.join(self.repo_root, "src", "lib.cpp")
|
|
self.compiler = shutil.which("c++")
|
|
|
|
def _compile_harness(self, tmpdir, schema, harness):
|
|
schema_path = os.path.join(tmpdir, "schema.json")
|
|
with open(schema_path, "w") as fp:
|
|
json.dump(schema, fp)
|
|
header_path = os.path.join(tmpdir, "gen.h")
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
SCRIPT,
|
|
schema_path,
|
|
"-o",
|
|
header_path,
|
|
"--namespace",
|
|
"test_schema",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
|
|
lib_obj = os.path.join(tmpdir, "lib.o")
|
|
comp_lib = subprocess.run(
|
|
[
|
|
self.compiler,
|
|
"-std=c++20",
|
|
"-I",
|
|
self.include_dir,
|
|
"-I",
|
|
os.path.join(self.repo_root, "third_party", "include"),
|
|
"-I",
|
|
os.path.join(self.repo_root, "third_party", "valgrind"),
|
|
"-c",
|
|
self.lib_src,
|
|
"-o",
|
|
lib_obj,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(comp_lib.returncode, 0, msg=comp_lib.stderr)
|
|
|
|
cpp_path = os.path.join(tmpdir, "test.cpp")
|
|
with open(cpp_path, "w") as fp:
|
|
fp.write(harness)
|
|
|
|
exe_path = os.path.join(tmpdir, "test")
|
|
comp = subprocess.run(
|
|
[
|
|
self.compiler,
|
|
"-std=c++20",
|
|
"-I",
|
|
self.include_dir,
|
|
"-I",
|
|
tmpdir,
|
|
lib_obj,
|
|
cpp_path,
|
|
"-o",
|
|
exe_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(comp.returncode, 0, msg=comp.stderr)
|
|
run = subprocess.run([exe_path], capture_output=True, text=True, check=False)
|
|
self.assertEqual(run.returncode, 0, msg=run.stdout + run.stderr)
|
|
|
|
def _build_cases_array(self, name, entries):
|
|
lines = [
|
|
f" struct {name}Case {{ const char *s; WeaselJsonStatus expected; long long v; }};"
|
|
]
|
|
lines.append(f" {name}Case {name}_cases[] = {{")
|
|
for s, exp, v in entries:
|
|
val = f"{v}LL" if isinstance(v, int) else v
|
|
lines.append(f' {{ R"({s})", {exp}, {val} }},')
|
|
lines.append(" };")
|
|
return "\n".join(lines)
|
|
|
|
def test_integer_boundary_root(self):
|
|
if not self.compiler:
|
|
self.skipTest("C++ compiler not available")
|
|
cases = [
|
|
("9223372036854775807.0", "WeaselJson_OK", 9223372036854775807),
|
|
("9223372036854775807", "WeaselJson_OK", 9223372036854775807),
|
|
("9223372036854775806.0", "WeaselJson_OK", 9223372036854775806),
|
|
("9223372036854775808", "WeaselJson_REJECT", 0),
|
|
("-9223372036854775808", "WeaselJson_OK", "-9223372036854775807LL - 1"),
|
|
("-9223372036854775808.0", "WeaselJson_OK", "-9223372036854775807LL - 1"),
|
|
("-9223372036854775809", "WeaselJson_REJECT", 0),
|
|
("1e3", "WeaselJson_OK", 1000),
|
|
("2.0", "WeaselJson_OK", 2),
|
|
("0.001", "WeaselJson_REJECT", 0),
|
|
("1e-3", "WeaselJson_REJECT", 0),
|
|
("1000e-3", "WeaselJson_OK", 1),
|
|
("100.0e-2", "WeaselJson_OK", 1),
|
|
("0.0001e4", "WeaselJson_OK", 1),
|
|
("0.001e3", "WeaselJson_OK", 1),
|
|
("123.0", "WeaselJson_OK", 123),
|
|
("9e18", "WeaselJson_OK", 9000000000000000000),
|
|
("10e18", "WeaselJson_REJECT", 0),
|
|
# Issue #56: zero written with a negative exponent must be accepted.
|
|
("0e-1", "WeaselJson_OK", 0),
|
|
("0e-2", "WeaselJson_OK", 0),
|
|
("0.0e-2", "WeaselJson_OK", 0),
|
|
("-0e-2", "WeaselJson_OK", 0),
|
|
("0e-20", "WeaselJson_OK", 0),
|
|
("0.000e-5", "WeaselJson_OK", 0),
|
|
]
|
|
cases_src = self._build_cases_array("root", cases)
|
|
harness = textwrap.dedent(
|
|
f"""
|
|
#include "gen.h"
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
{cases_src}
|
|
int main() {{
|
|
for (const auto \u0026c : root_cases) {{
|
|
test_schema::RootBuilder b;
|
|
char buf[512];
|
|
std::strncpy(buf, c.s, sizeof(buf) - 1);
|
|
buf[sizeof(buf) - 1] = '\\0';
|
|
WeaselJsonStatus st = b.feed(buf, std::strlen(buf));
|
|
st = b.finish();
|
|
if (st != c.expected) {{
|
|
std::printf("root case %s expected %d got %d\\n", c.s, c.expected, st);
|
|
return 1;
|
|
}}
|
|
if (st == WeaselJson_OK \u0026\u0026 b.take() != c.v) {{
|
|
std::printf("root case %s value mismatch\\n", c.s);
|
|
return 2;
|
|
}}
|
|
}}
|
|
return 0;
|
|
}}
|
|
"""
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
self._compile_harness(tmpdir, {"type": "integer"}, harness)
|
|
|
|
def test_integer_boundary_object_field(self):
|
|
if not self.compiler:
|
|
self.skipTest("C++ compiler not available")
|
|
cases = [
|
|
('{"age":9223372036854775807.0}', "WeaselJson_OK", 9223372036854775807),
|
|
('{"age":-9223372036854775809}', "WeaselJson_REJECT", 0),
|
|
('{"age":1e3}', "WeaselJson_OK", 1000),
|
|
('{"age":2.0}', "WeaselJson_OK", 2),
|
|
('{"age":0.0001e4}', "WeaselJson_OK", 1),
|
|
('{"age":0.001}', "WeaselJson_REJECT", 0),
|
|
# Issue #56: zero written with a negative exponent must be accepted.
|
|
('{"age":0e-2}', "WeaselJson_OK", 0),
|
|
('{"age":-0e-20}', "WeaselJson_OK", 0),
|
|
(
|
|
'{"age":-9223372036854775808.0}',
|
|
"WeaselJson_OK",
|
|
"-9223372036854775807LL - 1",
|
|
),
|
|
]
|
|
cases_src = self._build_cases_array("object", cases)
|
|
harness = textwrap.dedent(
|
|
f"""
|
|
#include "gen.h"
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
{cases_src}
|
|
int main() {{
|
|
for (const auto \u0026c : object_cases) {{
|
|
test_schema::RootBuilder b;
|
|
char buf[512];
|
|
std::strncpy(buf, c.s, sizeof(buf) - 1);
|
|
buf[sizeof(buf) - 1] = '\\0';
|
|
WeaselJsonStatus st = b.feed(buf, std::strlen(buf));
|
|
st = b.finish();
|
|
if (st != c.expected) {{
|
|
std::printf("object case %s expected %d got %d\\n", c.s, c.expected, st);
|
|
return 3;
|
|
}}
|
|
if (st == WeaselJson_OK \u0026\u0026 b.take().age != c.v) {{
|
|
std::printf("object case %s value mismatch\\n", c.s);
|
|
return 4;
|
|
}}
|
|
}}
|
|
return 0;
|
|
}}
|
|
"""
|
|
)
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"age": {"type": "integer"}},
|
|
"required": ["age"],
|
|
}
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
self._compile_harness(tmpdir, schema, harness)
|
|
|
|
def test_integer_no_quadratic_leading_zero_loop(self):
|
|
"""Regression test for issue #34: leading-zero stripping must not be quadratic."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
schema_path = os.path.join(tmpdir, "schema.json")
|
|
with open(schema_path, "w") as fp:
|
|
json.dump({"type": "integer"}, fp)
|
|
result = subprocess.run(
|
|
[sys.executable, SCRIPT, schema_path],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
self.assertIn("parseJsonInt64", result.stdout)
|
|
self.assertNotIn("digits.erase(digits.begin())", result.stdout)
|
|
|
|
def test_integer_large_fractional_leading_zeros(self):
|
|
"""Numbers with many leading fractional zeros must parse correctly."""
|
|
if not self.compiler:
|
|
self.skipTest("C++ compiler not available")
|
|
harness = textwrap.dedent(
|
|
"""
|
|
#include "gen.h"
|
|
#include <cstdio>
|
|
#include <string>
|
|
int main() {
|
|
const int n = 100000;
|
|
std::string s = std::string("0.") + std::string(n - 1, '0') + "1e" + std::to_string(n);
|
|
test_schema::RootBuilder b;
|
|
WeaselJsonStatus st = b.feed(s.data(), static_cast<int>(s.size()));
|
|
st = b.finish();
|
|
if (st != WeaselJson_OK) {
|
|
std::printf("expected OK, got %d\\n", st);
|
|
return 1;
|
|
}
|
|
if (b.take() != 1) {
|
|
std::printf("expected value 1\\n");
|
|
return 2;
|
|
}
|
|
return 0;
|
|
}
|
|
"""
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
self._compile_harness(tmpdir, {"type": "integer"}, harness)
|
|
|
|
|
|
class SchemagenStringEscapeTest(unittest.TestCase):
|
|
"""Regression tests for issue #35: control characters in string literals."""
|
|
|
|
def setUp(self):
|
|
self.repo_root = os.path.dirname(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
)
|
|
self.include_dir = os.path.join(self.repo_root, "include")
|
|
self.compiler = shutil.which("c++")
|
|
|
|
def generate_and_compile(self, schema):
|
|
"""Run schemagen on schema and syntax-check the resulting header."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
schema_path = os.path.join(tmpdir, "schema.json")
|
|
with open(schema_path, "w") as fp:
|
|
json.dump(schema, fp)
|
|
header_path = os.path.join(tmpdir, "gen.h")
|
|
cmd = [
|
|
sys.executable,
|
|
SCRIPT,
|
|
schema_path,
|
|
"-o",
|
|
header_path,
|
|
"--namespace",
|
|
"test_schema",
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
|
|
|
if self.compiler:
|
|
cpp_path = os.path.join(tmpdir, "test.cpp")
|
|
with open(cpp_path, "w") as fp:
|
|
fp.write(
|
|
'#include "gen.h"\n'
|
|
"int main() {\n"
|
|
" test_schema::RootBuilder b;\n"
|
|
" test_schema::Root r = b.take();\n"
|
|
" (void)r;\n"
|
|
"}\n"
|
|
)
|
|
comp = subprocess.run(
|
|
[
|
|
self.compiler,
|
|
"-std=c++20",
|
|
"-fsyntax-only",
|
|
"-I",
|
|
self.include_dir,
|
|
"-I",
|
|
tmpdir,
|
|
cpp_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(comp.returncode, 0, msg=comp.stderr)
|
|
|
|
with open(header_path) as fp:
|
|
return fp.read()
|
|
|
|
def test_newline_in_property_key(self):
|
|
"""A JSON key containing a newline must become a valid C++ literal."""
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"a\nb": {"type": "string"}},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
self.assertIn('// "a\\nb"', out)
|
|
self.assertIn('if (key == "a\\nb") return 0;', out)
|
|
|
|
def test_newline_in_enum_value(self):
|
|
"""An enum value containing a newline must become a valid C++ literal."""
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"x": {"enum": ["a\nb"]}},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
self.assertIn('static constexpr const char *X_names[] = { "a\\nb" };', out)
|
|
|
|
def test_mixed_control_chars_in_enum_value(self):
|
|
"""Mixed control characters in an enum value must be escaped."""
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"x": {"enum": ["x\ny\rz\tw\vq\x00\x01"]},
|
|
},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
self.assertIn(
|
|
'static constexpr const char *X_names[] = { "x\\ny\\rz\\tw\\vq\\u0000\\u0001" };',
|
|
out,
|
|
)
|
|
|
|
def test_backslash_and_quote_still_escaped(self):
|
|
"""Existing escaping for backslash and double quote must remain correct."""
|
|
schema = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {'a"b\\c': {"type": "string"}},
|
|
}
|
|
out = self.generate_and_compile(schema)
|
|
self.assertIn('// "a\\"b\\\\c"', out)
|
|
self.assertIn('if (key == "a\\"b\\\\c") return 0;', out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|