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 }