sots-engine/tests/mars_parse/test_blocks.cpp

261 lines
10 KiB
C++

// Unit tests for the brace-block reader. Every sample is hand-written.
#include <string>
#include "canon.h"
#include "mars/parse/blocks.h"
#include "test_main.h"
using namespace mars::parse;
namespace {
Document must_parse(std::string_view text, Options o = {}) {
auto r = parse_blocks(text, o);
if (!r.ok()) {
std::printf(" unexpected parse error line %d: %s\n", r.error().line, r.error().message.c_str());
CHECK(r.ok());
return Document{};
}
return std::move(r).value();
}
std::string canon_of(std::string_view text, Options o = {}) {
return canon::blocks_json(must_parse(text, o).root);
}
} // namespace
TEST(basic_block_pairs_and_nesting) {
Document d = must_parse(
"weapon\n{\n\tname @WEAPON_X\n\tcost 50\n\tbank { mount { node N1 } }\n}\n");
CHECK_EQ(d.root.entries.size(), std::size_t{1});
const Node* w = d.root.first_block("weapon");
CHECK(w != nullptr);
CHECK_EQ(w->name, "weapon");
CHECK_EQ(*w->first_value("name"), "@WEAPON_X"); // @TOKEN preserved verbatim
CHECK_EQ(*w->first_value("cost"), "50"); // numbers kept as text
const Node* mount = w->first_block("bank")->first_block("mount");
CHECK(mount != nullptr);
CHECK_EQ(*mount->first_value("node"), "N1");
CHECK(d.warnings.empty());
CHECK_EQ(canon_of("weapon { name @X cost 50 bank { mount { node N1 } } }"),
R"({"weapon":{"bank":{"mount":{"node":"N1"}},"cost":50,"name":"@X"}})");
}
TEST(quoted_values_keep_spaces_backslashes_and_slashes) {
Document d = must_parse(
"a \"hello world\"\n"
"b \"C:\\path\\to\\file.X\"\n"
"c \"http://x//y\" // real comment\n"
"d \"\"\n");
CHECK_EQ(*d.root.first_value("a"), "hello world");
CHECK_EQ(*d.root.first_value("b"), "C:\\path\\to\\file.X");
CHECK_EQ(*d.root.first_value("c"), "http://x//y");
CHECK_EQ(*d.root.first_value("d"), "");
CHECK(d.root.first("a")->quoted);
CHECK(d.root.first("d")->quoted);
CHECK_EQ(d.root.entries.size(), std::size_t{4});
}
TEST(comments_are_stripped) {
std::string s = canon_of("// leading\nx 1 // trailing\n//y 2\nz 3\n");
CHECK_EQ(s, R"({"x":1,"z":3})");
// '//' only starts a comment at a token boundary: glued to a bareword it is
// part of the word (the reference reader behaves this way; the shipped data
// never relies on either reading).
CHECK_EQ(canon_of("z 3//glued\n"), R"({"z":"3//glued"})");
CHECK_EQ(canon_of("z 3 //not glued\n"), R"({"z":3})");
}
TEST(repeated_keys_stay_in_order) {
Document d = must_parse("t { requires A requires B other 1 requires C }");
auto all = d.root.first_block("t")->all("requires");
CHECK_EQ(all.size(), std::size_t{3});
CHECK_EQ(all[0]->value, "A");
CHECK_EQ(all[1]->value, "B");
CHECK_EQ(all[2]->value, "C");
CHECK_EQ(*d.root.first_block("t")->first_value("requires"), "A");
CHECK_EQ(canon_of("t { requires A requires B other 1 requires C }"),
R"({"t":{"other":1,"requires":["A","B","C"]}})");
}
TEST(keys_are_case_insensitive_but_case_is_kept) {
Document d = must_parse("tech { Requires X requires Y Badge b }");
const Node* t = d.root.first_block("TECH");
CHECK(t != nullptr);
CHECK_EQ(t->all("REQUIRES").size(), std::size_t{2});
CHECK_EQ(t->first("badge")->key, "Badge");
CHECK_EQ(canon_of("tech { Requires X requires Y }"), R"({"tech":{"requires":["X","Y"]}})");
}
TEST(bare_quoted_items_and_trailing_bareword) {
Document d = must_parse("names { \"Sol\" \"Alpha Centauri\" }\nflags { on off }");
auto items = d.root.first_block("names")->items();
CHECK_EQ(items.size(), std::size_t{2});
CHECK_EQ(std::string(items[1]), "Alpha Centauri");
// "on off": 'on' takes 'off' as its value; nothing is left over.
const Node* f = d.root.first_block("flags");
CHECK_EQ(*f->first_value("on"), "off");
// A lone word right before '}' is an item, not a key.
Document e = must_parse("flags { a 1 solo }");
CHECK_EQ(e.root.first_block("flags")->items().size(), std::size_t{1});
CHECK_EQ(std::string(e.root.first_block("flags")->items()[0]), "solo");
CHECK_EQ(canon_of("flags { a 1 solo }"), R"({"flags":{"_items":"solo","a":1}})");
CHECK_EQ(canon_of("n { \"x\" \"y\" }"), R"({"n":{"_items":["x","y"]}})");
// lone bareword at EOF, top level
CHECK_EQ(canon_of("a 1 trailing"), R"({"_items":"trailing","a":1})");
}
TEST(option_scalar_and_block_share_a_key) {
Document d = must_parse(
"shipsection { option DRV_A option { option T1 option T2 } option DRV_B }");
const Node* s = d.root.first_block("shipsection");
auto all = s->all("option");
CHECK_EQ(all.size(), std::size_t{3});
CHECK(all[0]->is_pair());
CHECK(all[1]->is_block());
CHECK(all[2]->is_pair());
CHECK_EQ(s->values("option").size(), std::size_t{2});
CHECK_EQ(s->blocks("option").size(), std::size_t{1});
CHECK_EQ(s->blocks("option")[0]->values("option").size(), std::size_t{2});
CHECK_EQ(canon_of("s { option A option { option T1 option T2 } option B }"),
R"({"s":{"option":["A",{"option":["T1","T2"]},"B"]}})");
}
TEST(tokenizer_is_not_line_based) {
// block opens on the same line as a preceding pair; name glued to brace;
// several entries per line; entries split across lines
std::string s = canon_of(
"bank { turretsize small mount { node N }\n}\n"
"weapon{ name\n\nX }\n"
"a\n{\nb\n1\n}");
CHECK_EQ(s, R"({"a":{"b":1},"bank":{"mount":{"node":"N"},"turretsize":"small"},"weapon":{"name":"X"}})");
}
TEST(braces_split_barewords) {
CHECK_EQ(canon_of("a{b 1}c 2"), R"({"a":{"b":1},"c":2})");
}
TEST(quotes_split_barewords) {
// abc"def" -> bareword abc, then quoted def (a pair abc="def")
CHECK_EQ(canon_of("abc\"def\""), R"({"abc":"def"})");
}
TEST(eof_closes_open_blocks_lenient) {
auto r = parse_blocks("shipsection {\n model M\n bank { mount { node N }\n");
CHECK(r.ok());
CHECK_EQ(r->warnings.size(), std::size_t{2}); // bank and shipsection both unclosed
CHECK_EQ(r->warnings[0].line, 4);
const Node* s = r->root.first_block("shipsection");
CHECK(s != nullptr);
CHECK_EQ(*s->first_value("model"), "M");
CHECK_EQ(*s->first_block("bank")->first_block("mount")->first_value("node"), "N");
}
TEST(eof_inside_block_is_an_error_when_strict) {
Options strict;
strict.strict = true;
auto r = parse_blocks("shipsection {\n model M\n", strict);
CHECK(!r.ok());
CHECK_EQ(r.error().line, 3);
}
TEST(stray_top_level_close_is_ignored_lenient) {
auto r = parse_blocks("a { x 1 }\n}\nb { y 2 }\n");
CHECK(r.ok());
CHECK_EQ(r->warnings.size(), std::size_t{1});
CHECK_EQ(r->warnings[0].line, 2);
CHECK(r->root.first_block("a") != nullptr);
CHECK(r->root.first_block("b") != nullptr); // parsing continues after the stray brace
CHECK_EQ(canon::blocks_json(r->root), R"({"a":{"x":1},"b":{"y":2}})");
}
TEST(stray_top_level_close_is_an_error_when_strict) {
Options strict;
strict.strict = true;
auto r = parse_blocks("a { x 1 }\n}\n", strict);
CHECK(!r.ok());
CHECK_EQ(r.error().line, 2);
}
TEST(unterminated_string_is_always_an_error) {
auto r = parse_blocks("a \"never closed\n b 1\n");
CHECK(!r.ok());
CHECK_EQ(r.error().line, 1);
Options strict;
strict.strict = true;
CHECK(!parse_blocks("a \"never closed", strict).ok());
}
TEST(open_brace_without_name_is_always_an_error) {
auto r = parse_blocks("a 1\n{ b 2 }");
CHECK(!r.ok());
CHECK_EQ(r.error().line, 2);
// ... also when it follows a complete pair on the same line
CHECK(!parse_blocks("a 1 { b 2 }").ok());
}
TEST(crlf_and_line_numbers) {
Document d = must_parse("a 1\r\nb 2\r\n\r\nc { d 3 }\r\n");
CHECK_EQ(d.root.first("a")->line, 1);
CHECK_EQ(d.root.first("b")->line, 2);
CHECK_EQ(d.root.first("c")->line, 4);
CHECK_EQ(d.root.first_block("c")->first("d")->line, 4);
CHECK_EQ(*d.root.first_value("b"), "2"); // no '\r' leaks into values
}
TEST(multiline_quoted_string_counts_lines) {
Document d = must_parse("a \"line one\nline two\"\nb 2\n");
CHECK_EQ(*d.root.first_value("a"), "line one\nline two");
CHECK_EQ(d.root.first("b")->line, 3);
}
TEST(empty_and_comment_only_input) {
Document d = must_parse("");
CHECK(d.root.entries.empty());
CHECK(d.warnings.empty());
Document e = must_parse(" // nothing here\n\n");
CHECK(e.root.entries.empty());
CHECK_EQ(canon_of(""), "{}");
}
TEST(top_level_pairs_and_blocks_mix) {
// scenario .txt shape: top-level pairs plus repeated player{} blocks
std::string s = canon_of("name @S\nnumplayers \"8\"\nplayer { recommended 1 }\nplayer { recommended 2 }");
CHECK_EQ(s, R"({"name":"@S","numplayers":"8","player":[{"recommended":1},{"recommended":2}]})");
}
TEST(numbers_are_typed_only_in_canonical_form_and_only_when_bare) {
// The Node keeps raw text; the canonical form types barewords like the oracle
std::string s = canon_of("i 7 f .5 e 7e+8 t TRUE q \"8\" n -.8 d 5.");
CHECK_EQ(s, "{\"d\":\"\\u00015\",\"e\":\"\\u0001700000000\",\"f\":\"\\u00010.5\","
"\"i\":7,\"n\":\"\\u0001-0.80000000000000004\",\"q\":\"8\",\"t\":true}");
}
TEST(high_bytes_pass_through) {
std::string src = "a \"caf\xe9\"\nb na\xefve\n";
Document d = must_parse(src);
CHECK_EQ(*d.root.first_value("a"), "caf\xe9");
CHECK_EQ(*d.root.first_value("b"), "na\xefve");
CHECK_EQ(canon::blocks_json(d.root), R"({"a":"caf\u00e9","b":"na\u00efve"})");
}
TEST(deep_nesting_returns_to_the_right_parent) {
Document d = must_parse("a { b { c { d 1 } e 2 } f 3 } g 4");
const Node* a = d.root.first_block("a");
CHECK_EQ(*a->first_block("b")->first_block("c")->first_value("d"), "1");
CHECK_EQ(*a->first_block("b")->first_value("e"), "2");
CHECK_EQ(*a->first_value("f"), "3");
CHECK_EQ(*d.root.first_value("g"), "4");
CHECK_EQ(a->first_block("b")->line, 1);
}
TEST(lookup_misses_return_null_or_empty) {
Document d = must_parse("a 1");
CHECK(d.root.first("zzz") == nullptr);
CHECK(d.root.first_value("zzz") == nullptr);
CHECK(d.root.first_block("a") == nullptr); // a is a pair, not a block
CHECK(d.root.all("zzz").empty());
CHECK(!d.root.has("zzz"));
CHECK(d.root.has("A"));
}