From be92755cbf6d87da2d0c8e2d94fa1188e766036f Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Tue, 30 Jun 2026 12:42:03 -0400 Subject: [PATCH] schemagen: add regression test for nullable cyclic $ref targets (issue #14) The generator already preserves nullability for self-referential object $defs thanks to prior fixes, but issue #14 had no regression coverage. Add a test using the exact reproduction schema from the issue and verify that both the outer and recursive `self` fields accept `null`. --- contrib/schemagen/test_schemagen.py | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/contrib/schemagen/test_schemagen.py b/contrib/schemagen/test_schemagen.py index db452f2..a3e4425 100644 --- a/contrib/schemagen/test_schemagen.py +++ b/contrib/schemagen/test_schemagen.py @@ -353,6 +353,132 @@ class SchemagenCyclicArrayTest(unittest.TestCase): self.assertIn("std::optional> 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", + "properties": {"self": {"$ref": "#/$defs/Self"}}, + "$defs": { + "Self": { + "type": ["object", "null"], + "properties": {"self": {"$ref": "#/$defs/Self"}}, + } + }, + } + harness = textwrap.dedent( + """ + #include "gen.h" + #include + #include + 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.""" -- 2.43.0