odin-jsonschema

Implementation of JSON schema for Odin
Log | Files | Refs | LICENSE

generate_test.odin (8532B)


      1 package jschema
      2 
      3 import "core:mem"
      4 import "core:os"
      5 import "core:path/filepath"
      6 import "core:strings"
      7 import "core:testing"
      8 
      9 // Repo root derived from this source file, so tests work from any cwd.
     10 @(private = "file")
     11 repo_root :: proc(allocator := context.allocator) -> string {
     12 	context.allocator = allocator
     13 	source_dir := filepath.dir(#location().file_path)
     14 	pkg_dir := filepath.dir(source_dir)
     15 	src_dir := filepath.dir(pkg_dir)
     16 	return filepath.dir(src_dir)
     17 }
     18 
     19 @(private = "file")
     20 join :: proc(elems: []string, allocator := context.allocator) -> string {
     21 	path, _ := filepath.join(elems, allocator)
     22 	return path
     23 }
     24 
     25 // Every testdata case with an expected.odin is a golden test: generating from
     26 // its schema.json must reproduce expected.odin byte for byte.
     27 @(test)
     28 golden :: proc(t: ^testing.T) {
     29 	root := repo_root(context.temp_allocator)
     30 	cases_dir := join({root, "testdata", "cases"}, context.temp_allocator)
     31 	entries, rerr := os.read_all_directory_by_path(cases_dir, context.temp_allocator)
     32 	if !testing.expectf(t, rerr == nil, "cannot list %s: %v", cases_dir, rerr) {
     33 		return
     34 	}
     35 	tested := 0
     36 	for entry in entries {
     37 		if entry.type != .Directory {
     38 			continue
     39 		}
     40 		expected_path := join({entry.fullpath, "expected.odin"}, context.temp_allocator)
     41 		expected, eerr := os.read_entire_file_from_path(expected_path, context.temp_allocator)
     42 		if eerr != nil {
     43 			continue // no golden for this case (e.g. openapi is end-to-end only)
     44 		}
     45 		schema_path := join({entry.fullpath, "schema.json"}, context.temp_allocator)
     46 		schema, serr := os.read_entire_file_from_path(schema_path, context.temp_allocator)
     47 		if !testing.expectf(t, serr == nil, "cannot read %s: %v", schema_path, serr) {
     48 			continue
     49 		}
     50 		got, gerr := generate_source(schema, base_dir = entry.fullpath, allocator = context.temp_allocator)
     51 		if !testing.expectf(t, gerr == nil, "%s: generate_source failed: %v", entry.name, gerr) {
     52 			continue
     53 		}
     54 		testing.expectf(
     55 			t,
     56 			got == string(expected),
     57 			"%s: output mismatch\n--- expected ---\n%s\n--- got ---\n%s",
     58 			entry.name,
     59 			string(expected),
     60 			got,
     61 		)
     62 		tested += 1
     63 	}
     64 	testing.expectf(t, tested >= 12, "expected at least 12 golden cases, ran %d", tested)
     65 }
     66 
     67 // The openapi case has no golden file but must still generate successfully
     68 // and produce substantial output.
     69 @(test)
     70 generates_openapi :: proc(t: ^testing.T) {
     71 	root := repo_root(context.temp_allocator)
     72 	schema_path := join(
     73 		{root, "testdata", "cases", "openapi", "schema.json"},
     74 		context.temp_allocator,
     75 	)
     76 	schema, serr := os.read_entire_file_from_path(schema_path, context.temp_allocator)
     77 	if !testing.expectf(t, serr == nil, "cannot read %s: %v", schema_path, serr) {
     78 		return
     79 	}
     80 	got, gerr := generate_source(schema, allocator = context.temp_allocator)
     81 	if !testing.expectf(t, gerr == nil, "generate_source failed: %v", gerr) {
     82 		return
     83 	}
     84 	testing.expect(t, strings.contains(got, "Root :: struct"), "missing root struct")
     85 	testing.expect(t, strings.contains(got, "Info :: struct"), "missing Info struct")
     86 	testing.expect(t, len(got) > 2000, "suspiciously small output for a 1500-line schema")
     87 	// Auto-detected OpenAPI mode: if/then/else lowering and Or_Reference
     88 	// generic union should be present.
     89 	testing.expect(t, strings.contains(got, "Or_Reference :: union"), "missing Or_Reference generic")
     90 	testing.expect(t, strings.contains(got, "Or_Reference(Parameter)"), "missing Or_Reference(Parameter)")
     91 	testing.expect(t, !strings.contains(got, "Parameter_Or_Reference"), "should use Or_Reference, not dedicated decl")
     92 }
     93 
     94 // A schema with if/then/else but no "openapi" property: auto-detection
     95 // leaves it off, so the conditional is ignored (value -> json.Value).
     96 @(test)
     97 openapi_flag_forces_on :: proc(t: ^testing.T) {
     98 	schema := transmute([]u8)string(`{
     99 		"type": "object",
    100 		"properties": {
    101 			"value": {
    102 				"if": {"type": "object", "required": ["$ref"]},
    103 				"then": {"$ref": "#/$defs/a"},
    104 				"else": {"$ref": "#/$defs/b"}
    105 			}
    106 		},
    107 		"$defs": {
    108 			"a": {"type": "object", "properties": {"a": {"type": "string"}}},
    109 			"b": {"type": "object", "properties": {"b": {"type": "integer"}}}
    110 		}
    111 	}`)
    112 	// Without the flag, auto-detect sees no "openapi" property -> raw mode.
    113 	got, gerr := generate_source(schema, allocator = context.temp_allocator)
    114 	if !testing.expectf(t, gerr == nil, "generate_source failed: %v", gerr) {
    115 		return
    116 	}
    117 	testing.expect(t, strings.contains(got, "json.Value"), "raw mode should degrade if/then/else to json.Value")
    118 	testing.expect(t, !strings.contains(got, "union {A, B}"), "raw mode should not lower if/then/else")
    119 
    120 	// With -openapi forced on, if/then/else lowers to a union.
    121 	got_on, on_err := generate_source(schema, opts = {openapi = true}, allocator = context.temp_allocator)
    122 	if !testing.expectf(t, on_err == nil, "generate_source failed: %v", on_err) {
    123 		return
    124 	}
    125 	testing.expect(t, strings.contains(got_on, "union {A, B}"), "openapi mode should lower if/then/else to a union")
    126 }
    127 
    128 // -openapi:false forces raw mode even when the document has an "openapi"
    129 // property (auto-detect would otherwise enable idioms).
    130 @(test)
    131 openapi_flag_forces_off :: proc(t: ^testing.T) {
    132 	schema := transmute([]u8)string(`{
    133 		"type": "object",
    134 		"properties": {
    135 			"openapi": {"type": "string"},
    136 			"value": {
    137 				"if": {"type": "object", "required": ["$ref"]},
    138 				"then": {"$ref": "#/$defs/a"},
    139 				"else": {"$ref": "#/$defs/b"}
    140 			}
    141 		},
    142 		"$defs": {
    143 			"a": {"type": "object", "properties": {"a": {"type": "string"}}},
    144 			"b": {"type": "object", "properties": {"b": {"type": "integer"}}}
    145 		}
    146 	}`)
    147 	got, gerr := generate_source(schema, opts = {openapi = false}, allocator = context.temp_allocator)
    148 	if !testing.expectf(t, gerr == nil, "generate_source failed: %v", gerr) {
    149 		return
    150 	}
    151 	testing.expect(t, strings.contains(got, "json.Value"), "forced-off should degrade if/then/else to json.Value")
    152 	testing.expect(t, !strings.contains(got, "Or_Reference"), "forced-off should not emit Or_Reference")
    153 	testing.expect(t, !strings.contains(got, "union {A, B}"), "forced-off should not lower if/then/else")
    154 }
    155 
    156 @(test)
    157 reports_invalid_json :: proc(t: ^testing.T) {
    158 	_, err := generate_source(transmute([]u8)string(`{"type": `), allocator = context.temp_allocator)
    159 	_, is_parse_error := err.(Parse_Error)
    160 	testing.expectf(t, is_parse_error, "want Parse_Error, got %v", err)
    161 }
    162 
    163 @(test)
    164 reports_unresolvable_ref :: proc(t: ^testing.T) {
    165 	schema := transmute([]u8)string(`{"$ref": "#/$defs/missing"}`)
    166 	_, err := generate_source(schema, allocator = context.temp_allocator)
    167 	resolve_error, is_resolve_error := err.(Resolve_Error)
    168 	if !testing.expectf(t, is_resolve_error, "want Resolve_Error, got %v", err) {
    169 		return
    170 	}
    171 	testing.expect_value(t, resolve_error.ref, "#/$defs/missing")
    172 }
    173 
    174 @(test)
    175 reports_missing_ref_file :: proc(t: ^testing.T) {
    176 	schema := transmute([]u8)string(`{"$ref": "does_not_exist.json"}`)
    177 	_, err := generate_source(schema, allocator = context.temp_allocator)
    178 	_, is_io_error := err.(IO_Error)
    179 	testing.expectf(t, is_io_error, "want IO_Error, got %v", err)
    180 }
    181 
    182 // generate_source must not leak: the only allocation left for the caller is
    183 // the returned source string.
    184 @(test)
    185 no_leaks :: proc(t: ^testing.T) {
    186 	track: mem.Tracking_Allocator
    187 	mem.tracking_allocator_init(&track, context.allocator)
    188 	defer mem.tracking_allocator_destroy(&track)
    189 
    190 	schema := transmute([]u8)string(`{
    191 		"type": "object",
    192 		"properties": {"a": {"type": "string"}},
    193 		"required": ["a"]
    194 	}`)
    195 	source, err := generate_source(schema, allocator = mem.tracking_allocator(&track))
    196 	testing.expectf(t, err == nil, "generate_source failed: %v", err)
    197 	delete(source, mem.tracking_allocator(&track))
    198 
    199 	for _, entry in track.allocation_map {
    200 		testing.expectf(t, false, "leaked %d bytes at %v", entry.size, entry.location)
    201 	}
    202 	testing.expect_value(t, len(track.bad_free_array), 0)
    203 }
    204 
    205 @(test)
    206 field_names_convert :: proc(t: ^testing.T) {
    207 	cases := [][2]string {
    208 		{"jsonSchemaDialect", "json_schema_dialect"},
    209 		{"$self", "self"},
    210 		{"path-item", "path_item"},
    211 		{"map", "map_"},
    212 		{"200", "_200"},
    213 		{"", "field"},
    214 	}
    215 	for c in cases {
    216 		got := field_name(c[0], context.temp_allocator)
    217 		testing.expectf(t, got == c[1], "field_name(%q) = %q, want %q", c[0], got, c[1])
    218 	}
    219 }
    220 
    221 @(test)
    222 type_names_convert :: proc(t: ^testing.T) {
    223 	cases := [][2]string {
    224 		{"path-item", "Path_Item"},
    225 		{"securityScheme", "Security_Scheme"},
    226 		{"info", "Info"},
    227 		{"OAuthFlows", "OAuth_Flows"},
    228 	}
    229 	for c in cases {
    230 		got := type_name(c[0], context.temp_allocator)
    231 		testing.expectf(t, got == c[1], "type_name(%q) = %q, want %q", c[0], got, c[1])
    232 	}
    233 }