spiegel-keyman/core/tests/unit/ldml/ldml.cpp
Marc Durdin d71cb56ca7 fix(core): rename assert() to test_assert() in unit tests
Had a real yak shave this morning with disabling assertions in release
builds in our C/C++ code. It turns out that our unit tests use
`assert()` which we intended to use from `test_assert.h`, but in some
cases `cassert` or `assert.h` had been #included after `test_assert.h`,
overriding our special `assert()` macro. The chain of includes is
somewhat hard to puzzle out -- it's often buried several levels deep.
This meant that a release build would drop all test assertions, meaning
most tests passed, unsurprisingly, as there were no assertions left to
fail ... but some tests failed with crashes because we optimized out
important lines such as `assert(some_important_function())`.

I was quite unhappy with this fragility, so I have opted to rename
`assert()` to `test_assert()` in all of our home-grown C/C++ unit tests,
which further highlighted unit tests which were only using the C/C++
`assert()` and not ours, so then had to figure out which unit test
executables needed to have `test_assert` added, and then ... then ...
discovered a bug in `test_color.h`, where we were #including
`io.h`/`unistd.h` inside a `namespace console_color {}` block, which
just happened to be the first ref to those beautiful headers, and thus
(because `#pragma once`) meant that useful little functions like
`access()` were no longer accessible to us in the global namespace.

I have also audited Every Single Call to `assert()` to verify that we do
not do Important Work inside the parentheses, and, apart from those
offending unit tests, now resolved with `test_assert()`, it looks like
all is good.

I would like to present one very well-shaved yak in this commit.

Fixes: #12619
2024-11-28 09:46:01 +07:00

570 lines
20 KiB
C++

