338 lines
14 KiB
C++
338 lines
14 KiB
C++
// Unit tests for mars::text -- hand-written minimal samples covering every
|
|
// quirk the shipped data exercises (see docs/mars-text.md). No game data.
|
|
#include <cstdio>
|
|
#include <string>
|
|
|
|
#include "mars/text/csv.h"
|
|
#include "mars/text/flat_kv.h"
|
|
#include "mars/text/manifest.h"
|
|
#include "mars/text/value.h"
|
|
|
|
using namespace mars::text;
|
|
|
|
static int g_failures = 0;
|
|
static int g_checks = 0;
|
|
|
|
#define CHECK(cond) \
|
|
do { \
|
|
++g_checks; \
|
|
if (!(cond)) { \
|
|
++g_failures; \
|
|
std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
|
} \
|
|
} while (0)
|
|
|
|
#define CHECK_EQ(a, b) CHECK((a) == (b))
|
|
|
|
// ---- value ---------------------------------------------------------------
|
|
|
|
static void test_classify() {
|
|
CHECK(classify("12") == ValueKind::Int);
|
|
CHECK(classify("-7") == ValueKind::Int);
|
|
CHECK(classify("+3") == ValueKind::Int);
|
|
CHECK(classify("0.05") == ValueKind::Float);
|
|
CHECK(classify(".5") == ValueKind::Float);
|
|
CHECK(classify("-.8") == ValueKind::Float);
|
|
CHECK(classify("1.") == ValueKind::Float);
|
|
CHECK(classify("7e+8") == ValueKind::Float);
|
|
CHECK(classify("1E5") == ValueKind::Float);
|
|
CHECK(classify("1e") == ValueKind::Bareword);
|
|
CHECK(classify(".") == ValueKind::Bareword);
|
|
CHECK(classify("-") == ValueKind::Bareword);
|
|
CHECK(classify("0x10") == ValueKind::Bareword);
|
|
CHECK(classify("inf") == ValueKind::Bareword);
|
|
CHECK(classify("nan") == ValueKind::Bareword);
|
|
CHECK(classify("1,5") == ValueKind::Bareword);
|
|
CHECK(classify("true") == ValueKind::Bool);
|
|
CHECK(classify("FALSE") == ValueKind::Bool);
|
|
CHECK(classify("True") == ValueKind::Bool);
|
|
CHECK(classify("yes") == ValueKind::Bareword);
|
|
CHECK(classify("") == ValueKind::Bareword);
|
|
|
|
CHECK_EQ(parse_int("42").value(), 42);
|
|
CHECK_EQ(parse_int("-42").value(), -42);
|
|
CHECK_EQ(parse_int("+9").value(), 9);
|
|
CHECK(!parse_int("4.0"));
|
|
CHECK(!parse_int("99999999999999999999")); // does not fit in 64 bits
|
|
CHECK_EQ(parse_int("-9223372036854775808").value(), INT64_MIN);
|
|
CHECK_EQ(parse_float("0.05").value(), 0.05);
|
|
CHECK_EQ(parse_float(".5").value(), 0.5);
|
|
CHECK_EQ(parse_float("-.8").value(), -0.8);
|
|
CHECK_EQ(parse_float("1.").value(), 1.0);
|
|
CHECK_EQ(parse_float("7e+8").value(), 7e8);
|
|
CHECK_EQ(parse_float("3").value(), 3.0); // int shape converts too
|
|
CHECK(!parse_float("inf"));
|
|
CHECK(!parse_float("1e"));
|
|
CHECK_EQ(parse_bool("TRUE").value(), true);
|
|
CHECK_EQ(parse_bool("false").value(), false);
|
|
CHECK(!parse_bool("0"));
|
|
|
|
Token quoted{"8", true};
|
|
CHECK(quoted.kind() == ValueKind::String);
|
|
CHECK(!quoted.as_int());
|
|
Token bare{"8", false};
|
|
CHECK(bare.kind() == ValueKind::Int);
|
|
CHECK_EQ(bare.as_int().value(), 8);
|
|
CHECK_EQ(bare.as_float().value(), 8.0);
|
|
}
|
|
|
|
static void test_color() {
|
|
auto c = parse_color("255\t177\t\t39"); // tabs inside the quotes, as in globals.txt
|
|
CHECK(c && c->components == 3 && c->r == 255 && c->g == 177 && c->b == 39 && c->a == 1);
|
|
auto c4 = parse_color(" 0 0.5 1 0.25 ");
|
|
CHECK(c4 && c4->components == 4 && c4->g == 0.5 && c4->a == 0.25);
|
|
CHECK(!parse_color("1 2"));
|
|
CHECK(!parse_color("1 2 3 4 5"));
|
|
CHECK(!parse_color("red green blue"));
|
|
CHECK(!parse_color(""));
|
|
}
|
|
|
|
// ---- flat kv -------------------------------------------------------------
|
|
|
|
static void test_strip_comment_and_tokens() {
|
|
CHECK_EQ(strip_comment("A 1 // c"), std::string_view("A 1 "));
|
|
CHECK_EQ(strip_comment("A \"x // y\" // c"), std::string_view("A \"x // y\" "));
|
|
CHECK_EQ(strip_comment("A \"unclosed // not a comment"), std::string_view("A \"unclosed // not a comment"));
|
|
CHECK_EQ(strip_comment("A / 1"), std::string_view("A / 1"));
|
|
CHECK_EQ(strip_comment("//"), std::string_view(""));
|
|
|
|
auto t = split_tokens(" a\t\"b c\" \"\" d\"e f\" g");
|
|
CHECK_EQ(t.size(), 6u);
|
|
CHECK(t[0].text == "a" && !t[0].quoted);
|
|
CHECK(t[1].text == "b c" && t[1].quoted);
|
|
CHECK(t[2].text == "" && t[2].quoted); // empty quoted token survives
|
|
CHECK(t[3].text == "d\"e" && !t[3].quoted); // quote mid-bareword is literal
|
|
CHECK(t[4].text == "f\"" && !t[4].quoted);
|
|
CHECK(t[5].text == "g" && !t[5].quoted);
|
|
auto u = split_tokens("\"open x");
|
|
CHECK_EQ(u.size(), 2u);
|
|
CHECK(u[0].text == "\"open" && !u[0].quoted); // unpaired quote: bareword
|
|
CHECK(u[1].text == "x");
|
|
CHECK(split_tokens(" ").empty());
|
|
}
|
|
|
|
static void test_flat_kv() {
|
|
const char* text =
|
|
"// header comment\r\n"
|
|
"\r\n"
|
|
"SLAVES_DEATH_RATE 0.05\r\n"
|
|
"Max_Ships\t\t12 // trailing comment\n"
|
|
"TITLE \"Sword // of the Stars\"\n"
|
|
"EMPTY \"\"\n"
|
|
"COLOR \"255\t177\t\t39\"\n"
|
|
"FLAG true\n"
|
|
"NAME_ONLY\n"
|
|
"LIST 1 2 three\n"
|
|
"path \"Data\\\\x.txt\"\n"
|
|
"SLAVES_DEATH_RATE 0.10\n"
|
|
"BADQ \"oops\n";
|
|
auto r = parse_flat_kv(text);
|
|
const FlatKV& kv = r.value;
|
|
CHECK_EQ(kv.size(), 11u);
|
|
|
|
// typed access, case-insensitive lookup, original spelling preserved
|
|
CHECK_EQ(kv.get_int("max_ships").value(), 12);
|
|
CHECK_EQ(kv.find("MAX_SHIPS")->key, std::string("Max_Ships"));
|
|
CHECK_EQ(kv.find("MAX_SHIPS")->line, 4);
|
|
CHECK(!kv.get_int("TITLE"));
|
|
CHECK_EQ(kv.get_string("TITLE").value(), std::string_view("Sword // of the Stars"));
|
|
CHECK(kv.find("TITLE")->values[0].quoted);
|
|
CHECK_EQ(kv.get_string("EMPTY").value(), std::string_view(""));
|
|
auto c = kv.get_color("COLOR");
|
|
CHECK(c && c->r == 255 && c->g == 177 && c->b == 39);
|
|
CHECK_EQ(kv.get_bool("flag").value(), true);
|
|
CHECK(kv.has("NAME_ONLY"));
|
|
CHECK(kv.find("NAME_ONLY")->values.empty());
|
|
CHECK(!kv.get_string("NAME_ONLY"));
|
|
CHECK_EQ(kv.find("LIST")->values.size(), 3u);
|
|
CHECK(kv.find("LIST")->values[2].kind() == ValueKind::Bareword);
|
|
CHECK_EQ(kv.get_int("LIST").value(), 1); // first token
|
|
CHECK_EQ(kv.get_string("path").value(), std::string_view("Data\\\\x.txt")); // no escape processing
|
|
CHECK(!kv.has("missing"));
|
|
CHECK(!kv.get_int("missing"));
|
|
|
|
// duplicates: last wins for lookup, both entries kept
|
|
CHECK_EQ(kv.get_float("slaves_death_rate").value(), 0.10);
|
|
auto dups = kv.duplicates();
|
|
CHECK_EQ(dups.size(), 1u);
|
|
CHECK(dups[0].first == "slaves_death_rate");
|
|
CHECK((dups[0].second == std::vector<int>{3, 12}));
|
|
|
|
// unbalanced quote: line still parsed, warning reported
|
|
CHECK_EQ(r.problems.size(), 1u);
|
|
CHECK(r.problems[0].kind == Problem::Kind::UnbalancedQuote && r.problems[0].line == 13);
|
|
CHECK_EQ(kv.get_string("BADQ").value(), std::string_view("\"oops"));
|
|
|
|
// key case only differs: a duplicate for us
|
|
auto r2 = parse_flat_kv("Foo 1\nFOO 2\n");
|
|
CHECK_EQ(r2.value.get_int("foo").value(), 2);
|
|
CHECK_EQ(r2.value.duplicates().size(), 1u);
|
|
|
|
// empty / comment-only input
|
|
CHECK_EQ(parse_flat_kv("").value.size(), 0u);
|
|
CHECK_EQ(parse_flat_kv("// nothing\n\n \n").value.size(), 0u);
|
|
// line numbers with mixed terminators
|
|
auto r3 = parse_flat_kv("A 1\rB 2\r\nC 3\nD 4");
|
|
CHECK_EQ(r3.value.find("D")->line, 4);
|
|
// high bytes pass through untouched
|
|
auto r4 = parse_flat_kv("K \"caf\xe9 \x93q\x94\"");
|
|
CHECK_EQ(r4.value.get_string("K").value(), std::string_view("caf\xe9 \x93q\x94"));
|
|
}
|
|
|
|
static void test_rows() {
|
|
const char* text =
|
|
"// size weapon-size class health \"model\"\r\n"
|
|
"\r\n"
|
|
"small\ttiny\tstandard 20 360 1.00 \"turret_pd.x\"\r\n"
|
|
"large small standard 800 120 1.00 \"turret with space.x\" // c\n"
|
|
"\tHiver\t\tBADGE_HIVER_GW100\t\ttrue\n";
|
|
auto r = parse_rows(text);
|
|
CHECK(r.ok());
|
|
CHECK_EQ(r.value.size(), 3u);
|
|
CHECK_EQ(r.value[0].line, 3);
|
|
CHECK_EQ(r.value[0].tokens.size(), 7u);
|
|
CHECK_EQ(r.value[0].tokens[3].as_int().value(), 20);
|
|
CHECK(r.value[0].tokens[5].kind() == ValueKind::Float);
|
|
CHECK(r.value[0].tokens[6].quoted && r.value[0].tokens[6].text == "turret_pd.x");
|
|
CHECK_EQ(r.value[1].tokens[6].text, std::string("turret with space.x"));
|
|
CHECK_EQ(r.value[2].tokens.size(), 3u);
|
|
CHECK_EQ(r.value[2].tokens[2].as_bool().value(), true);
|
|
}
|
|
|
|
// ---- manifest ------------------------------------------------------------
|
|
|
|
static void test_manifest() {
|
|
const char* text =
|
|
"// Only add to this list: always use new IDs.\r\n"
|
|
"\r\n"
|
|
"1 can_am.weapon\r\n"
|
|
"2\tDEWar.SHIPSECTION\r\n"
|
|
"// DELETED - 36\r\n"
|
|
"//deleted-58\n"
|
|
" // Deleted - 59 \n"
|
|
"3 x.weapon // trailing comment is not accepted\n"
|
|
"4 two words\n"
|
|
"not_an_id y.weapon\n"
|
|
"1 dup.weapon\n"
|
|
"7 z.weapon\n"
|
|
"// DELETED - 7\n";
|
|
auto r = parse_manifest(text);
|
|
const Manifest& m = r.value;
|
|
CHECK_EQ(m.entries().size(), 4u);
|
|
CHECK((m.deleted() == std::vector<int>{36, 58, 59, 7}));
|
|
CHECK_EQ(m.entries()[1].line, 4);
|
|
CHECK_EQ(m.name_of(2).value(), std::string_view("DEWar.SHIPSECTION"));
|
|
CHECK_EQ(m.id_of("dewar.shipsection").value(), 2); // case-folded lookup
|
|
CHECK_EQ(m.id_of("CAN_AM.WEAPON").value(), 1);
|
|
CHECK(!m.id_of("nope"));
|
|
CHECK(!m.name_of(99));
|
|
CHECK(m.is_deleted(36) && !m.is_deleted(1));
|
|
CHECK_EQ(m.name_of(1).value(), std::string_view("dup.weapon")); // last assignment wins
|
|
|
|
CHECK_EQ(r.problems.size(), 5u);
|
|
CHECK(r.problems[0].kind == Problem::Kind::Unrecognised && r.problems[0].line == 8);
|
|
CHECK(r.problems[1].kind == Problem::Kind::Unrecognised && r.problems[1].line == 9);
|
|
CHECK(r.problems[2].kind == Problem::Kind::Unrecognised && r.problems[2].line == 10);
|
|
CHECK(r.problems[3].kind == Problem::Kind::DuplicateId && r.problems[3].line == 11 && r.problems[3].id == 1);
|
|
CHECK(r.problems[4].kind == Problem::Kind::DeletedAndAssigned && r.problems[4].id == 7);
|
|
|
|
CHECK(parse_manifest("").ok());
|
|
CHECK(parse_manifest("").value.entries().empty());
|
|
}
|
|
|
|
// ---- csv -----------------------------------------------------------------
|
|
|
|
static void test_csv_records() {
|
|
// raw split: quoting, escapes, terminators, multi-line cells
|
|
auto r = split_csv_records("a,b\r\n\r\n\"x,y\",\"say \"\"hi\"\"\"\n\"multi\r\nline\",end\r\nlast,");
|
|
CHECK(r.ok());
|
|
const auto& rows = r.value;
|
|
CHECK_EQ(rows.size(), 5u);
|
|
CHECK((rows[0].cells == std::vector<std::string>{"a", "b"}));
|
|
CHECK(rows[1].cells.empty());
|
|
CHECK((rows[2].cells == std::vector<std::string>{"x,y", "say \"hi\""}));
|
|
CHECK((rows[3].cells == std::vector<std::string>{"multi\r\nline", "end"}));
|
|
CHECK_EQ(rows[3].line, 4);
|
|
CHECK((rows[4].cells == std::vector<std::string>{"last", ""})); // trailing comma, no final newline
|
|
CHECK_EQ(rows[4].line, 6);
|
|
|
|
// quote is literal unless it is the first byte of the cell
|
|
auto q = split_csv_records("KEY\",text\n \"Effects/x.effect\",1\n\"tail\"junk,2\n");
|
|
CHECK((q.value[0].cells == std::vector<std::string>{"KEY\"", "text"}));
|
|
CHECK((q.value[1].cells == std::vector<std::string>{" \"Effects/x.effect\"", "1"}));
|
|
CHECK((q.value[2].cells == std::vector<std::string>{"tailjunk", "2"})); // lenient, like the reference
|
|
|
|
// lone CR terminators, and CR LF sequences counted once
|
|
auto cr = split_csv_records("a\rb\r\nc");
|
|
CHECK_EQ(cr.value.size(), 3u);
|
|
CHECK_EQ(cr.value[2].line, 3);
|
|
|
|
// unterminated quoted cell: content kept, problem reported
|
|
auto u = split_csv_records("a,\"never closed\nmore");
|
|
CHECK_EQ(u.value.size(), 1u);
|
|
CHECK((u.value[0].cells == std::vector<std::string>{"a", "never closed\nmore"}));
|
|
CHECK_EQ(u.problems.size(), 1u);
|
|
CHECK(u.problems[0].kind == Problem::Kind::UnterminatedQuotedCell);
|
|
|
|
CHECK(split_csv_records("").value.empty());
|
|
CHECK_EQ(split_csv_records("\n").value.size(), 1u);
|
|
}
|
|
|
|
static void test_csv() {
|
|
const char* text =
|
|
"# <tech>,<human-pri>,<hiver-pri>\r\n"
|
|
"#\r\n"
|
|
"# tech: tech label\r\n"
|
|
"\r\n"
|
|
" \r\n"
|
|
",,\r\n"
|
|
"// slash comment, with comma\r\n"
|
|
" WEP_Laser , 10, 20 \r\n"
|
|
" # indented comment,1,2\r\n"
|
|
"WEP_Gauss,\"1,5\",\"a \"\"b\"\"\"\r\n"
|
|
"\"multi\r\nline\",x,y\r\n";
|
|
auto r = parse_csv(text);
|
|
CHECK(r.ok());
|
|
const Csv& csv = r.value;
|
|
CHECK(csv.header.has_value());
|
|
CHECK((csv.header.value() == std::vector<std::string>{"tech", "human-pri", "hiver-pri"}));
|
|
CHECK_EQ(csv.size(), 3u);
|
|
CHECK((csv.rows[0].cells == std::vector<std::string>{"WEP_Laser", "10", "20"})); // stripped
|
|
CHECK_EQ(csv.rows[0].line, 8);
|
|
CHECK((csv.rows[1].cells == std::vector<std::string>{"WEP_Gauss", "1,5", "a \"b\""}));
|
|
CHECK((csv.rows[2].cells == std::vector<std::string>{"multi\r\nline", "x", "y"}));
|
|
CHECK_EQ(csv.rows[2].line, 11);
|
|
|
|
// header variants
|
|
auto h1 = parse_csv("\"# species\",\"event\",\"message\"\n,,\n\"Human\",\"E\",\"M\"\n");
|
|
CHECK((h1.value.header.value() == std::vector<std::string>{"species", "event", "message"}));
|
|
CHECK_EQ(h1.value.size(), 1u);
|
|
auto h2 = parse_csv("#Key+A955,String,Size,Notes\nK,v,,\n");
|
|
CHECK((h2.value.header.value() == std::vector<std::string>{"Key+A955", "String", "Size", "Notes"}));
|
|
CHECK((h2.value.rows[0].cells == std::vector<std::string>{"K", "v", "", ""}));
|
|
auto h3 = parse_csv("# just a comment\nA,B\n# <late>,<schema>\n");
|
|
CHECK(!h3.value.header.has_value()); // single-cell '#' rows are not schemas; data ends the search
|
|
CHECK_EQ(h3.value.size(), 1u);
|
|
auto h4 = parse_csv("A\nB\n");
|
|
CHECK(!h4.value.header.has_value());
|
|
CHECK_EQ(h4.value.size(), 2u);
|
|
CHECK_EQ(parse_csv("").value.size(), 0u);
|
|
CHECK_EQ(parse_csv("#a,b\n#c\n").value.size(), 0u); // comment-only template
|
|
|
|
// bytes >= 0x80 pass through
|
|
auto hb = parse_csv("K,caf\xe9 \x85\n");
|
|
CHECK_EQ(hb.value.rows[0].cells[1], std::string("caf\xe9 \x85"));
|
|
}
|
|
|
|
int main() {
|
|
test_classify();
|
|
test_color();
|
|
test_strip_comment_and_tokens();
|
|
test_flat_kv();
|
|
test_rows();
|
|
test_manifest();
|
|
test_csv_records();
|
|
test_csv();
|
|
std::printf("mars_text unit tests: %d checks, %d failures\n", g_checks, g_failures);
|
|
return g_failures == 0 ? 0 : 1;
|
|
}
|