/* 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 #include #include #include #include #include #include #include #include #include #include "path.hpp" #include "state.hpp" #include "utfcodec.hpp" #include #include #include "keyman_core.h" #include "util_normalize.hpp" #include // for surrogate pair macros #include "ldml_test_source.hpp" #include "debuglog.h" #include "ldml/ldml_markers.hpp" #include "processor.hpp" #include "../load_kmx_file.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 &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 &context, km::tests::LdmlTestSource &test_source, std::vector &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 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 &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(); auto blob = km::tests::load_kmx_file(compiled.native().c_str()); test_assert_equal(km_core_keyboard_load_from_blob(compiled.stem().c_str(), blob.data(), blob.size(), &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 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 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] [ | --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; }