/*
Copyright: © SIL International.
Description: Tests for LDML keyboard integration
Create Date: 5 Aug 2022
Authors: Marc Durdin (MD)
Note: Exit codes will be 100*LINE + ERROR CODE, e.g. 25005 is code 5 on line 250
*/
#include <algorithm>
#include <cctype>
#include <fstream>
#include <iostream>
#include <iomanip>
#include <iterator>
#include <list>
#include <sstream>
#include <string>
#include <type_traits>
#include "path.hpp"
#include "state.hpp"
#include "utfcodec.hpp"
#include <test_assert.h>
#include <test_color.h>
#include "keyman_core.h"
#include "util_normalize.hpp"
#include <kmx/kmx_xstring.h> // for surrogate pair macros
#include "ldml_test_source.hpp"
#include "debuglog.h"
#include "ldml/ldml_markers.hpp"
#include "processor.hpp"
namespace {
bool g_beep_found = false;
km_core_option_item test_env_opts[] =
{
KM_CORE_OPTIONS_END
};
std::string
string_to_hex(const std::u16string &input) {
std::ostringstream result;
result << std::setfill('0') << std::hex << std::uppercase;
for (size_t i = 0; i < input.length(); i++) {
unsigned int ch = input[i];
if (i < input.length() - 1 && Uni_IsSurrogate1(input[i]) && Uni_IsSurrogate2(input[i + 1])) {
ch = Uni_SurrogateToUTF32(input[i], input[i + 1]);
i++;
}
result << "U+" << std::setw(4) << ch << " ";
}
return result.str();
}
void
copy_context_items_to_vector(km_core_context_item *citems, std::vector<km_core_context_item> &vector) {
vector.clear();
for(km_core_context_item *ci = citems; ci->type != KM_CORE_CT_END; ci++) {
vector.emplace_back(*ci);
}
}
void
apply_action(
km_core_state *test_state,
km_core_action_item const &act,
std::u16string &text_store,
std::vector<km_core_context_item> &context,
km::tests::LdmlTestSource &test_source,
std::vector<km_core_context_item> &test_context) {
switch (act.type) {
case KM_CORE_IT_END:
test_assert(false);
break;
case KM_CORE_IT_ALERT:
g_beep_found = true;
// std::cout << "beep" << std::endl;
break;
case KM_CORE_IT_CHAR:
context.push_back(km_core_context_item{
KM_CORE_CT_CHAR,
{
0,
},
{act.character}});
{
km::core::kmx::char16_single buf;
const int len = km::core::kmx::Utf32CharToUtf16(act.character, buf);
for(int i=0; i<len; i++) {
text_store.push_back(buf.ch[i]);
}
}
// std::cout << "char(" << act.character << ") size=" << cp->size() << std::endl;
break;
case KM_CORE_IT_MARKER:
// std::cout << "deadkey(" << act.marker << ")" << std::endl;
context.push_back(km_core_context_item{
KM_CORE_CT_MARKER,
{
0,
},
{(uint32_t)act.marker}});
break;
case KM_CORE_IT_BACK:
{
// single char removed in context
km_core_usv ch = 0;
bool matched_text = false;
// assume the backspace came from set_action() and there's no further info.
test_assert(act.backspace.expected_type == KM_CORE_BT_CHAR);
test_assert(act.backspace.expected_value == 0);
// It is valid for a backspace to be received with an empty text store
// as the user can press backspace with no text in the store and Keyman
// will pass that back to the client, as the client may do additional
// processing at start of a text store, e.g. delete from a previous cell
// in a table. Or, if Keyman has a cached context, then there may be
// additional text in the text store that Keyman can't see.
// If there's anything in the text store, pop it off. Two if a pair.
if (text_store.length() > 0) {
test_assert(!context.empty() && !text_store.empty());
const auto ch1 = text_store.back();
text_store.pop_back();
if (text_store.length() > 0 && Uni_IsSurrogate2(ch1)) {
const auto ch2 = text_store.back();
if (Uni_IsSurrogate1(ch2)) {
// We'll only pop the next character off it is actually a
// surrogate pair
ch = Uni_SurrogateToUTF32(ch2, ch1); // reverse order
text_store.pop_back();
} else {
ch = 0xFFFF; // unpaired
}
} else {
ch = ch1; // single char
}
} else {
matched_text = true; // no text to match as context is empty.
}
// now, we need to simulate what ldml_processor::emit_backspace() is going to do.
auto end = context.rbegin();
while (end != context.rend()) {
if (end->type == KM_CORE_CT_CHAR) {
test_assert(!matched_text);
test_assert_equal(end->character, ch); // expect popped char to be same as what's in context
matched_text = true;
context.pop_back();
break; // exit on first real char
}
test_assert(end->type != KM_CORE_CT_END); // inappropriate here.
context.pop_back();
}
test_assert(matched_text);
}
break;
case KM_CORE_IT_PERSIST_OPT:
break;
case KM_CORE_IT_INVALIDATE_CONTEXT:
{
std::cout << "action: context invalidated (markers cleared)" << std::endl;
// TODO-LDML: We need the context for tests. So we will simulate recreating
// the context from the context string.
km_core_context_item* new_context_items = nullptr;
// We replace the cached context with the current application context
km_core_status status = context_items_from_utf16(text_store.c_str(), &new_context_items);
test_assert(status == KM_CORE_STATUS_OK);
copy_context_items_to_vector(new_context_items, context);
// also update the test context
copy_context_items_to_vector(new_context_items, test_context);
// TODO-LDML: now we need to SET the core context!
status = km_core_context_set(km_core_state_context(test_state), new_context_items);
km_core_context_items_dispose(new_context_items);
}
break;
case KM_CORE_IT_EMIT_KEYSTROKE:
std::cout << "action: emit keystroke" << std::endl;
// TODO-LDML: For now, this is a no-op. We could handle enter, etc.
break;
case KM_CORE_IT_CAPSLOCK:
std::cout << "action: capsLock " << act.capsLock << std::endl;
test_source.set_caps_lock_on(act.capsLock);
break;
default:
test_assert(false); // NOT SUPPORTED
break;
}
}
/**
* verify the current context
*/
void
verify_context(std::u16string &text_store, km_core_state *&test_state, std::vector<km_core_context_item> &test_context) {
// Compare context and text store at each step - should be identical
size_t n = 0;
km_core_context_item *citems = nullptr;
try_status(km_core_context_get(km_core_state_context(test_state), &citems));
try_status(context_items_to_utf16(citems, nullptr, &n));
km_core_cu *buf = new km_core_cu[n];
try_status(context_items_to_utf16(citems, buf, &n));
std::cout << "context (raw): "; // output including markers (which aren't in 'buf' here)
for (auto ci = citems; ci->type != KM_CORE_CT_END; ci++) {
switch (ci->type) {
case KM_CORE_CT_CHAR:
std::cout << "U+" << std::setw(4) << std::hex << ci->character << std::dec << " ";
break;
case KM_CORE_CT_MARKER:
std::cout << "\\m{" << ci->character << "} ";
break;
default:
std::cout << "type#" << ci->type << " ";
}
}
std::cout << std::endl;
std::cout << "context : " << string_to_hex(buf) << " [" << buf << "]" << std::endl;
std::cout << "testcontext ";
std::cout.fill('0');
for (auto i = test_context.begin(); i < test_context.end(); i++) {
switch (i->type) {
case KM_CORE_CT_CHAR:
std::cout << "U+" << std::setw(4) << std::hex << i->character << std::dec << " ";
break;
case KM_CORE_CT_MARKER:
std::cout << "\\m{" << i->character << "} ";
break;
default:
std::cout << "type#" << i->type << " ";
}
}
std::cout << std::endl;
// Verify that both our local test_context and the core's test_state.context have
// not diverged
auto ci = citems;
for (auto test_ci = test_context.begin();; ci++, test_ci++) {
// skip over markers, they won't be in test_context
while (ci->type == KM_CORE_CT_MARKER) {
ci++;
}
// exit if BOTH are at end.
if (ci->type == KM_CORE_CT_END && test_ci == test_context.end()) {
break; // success
}
// fail if only ONE is at end
test_assert(ci->type != KM_CORE_CT_END && test_ci != test_context.end());
// fail if type and marker don't match.
test_assert(test_ci->type == ci->type && test_ci->marker == ci->marker);
}
km_core_context_items_dispose(citems);
if (text_store != buf) {
std::cerr << "text store has diverged from buf" << std::endl;
std::cerr << "text store: " << string_to_hex(text_store) << " [" << text_store << "]" << std::endl;
test_assert(false);
}
delete[] buf;
}
int
run_test(const km::core::path &source, const km::core::path &compiled, km::tests::LdmlTestSource& test_source) {
km_core_keyboard * test_kb = nullptr;
km_core_state * test_state = nullptr;
const km_core_status expect_load_status = test_source.get_expected_load_status();
test_assert_equal(km_core_keyboard_load(compiled.c_str(), &test_kb), expect_load_status);
if (expect_load_status != KM_CORE_STATUS_OK) {
std::cout << "Keyboard was expected to be invalid, so exiting " << std::endl;
return 0;
}
// setup normalization status
const bool normalization_disabled = !test_kb->supports_normalization();
test_source.set_normalization_disabled(normalization_disabled);
// Setup state, environment
try_status(km_core_state_create(test_kb, test_env_opts, &test_state));
std::vector<km_core_context_item> test_context;
km_core_context_item *citems = nullptr;
// setup test_context
try_status(context_items_from_utf16(test_source.get_context().c_str(), &citems));
try_status(km_core_context_set(km_core_state_context(test_state), citems));
// Make a copy of the setup context for the test
copy_context_items_to_vector(citems, test_context);
km_core_context_items_dispose(citems);
// Setup baseline text store
std::u16string text_store = test_source.get_context();
km::tests::ldml_action action;
// verify at beginning
verify_context(text_store, test_state, test_context);
int errorLine = 0; // nonzero if err.
// Run through actions, applying output for each event
do {
test_source.next_action(action);
switch (action.type) {
case km::tests::LDML_ACTION_DONE:
// We'll get a printout when this loop exits.
break;
case km::tests::LDML_ACTION_KEY_EVENT: {
auto &p = action.k;
std::cout << "- key action: " << km::core::kmx::Debug_VirtualKey(p.vk) << "/modifier "
<< km::core::kmx::Debug_ModifierName(p.modifier_state) << " 0x" << p.modifier_state << std::dec << std::endl;
// Because a normal system tracks caps lock state itself,
// we mimic that in the tests. We assume caps lock state is
// updated on key_down before the processor receives the
// event.
if (p.vk == KM_CORE_VKEY_CAPS) {
test_source.toggle_caps_lock_state();
}
for (auto key_down = 1; key_down >= 0; key_down--) {
// expected error only applies to key down
try_status(km_core_process_event(
test_state, p.vk, p.modifier_state | test_source.caps_lock_state(), key_down,
KM_CORE_EVENT_FLAG_DEFAULT)); // TODO-LDML: for now. Should send touch and hardware events.
if (state_should_invalidate_context(test_state, p.vk, p.modifier_state | test_source.caps_lock_state(), key_down,
KM_CORE_EVENT_FLAG_DEFAULT)) {
test_context.clear();
text_store.clear();
}
for (auto act = km_core_state_action_items(test_state, nullptr); act->type != KM_CORE_IT_END; act++) {
apply_action(test_state, *act, text_store, test_context, test_source, test_context);
}
}
verify_context(text_store, test_state, test_context);
} break;
case km::tests::LDML_ACTION_EMIT_STRING: {
std::cout << "- string emit action: " << action.string << std::endl;
std::cerr << "TODO-LDML: note, LDML_ACTION_EMIT_STRING is NOT going through keyboard, transforms etc." << std::endl;
text_store.append(action.string); // TODO-LDML: not going through keyboard
// Now, update context?
km_core_context_item *nitems = nullptr;
try_status(context_items_from_utf16(action.string.c_str(), &nitems));
try_status(context_append(km_core_state_context(test_state), nitems));
// update the test_context also.
for (km_core_context_item *ci = nitems; ci->type != KM_CORE_CT_END; ci++) {
test_context.emplace_back(*ci);
}
km_core_context_items_dispose(nitems);
verify_context(text_store, test_state, test_context);
} break;
case km::tests::LDML_ACTION_CHECK_EXPECTED: {
if (!normalization_disabled) {
test_assert(km::core::util::normalize_nfd(action.string)); // TODO-LDML: should be NFC
}
std::cout << "- check expected" << std::endl;
std::cout << "expected : " << string_to_hex(action.string) << " [" << action.string << "]" << std::endl;
std::cout << "text store: " << string_to_hex(text_store) << " [" << text_store << "]" << std::endl;
// Compare internal context with expected result
if (text_store != action.string) {
errorLine = __LINE__;
}
} break;
case km::tests::LDML_ACTION_FAIL: {
// test requested failure
std::wcout << console_color::fg(console_color::BRIGHT_RED) << "- FAIL: " << action.string << console_color::reset()
<< std::endl;
errorLine = __LINE__;
} break;
case km::tests::LDML_ACTION_SKIP: {
// test requested skip
std::wcout << console_color::fg(console_color::YELLOW) << "- SKIP: " << action.string << console_color::reset()
<< std::endl;
} break;
default:
std::cerr << " Err: unhandled action type " << action.type << std::endl;
errorLine = __LINE__;
}
} while (!action.done() && errorLine == 0);
if (errorLine != 0) {
// re-verify at end (if there wasn't already a failure)
verify_context(text_store, test_state, test_context);
}
// cleanup
km_core_state_dispose(test_state);
km_core_keyboard_dispose(test_kb);
if (g_beep_found != test_source.get_expected_beep()) {
// Test if the beep action was as expected.
// TODO-LDML: possible in LDML?
std::wcout << console_color::fg(console_color::BRIGHT_RED) << "- FAIL - did not get expected beep" << console_color::reset() << std::endl;
return __LINE__;
}
return errorLine;
}
/**
* Run all tests for this keyboard
*/
int run_all_tests(const km::core::path &source, const km::core::path &compiled, const std::string &filter) {
std::wcout << console_color::fg(console_color::BLUE) << "source file = " << source << std::endl
<< "compiled file = " << compiled << console_color::reset() << std::endl;
if(!filter.empty()) {
std::wcout << "Running only tests matching (substring search): " << filter.c_str() << std::endl;
}
km::tests::LdmlEmbeddedTestSource embedded_test_source;
std::vector<std::string> failures; // track failures for summary
int embedded_result = embedded_test_source.load_source(source);
if (!filter.empty()) {
// Always skip the embedded test if there's a filter.
std::wcout << console_color::fg(console_color::YELLOW) << "SKIP: " << source.name() << " (embedded)" << console_color::reset()
<< std::endl;
embedded_result = 0; // no error
} else if (embedded_result == 0) {
// embedded loaded OK, try it
std::wcout << console_color::fg(console_color::BLUE) << console_color::bold() << "TEST: " << source.name() << " (embedded)"
<< console_color::reset() << std::endl;
embedded_result = run_test(source, compiled, embedded_test_source);
if (embedded_result != 0) {
failures.push_back("in-XML (@@ comment) embedded test failed");
}
} else {
// Not an error in itself, if JSON is present.
embedded_result = -1; // load failed
}
km::tests::LdmlJsonTestSourceFactory json_factory;
// adjust path
const auto json_path = km::tests::LdmlJsonTestSourceFactory::kmx_to_test_json(compiled);
int json_result = json_factory.load(compiled, json_path);
if (json_result != -1) {
const km::tests::JsonTestMap& json_tests = json_factory.get_tests();
size_t skip_count = 0;
test_assert(json_tests.size() > 0);
// Loop over all tests
for (const auto& n : json_tests) {
const auto test_name = n.first;
auto qq = test_name.find(filter);
if (filter == "--list" || (qq == std::string::npos)) {
skip_count ++;
std::wcout << console_color::fg(console_color::YELLOW) << "SKIP: " << json_path.stem().c_str() << "/" << console_color::bold() << n.first.c_str() << console_color::reset() << std::endl;
continue;
}
std::wcout << console_color::fg(console_color::BLUE) << "TEST: " << json_path.stem().c_str() << "/" << console_color::bold() << n.first.c_str() << console_color::reset() << std::endl;
int sub_test = run_test(source, compiled, *n.second);
if (sub_test != 0) {
std::wcout << console_color::fg(console_color::BRIGHT_RED) << "FAIL: " << json_path.stem() << "/" << console_color::bold() << n.first.c_str()
<< console_color::reset() << std::endl;
failures.push_back(json_path.stem() + "/" + n.first);
json_result = sub_test; // set to last failure
} else {
std::wcout << console_color::fg(console_color::GREEN) << "PASS: " << console_color::reset() << json_path.stem()
<< "/" << console_color::bold() << n.first.c_str() << std::endl;
}
}
auto all_count = json_tests.size();
auto fail_count = failures.size();
auto pass_count = all_count - fail_count - skip_count;
if (pass_count > 0) {
std::wcout << console_color::fg(console_color::GREEN) << " +" << pass_count;
}
if (fail_count > 0) {
std::wcout << console_color::fg(console_color::BRIGHT_RED) <<
" -" << fail_count;
}
if (skip_count > 0) {
std::wcout << console_color::fg(console_color::YELLOW) <<
" (skipped " << skip_count << ")";
}
std::wcout << console_color::reset() << " of " << all_count << " JSON tests in "
<< json_path.stem() << std::endl;
}
// OK.
std::wcout << console_color::fg(console_color::YELLOW) << "---- Summary of " << source.name() << " ----" << console_color::reset() << std::endl;
if (embedded_result == -1) {
std::wcout << console_color::fg(console_color::YELLOW) << "Note: No embedded test." << console_color::reset() << std::endl;
}
if (json_result == -1) {
std::wcout << console_color::fg(console_color::YELLOW) << "Note: No json test." << console_color::reset() << std::endl;
}
// if both are missing, that's an error in itself.
if (embedded_result == -1 && json_result == -1) {
// Can't both be missing.
failures.push_back("Error: Need either embedded test (@@ directives) or json test");
}
// recap the failures
if (failures.size() > 0) {
for (const auto& f : failures) {
std::wcerr << console_color::fg(console_color::RED) << "failed: " << f.c_str() << console_color::reset() << std::endl;
}
return -1;
} else {
std::cout << "run_all_tests passed" << std::endl;
return 0;
}
}
constexpr const auto help_str =
"\
ldml [--color] <LDML_FILE> <KMX_FILE> [ <TEST_FILTER> | --list ]\n\
help:\n\
\tLDML_FILE:\tThe .xml file for the keyboard under test.\n\
\tKMX_FILE:\tThe corresponding compiled kmx file.\n\
\tTEST_FILTER:\tIf present, only run json tests containing the filter substring. --list will list all tests\n";
} // namespace
int error_args() {
std::cerr << "ldml: Not enough arguments." << std::endl;
std::cout << help_str;
return 1;
}
int main(int argc, char *argv[]) {
int first_arg = 1;
if ((argc - first_arg) < 2) { // if < 2 remaining args
return error_args();
}
auto arg_color = std::string(argv[first_arg]) == "--color";
if(arg_color) {
first_arg++;
}
console_color::enabled = console_color::isaterminal() || arg_color;
if ((argc - first_arg) < 2) {
return error_args();
}
const km::core::path ldml_file = argv[first_arg++];
const km::core::path kmx_file = argv[first_arg++];
std::string filter; // default to 'all tests'
if ((argc - first_arg) >= 1) {
filter = argv[first_arg++];
}
int rc = run_all_tests(ldml_file, kmx_file, filter);
if (rc != EXIT_SUCCESS) {
std::wcerr << console_color::fg(console_color::BRIGHT_RED) << "FAILED" << console_color::reset() << std::endl;
rc = EXIT_FAILURE;
}
return rc;
}