From 596640e326288b2219397ecc78aed42c9bb86673 Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:06 -0400 Subject: [PATCH 1/8] Vendor doctest 2.4.12 for unit tests Single-header test framework (MIT, licence kept in ThirdParty/doctest). --- ThirdParty/doctest/LICENSE.txt | 21 + ThirdParty/doctest/doctest.h | 7134 ++++++++++++++++++++++++++++++++ 2 files changed, 7155 insertions(+) create mode 100644 ThirdParty/doctest/LICENSE.txt create mode 100644 ThirdParty/doctest/doctest.h diff --git a/ThirdParty/doctest/LICENSE.txt b/ThirdParty/doctest/LICENSE.txt new file mode 100644 index 00000000..5ae0eb10 --- /dev/null +++ b/ThirdParty/doctest/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016-2023 Viktor Kirilov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ThirdParty/doctest/doctest.h b/ThirdParty/doctest/doctest.h new file mode 100644 index 00000000..52b4a4aa --- /dev/null +++ b/ThirdParty/doctest/doctest.h @@ -0,0 +1,7134 @@ +// ====================================================================== lgtm [cpp/missing-header-guard] +// == DO NOT MODIFY THIS FILE BY HAND - IT IS AUTO GENERATED BY CMAKE! == +// ====================================================================== +// +// doctest.h - the lightest feature-rich C++ single-header testing framework for unit tests and TDD +// +// Copyright (c) 2016-2023 Viktor Kirilov +// +// Distributed under the MIT Software License +// See accompanying file LICENSE.txt or copy at +// https://opensource.org/licenses/MIT +// +// The documentation can be found at the library's page: +// https://github.com/doctest/doctest/blob/master/doc/markdown/readme.md +// +// ================================================================================================= +// ================================================================================================= +// ================================================================================================= +// +// The library is heavily influenced by Catch - https://github.com/catchorg/Catch2 +// which uses the Boost Software License - Version 1.0 +// see here - https://github.com/catchorg/Catch2/blob/master/LICENSE.txt +// +// The concept of subcases (sections in Catch) and expression decomposition are from there. +// Some parts of the code are taken directly: +// - stringification - the detection of "ostream& operator<<(ostream&, const T&)" and StringMaker<> +// - the Approx() helper class for floating point comparison +// - colors in the console +// - breaking into a debugger +// - signal / SEH handling +// - timer +// - XmlWriter class - thanks to Phil Nash for allowing the direct reuse (AKA copy/paste) +// +// The expression decomposing templates are taken from lest - https://github.com/martinmoene/lest +// which uses the Boost Software License - Version 1.0 +// see here - https://github.com/martinmoene/lest/blob/master/LICENSE.txt +// +// ================================================================================================= +// ================================================================================================= +// ================================================================================================= + +#ifndef DOCTEST_LIBRARY_INCLUDED +#define DOCTEST_LIBRARY_INCLUDED + +// ================================================================================================= +// == VERSION ====================================================================================== +// ================================================================================================= + +#define DOCTEST_VERSION_MAJOR 2 +#define DOCTEST_VERSION_MINOR 4 +#define DOCTEST_VERSION_PATCH 12 + +// util we need here +#define DOCTEST_TOSTR_IMPL(x) #x +#define DOCTEST_TOSTR(x) DOCTEST_TOSTR_IMPL(x) + +#define DOCTEST_VERSION_STR \ + DOCTEST_TOSTR(DOCTEST_VERSION_MAJOR) "." \ + DOCTEST_TOSTR(DOCTEST_VERSION_MINOR) "." \ + DOCTEST_TOSTR(DOCTEST_VERSION_PATCH) + +#define DOCTEST_VERSION \ + (DOCTEST_VERSION_MAJOR * 10000 + DOCTEST_VERSION_MINOR * 100 + DOCTEST_VERSION_PATCH) + +// ================================================================================================= +// == COMPILER VERSION ============================================================================= +// ================================================================================================= + +// ideas for the version stuff are taken from here: https://github.com/cxxstuff/cxx_detect + +#ifdef _MSC_VER +#define DOCTEST_CPLUSPLUS _MSVC_LANG +#else +#define DOCTEST_CPLUSPLUS __cplusplus +#endif + +#define DOCTEST_COMPILER(MAJOR, MINOR, PATCH) ((MAJOR)*10000000 + (MINOR)*100000 + (PATCH)) + +// GCC/Clang and GCC/MSVC are mutually exclusive, but Clang/MSVC are not because of clang-cl... +#if defined(_MSC_VER) && defined(_MSC_FULL_VER) +#if _MSC_VER == _MSC_FULL_VER / 10000 +#define DOCTEST_MSVC DOCTEST_COMPILER(_MSC_VER / 100, _MSC_VER % 100, _MSC_FULL_VER % 10000) +#else // MSVC +#define DOCTEST_MSVC \ + DOCTEST_COMPILER(_MSC_VER / 100, (_MSC_FULL_VER / 100000) % 100, _MSC_FULL_VER % 100000) +#endif // MSVC +#endif // MSVC +#if defined(__clang__) && defined(__clang_minor__) && defined(__clang_patchlevel__) +#define DOCTEST_CLANG DOCTEST_COMPILER(__clang_major__, __clang_minor__, __clang_patchlevel__) +#elif defined(__GNUC__) && defined(__GNUC_MINOR__) && defined(__GNUC_PATCHLEVEL__) && \ + !defined(__INTEL_COMPILER) +#define DOCTEST_GCC DOCTEST_COMPILER(__GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__) +#endif // GCC +#if defined(__INTEL_COMPILER) +#define DOCTEST_ICC DOCTEST_COMPILER(__INTEL_COMPILER / 100, __INTEL_COMPILER % 100, 0) +#endif // ICC + +#ifndef DOCTEST_MSVC +#define DOCTEST_MSVC 0 +#endif // DOCTEST_MSVC +#ifndef DOCTEST_CLANG +#define DOCTEST_CLANG 0 +#endif // DOCTEST_CLANG +#ifndef DOCTEST_GCC +#define DOCTEST_GCC 0 +#endif // DOCTEST_GCC +#ifndef DOCTEST_ICC +#define DOCTEST_ICC 0 +#endif // DOCTEST_ICC + +// ================================================================================================= +// == COMPILER WARNINGS HELPERS ==================================================================== +// ================================================================================================= + +#if DOCTEST_CLANG && !DOCTEST_ICC +#define DOCTEST_PRAGMA_TO_STR(x) _Pragma(#x) +#define DOCTEST_CLANG_SUPPRESS_WARNING_PUSH _Pragma("clang diagnostic push") +#define DOCTEST_CLANG_SUPPRESS_WARNING(w) DOCTEST_PRAGMA_TO_STR(clang diagnostic ignored w) +#define DOCTEST_CLANG_SUPPRESS_WARNING_POP _Pragma("clang diagnostic pop") +#define DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH(w) \ + DOCTEST_CLANG_SUPPRESS_WARNING_PUSH DOCTEST_CLANG_SUPPRESS_WARNING(w) +#else // DOCTEST_CLANG +#define DOCTEST_CLANG_SUPPRESS_WARNING_PUSH +#define DOCTEST_CLANG_SUPPRESS_WARNING(w) +#define DOCTEST_CLANG_SUPPRESS_WARNING_POP +#define DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH(w) +#endif // DOCTEST_CLANG + +#if DOCTEST_GCC +#define DOCTEST_PRAGMA_TO_STR(x) _Pragma(#x) +#define DOCTEST_GCC_SUPPRESS_WARNING_PUSH _Pragma("GCC diagnostic push") +#define DOCTEST_GCC_SUPPRESS_WARNING(w) DOCTEST_PRAGMA_TO_STR(GCC diagnostic ignored w) +#define DOCTEST_GCC_SUPPRESS_WARNING_POP _Pragma("GCC diagnostic pop") +#define DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH(w) \ + DOCTEST_GCC_SUPPRESS_WARNING_PUSH DOCTEST_GCC_SUPPRESS_WARNING(w) +#else // DOCTEST_GCC +#define DOCTEST_GCC_SUPPRESS_WARNING_PUSH +#define DOCTEST_GCC_SUPPRESS_WARNING(w) +#define DOCTEST_GCC_SUPPRESS_WARNING_POP +#define DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH(w) +#endif // DOCTEST_GCC + +#if DOCTEST_MSVC +#define DOCTEST_MSVC_SUPPRESS_WARNING_PUSH __pragma(warning(push)) +#define DOCTEST_MSVC_SUPPRESS_WARNING(w) __pragma(warning(disable : w)) +#define DOCTEST_MSVC_SUPPRESS_WARNING_POP __pragma(warning(pop)) +#define DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(w) \ + DOCTEST_MSVC_SUPPRESS_WARNING_PUSH DOCTEST_MSVC_SUPPRESS_WARNING(w) +#else // DOCTEST_MSVC +#define DOCTEST_MSVC_SUPPRESS_WARNING_PUSH +#define DOCTEST_MSVC_SUPPRESS_WARNING(w) +#define DOCTEST_MSVC_SUPPRESS_WARNING_POP +#define DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(w) +#endif // DOCTEST_MSVC + +// ================================================================================================= +// == COMPILER WARNINGS ============================================================================ +// ================================================================================================= + +// both the header and the implementation suppress all of these, +// so it only makes sense to aggregate them like so +#define DOCTEST_SUPPRESS_COMMON_WARNINGS_PUSH \ + DOCTEST_CLANG_SUPPRESS_WARNING_PUSH \ + DOCTEST_CLANG_SUPPRESS_WARNING("-Wunknown-pragmas") \ + DOCTEST_CLANG_SUPPRESS_WARNING("-Wweak-vtables") \ + DOCTEST_CLANG_SUPPRESS_WARNING("-Wpadded") \ + DOCTEST_CLANG_SUPPRESS_WARNING("-Wmissing-prototypes") \ + DOCTEST_CLANG_SUPPRESS_WARNING("-Wc++98-compat") \ + DOCTEST_CLANG_SUPPRESS_WARNING("-Wc++98-compat-pedantic") \ + \ + DOCTEST_GCC_SUPPRESS_WARNING_PUSH \ + DOCTEST_GCC_SUPPRESS_WARNING("-Wunknown-pragmas") \ + DOCTEST_GCC_SUPPRESS_WARNING("-Wpragmas") \ + DOCTEST_GCC_SUPPRESS_WARNING("-Weffc++") \ + DOCTEST_GCC_SUPPRESS_WARNING("-Wstrict-overflow") \ + DOCTEST_GCC_SUPPRESS_WARNING("-Wstrict-aliasing") \ + DOCTEST_GCC_SUPPRESS_WARNING("-Wmissing-declarations") \ + DOCTEST_GCC_SUPPRESS_WARNING("-Wuseless-cast") \ + DOCTEST_GCC_SUPPRESS_WARNING("-Wnoexcept") \ + \ + DOCTEST_MSVC_SUPPRESS_WARNING_PUSH \ + /* these 4 also disabled globally via cmake: */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4514) /* unreferenced inline function has been removed */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4571) /* SEH related */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4710) /* function not inlined */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4711) /* function selected for inline expansion*/ \ + /* common ones */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4616) /* invalid compiler warning */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4619) /* invalid compiler warning */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4996) /* The compiler encountered a deprecated declaration */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4706) /* assignment within conditional expression */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4512) /* 'class' : assignment operator could not be generated */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4127) /* conditional expression is constant */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4820) /* padding */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4625) /* copy constructor was implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4626) /* assignment operator was implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5027) /* move assignment operator implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5026) /* move constructor was implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4640) /* construction of local static object not thread-safe */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5045) /* Spectre mitigation for memory load */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5264) /* 'variable-name': 'const' variable is not used */ \ + /* static analysis */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(26439) /* Function may not throw. Declare it 'noexcept' */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(26495) /* Always initialize a member variable */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(26451) /* Arithmetic overflow ... */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(26444) /* Avoid unnamed objects with custom ctor and dtor... */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(26812) /* Prefer 'enum class' over 'enum' */ + +#define DOCTEST_SUPPRESS_COMMON_WARNINGS_POP \ + DOCTEST_CLANG_SUPPRESS_WARNING_POP \ + DOCTEST_GCC_SUPPRESS_WARNING_POP \ + DOCTEST_MSVC_SUPPRESS_WARNING_POP + +DOCTEST_SUPPRESS_COMMON_WARNINGS_PUSH + +DOCTEST_CLANG_SUPPRESS_WARNING_PUSH +DOCTEST_CLANG_SUPPRESS_WARNING("-Wnon-virtual-dtor") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wdeprecated") + +DOCTEST_GCC_SUPPRESS_WARNING_PUSH +DOCTEST_GCC_SUPPRESS_WARNING("-Wctor-dtor-privacy") +DOCTEST_GCC_SUPPRESS_WARNING("-Wnon-virtual-dtor") +DOCTEST_GCC_SUPPRESS_WARNING("-Wsign-promo") + +DOCTEST_MSVC_SUPPRESS_WARNING_PUSH +DOCTEST_MSVC_SUPPRESS_WARNING(4623) // default constructor was implicitly defined as deleted + +#define DOCTEST_MAKE_STD_HEADERS_CLEAN_FROM_WARNINGS_ON_WALL_BEGIN \ + DOCTEST_MSVC_SUPPRESS_WARNING_PUSH \ + DOCTEST_MSVC_SUPPRESS_WARNING(4548) /* before comma no effect; expected side - effect */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4265) /* virtual functions, but destructor is not virtual */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4986) /* exception specification does not match previous */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4350) /* 'member1' called instead of 'member2' */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4668) /* not defined as a preprocessor macro */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4365) /* signed/unsigned mismatch */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4774) /* format string not a string literal */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4820) /* padding */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4625) /* copy constructor was implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4626) /* assignment operator was implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5027) /* move assignment operator implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5026) /* move constructor was implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4623) /* default constructor was implicitly deleted */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5039) /* pointer to pot. throwing function passed to extern C */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5045) /* Spectre mitigation for memory load */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5105) /* macro producing 'defined' has undefined behavior */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(4738) /* storing float result in memory, loss of performance */ \ + DOCTEST_MSVC_SUPPRESS_WARNING(5262) /* implicit fall-through */ + +#define DOCTEST_MAKE_STD_HEADERS_CLEAN_FROM_WARNINGS_ON_WALL_END DOCTEST_MSVC_SUPPRESS_WARNING_POP + +// ================================================================================================= +// == FEATURE DETECTION ============================================================================ +// ================================================================================================= + +// general compiler feature support table: https://en.cppreference.com/w/cpp/compiler_support +// MSVC C++11 feature support table: https://msdn.microsoft.com/en-us/library/hh567368.aspx +// GCC C++11 feature support table: https://gcc.gnu.org/projects/cxx-status.html +// MSVC version table: +// https://en.wikipedia.org/wiki/Microsoft_Visual_C%2B%2B#Internal_version_numbering +// MSVC++ 14.3 (17) _MSC_VER == 1930 (Visual Studio 2022) +// MSVC++ 14.2 (16) _MSC_VER == 1920 (Visual Studio 2019) +// MSVC++ 14.1 (15) _MSC_VER == 1910 (Visual Studio 2017) +// MSVC++ 14.0 _MSC_VER == 1900 (Visual Studio 2015) +// MSVC++ 12.0 _MSC_VER == 1800 (Visual Studio 2013) +// MSVC++ 11.0 _MSC_VER == 1700 (Visual Studio 2012) +// MSVC++ 10.0 _MSC_VER == 1600 (Visual Studio 2010) +// MSVC++ 9.0 _MSC_VER == 1500 (Visual Studio 2008) +// MSVC++ 8.0 _MSC_VER == 1400 (Visual Studio 2005) + +// Universal Windows Platform support +#if defined(WINAPI_FAMILY) && (WINAPI_FAMILY == WINAPI_FAMILY_APP) +#define DOCTEST_CONFIG_NO_WINDOWS_SEH +#endif // WINAPI_FAMILY +#if DOCTEST_MSVC && !defined(DOCTEST_CONFIG_WINDOWS_SEH) +#define DOCTEST_CONFIG_WINDOWS_SEH +#endif // MSVC +#if defined(DOCTEST_CONFIG_NO_WINDOWS_SEH) && defined(DOCTEST_CONFIG_WINDOWS_SEH) +#undef DOCTEST_CONFIG_WINDOWS_SEH +#endif // DOCTEST_CONFIG_NO_WINDOWS_SEH + +#if !defined(_WIN32) && !defined(__QNX__) && !defined(DOCTEST_CONFIG_POSIX_SIGNALS) && \ + !defined(__EMSCRIPTEN__) && !defined(__wasi__) +#define DOCTEST_CONFIG_POSIX_SIGNALS +#endif // _WIN32 +#if defined(DOCTEST_CONFIG_NO_POSIX_SIGNALS) && defined(DOCTEST_CONFIG_POSIX_SIGNALS) +#undef DOCTEST_CONFIG_POSIX_SIGNALS +#endif // DOCTEST_CONFIG_NO_POSIX_SIGNALS + +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS +#if !defined(__cpp_exceptions) && !defined(__EXCEPTIONS) && !defined(_CPPUNWIND) \ + || defined(__wasi__) +#define DOCTEST_CONFIG_NO_EXCEPTIONS +#endif // no exceptions +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + +#ifdef DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS +#define DOCTEST_CONFIG_NO_EXCEPTIONS +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS + +#if defined(DOCTEST_CONFIG_NO_EXCEPTIONS) && !defined(DOCTEST_CONFIG_NO_TRY_CATCH_IN_ASSERTS) +#define DOCTEST_CONFIG_NO_TRY_CATCH_IN_ASSERTS +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS && !DOCTEST_CONFIG_NO_TRY_CATCH_IN_ASSERTS + +#ifdef __wasi__ +#define DOCTEST_CONFIG_NO_MULTITHREADING +#endif + +#if defined(DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN) && !defined(DOCTEST_CONFIG_IMPLEMENT) +#define DOCTEST_CONFIG_IMPLEMENT +#endif // DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN + +#if defined(_WIN32) || defined(__CYGWIN__) +#if DOCTEST_MSVC +#define DOCTEST_SYMBOL_EXPORT __declspec(dllexport) +#define DOCTEST_SYMBOL_IMPORT __declspec(dllimport) +#else // MSVC +#define DOCTEST_SYMBOL_EXPORT __attribute__((dllexport)) +#define DOCTEST_SYMBOL_IMPORT __attribute__((dllimport)) +#endif // MSVC +#else // _WIN32 +#define DOCTEST_SYMBOL_EXPORT __attribute__((visibility("default"))) +#define DOCTEST_SYMBOL_IMPORT +#endif // _WIN32 + +#ifdef DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL +#ifdef DOCTEST_CONFIG_IMPLEMENT +#define DOCTEST_INTERFACE DOCTEST_SYMBOL_EXPORT +#else // DOCTEST_CONFIG_IMPLEMENT +#define DOCTEST_INTERFACE DOCTEST_SYMBOL_IMPORT +#endif // DOCTEST_CONFIG_IMPLEMENT +#else // DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL +#define DOCTEST_INTERFACE +#endif // DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL + +// needed for extern template instantiations +// see https://github.com/fmtlib/fmt/issues/2228 +#if DOCTEST_MSVC +#define DOCTEST_INTERFACE_DECL +#define DOCTEST_INTERFACE_DEF DOCTEST_INTERFACE +#else // DOCTEST_MSVC +#define DOCTEST_INTERFACE_DECL DOCTEST_INTERFACE +#define DOCTEST_INTERFACE_DEF +#endif // DOCTEST_MSVC + +#define DOCTEST_EMPTY + +#if DOCTEST_MSVC +#define DOCTEST_NOINLINE __declspec(noinline) +#define DOCTEST_UNUSED +#define DOCTEST_ALIGNMENT(x) +#elif DOCTEST_CLANG && DOCTEST_CLANG < DOCTEST_COMPILER(3, 5, 0) +#define DOCTEST_NOINLINE +#define DOCTEST_UNUSED +#define DOCTEST_ALIGNMENT(x) +#else +#define DOCTEST_NOINLINE __attribute__((noinline)) +#define DOCTEST_UNUSED __attribute__((unused)) +#define DOCTEST_ALIGNMENT(x) __attribute__((aligned(x))) +#endif + +#ifdef DOCTEST_CONFIG_NO_CONTRADICTING_INLINE +#define DOCTEST_INLINE_NOINLINE inline +#else +#define DOCTEST_INLINE_NOINLINE inline DOCTEST_NOINLINE +#endif + +#ifndef DOCTEST_NORETURN +#if DOCTEST_MSVC && (DOCTEST_MSVC < DOCTEST_COMPILER(19, 0, 0)) +#define DOCTEST_NORETURN +#else // DOCTEST_MSVC +#define DOCTEST_NORETURN [[noreturn]] +#endif // DOCTEST_MSVC +#endif // DOCTEST_NORETURN + +#ifndef DOCTEST_NOEXCEPT +#if DOCTEST_MSVC && (DOCTEST_MSVC < DOCTEST_COMPILER(19, 0, 0)) +#define DOCTEST_NOEXCEPT +#else // DOCTEST_MSVC +#define DOCTEST_NOEXCEPT noexcept +#endif // DOCTEST_MSVC +#endif // DOCTEST_NOEXCEPT + +#ifndef DOCTEST_CONSTEXPR +#if DOCTEST_MSVC && (DOCTEST_MSVC < DOCTEST_COMPILER(19, 0, 0)) +#define DOCTEST_CONSTEXPR const +#define DOCTEST_CONSTEXPR_FUNC inline +#else // DOCTEST_MSVC +#define DOCTEST_CONSTEXPR constexpr +#define DOCTEST_CONSTEXPR_FUNC constexpr +#endif // DOCTEST_MSVC +#endif // DOCTEST_CONSTEXPR + +#ifndef DOCTEST_NO_SANITIZE_INTEGER +#if DOCTEST_CLANG >= DOCTEST_COMPILER(3, 7, 0) +#define DOCTEST_NO_SANITIZE_INTEGER __attribute__((no_sanitize("integer"))) +#else +#define DOCTEST_NO_SANITIZE_INTEGER +#endif +#endif // DOCTEST_NO_SANITIZE_INTEGER + +// ================================================================================================= +// == FEATURE DETECTION END ======================================================================== +// ================================================================================================= + +#define DOCTEST_DECLARE_INTERFACE(name) \ + virtual ~name(); \ + name() = default; \ + name(const name&) = delete; \ + name(name&&) = delete; \ + name& operator=(const name&) = delete; \ + name& operator=(name&&) = delete; + +#define DOCTEST_DEFINE_INTERFACE(name) \ + name::~name() = default; + +// internal macros for string concatenation and anonymous variable name generation +#define DOCTEST_CAT_IMPL(s1, s2) s1##s2 +#define DOCTEST_CAT(s1, s2) DOCTEST_CAT_IMPL(s1, s2) +#ifdef __COUNTER__ // not standard and may be missing for some compilers +#define DOCTEST_ANONYMOUS(x) DOCTEST_CAT(x, __COUNTER__) +#else // __COUNTER__ +#define DOCTEST_ANONYMOUS(x) DOCTEST_CAT(x, __LINE__) +#endif // __COUNTER__ + +#ifndef DOCTEST_CONFIG_ASSERTION_PARAMETERS_BY_VALUE +#define DOCTEST_REF_WRAP(x) x& +#else // DOCTEST_CONFIG_ASSERTION_PARAMETERS_BY_VALUE +#define DOCTEST_REF_WRAP(x) x +#endif // DOCTEST_CONFIG_ASSERTION_PARAMETERS_BY_VALUE + +// not using __APPLE__ because... this is how Catch does it +#ifdef __MAC_OS_X_VERSION_MIN_REQUIRED +#define DOCTEST_PLATFORM_MAC +#elif defined(__IPHONE_OS_VERSION_MIN_REQUIRED) +#define DOCTEST_PLATFORM_IPHONE +#elif defined(_WIN32) +#define DOCTEST_PLATFORM_WINDOWS +#elif defined(__wasi__) +#define DOCTEST_PLATFORM_WASI +#else // DOCTEST_PLATFORM +#define DOCTEST_PLATFORM_LINUX +#endif // DOCTEST_PLATFORM + +namespace doctest { namespace detail { + static DOCTEST_CONSTEXPR int consume(const int*, int) noexcept { return 0; } +}} + +#define DOCTEST_GLOBAL_NO_WARNINGS(var, ...) \ + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wglobal-constructors") \ + static const int var = doctest::detail::consume(&var, __VA_ARGS__); \ + DOCTEST_CLANG_SUPPRESS_WARNING_POP + +#ifndef DOCTEST_BREAK_INTO_DEBUGGER +// should probably take a look at https://github.com/scottt/debugbreak +#ifdef DOCTEST_PLATFORM_LINUX +#if defined(__GNUC__) && (defined(__i386) || defined(__x86_64)) +// Break at the location of the failing check if possible +#define DOCTEST_BREAK_INTO_DEBUGGER() __asm__("int $3\n" : :) // NOLINT(hicpp-no-assembler) +#else +#include +#define DOCTEST_BREAK_INTO_DEBUGGER() raise(SIGTRAP) +#endif +#elif defined(DOCTEST_PLATFORM_MAC) +#if defined(__x86_64) || defined(__x86_64__) || defined(__amd64__) || defined(__i386) +#define DOCTEST_BREAK_INTO_DEBUGGER() __asm__("int $3\n" : :) // NOLINT(hicpp-no-assembler) +#elif defined(__ppc__) || defined(__ppc64__) +// https://www.cocoawithlove.com/2008/03/break-into-debugger.html +#define DOCTEST_BREAK_INTO_DEBUGGER() __asm__("li r0, 20\nsc\nnop\nli r0, 37\nli r4, 2\nsc\nnop\n": : : "memory","r0","r3","r4") // NOLINT(hicpp-no-assembler) +#else +#define DOCTEST_BREAK_INTO_DEBUGGER() __asm__("brk #0"); // NOLINT(hicpp-no-assembler) +#endif +#elif DOCTEST_MSVC +#define DOCTEST_BREAK_INTO_DEBUGGER() __debugbreak() +#elif defined(__MINGW32__) +DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wredundant-decls") +extern "C" __declspec(dllimport) void __stdcall DebugBreak(); +DOCTEST_GCC_SUPPRESS_WARNING_POP +#define DOCTEST_BREAK_INTO_DEBUGGER() ::DebugBreak() +#else // linux +#define DOCTEST_BREAK_INTO_DEBUGGER() (static_cast(0)) +#endif // linux +#endif // DOCTEST_BREAK_INTO_DEBUGGER + +// this is kept here for backwards compatibility since the config option was changed +#ifdef DOCTEST_CONFIG_USE_IOSFWD +#ifndef DOCTEST_CONFIG_USE_STD_HEADERS +#define DOCTEST_CONFIG_USE_STD_HEADERS +#endif +#endif // DOCTEST_CONFIG_USE_IOSFWD + +// for clang - always include ciso646 (which drags some std stuff) because +// we want to check if we are using libc++ with the _LIBCPP_VERSION macro in +// which case we don't want to forward declare stuff from std - for reference: +// https://github.com/doctest/doctest/issues/126 +// https://github.com/doctest/doctest/issues/356 +#if DOCTEST_CLANG +#include +#endif // clang + +#ifdef _LIBCPP_VERSION +#ifndef DOCTEST_CONFIG_USE_STD_HEADERS +#define DOCTEST_CONFIG_USE_STD_HEADERS +#endif +#endif // _LIBCPP_VERSION + +#ifdef DOCTEST_CONFIG_USE_STD_HEADERS +#ifndef DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS +#define DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS +#endif // DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS +DOCTEST_MAKE_STD_HEADERS_CLEAN_FROM_WARNINGS_ON_WALL_BEGIN +#include +#include +#include +DOCTEST_MAKE_STD_HEADERS_CLEAN_FROM_WARNINGS_ON_WALL_END +#else // DOCTEST_CONFIG_USE_STD_HEADERS + +// Forward declaring 'X' in namespace std is not permitted by the C++ Standard. +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4643) + +namespace std { // NOLINT(cert-dcl58-cpp) +typedef decltype(nullptr) nullptr_t; // NOLINT(modernize-use-using) +typedef decltype(sizeof(void*)) size_t; // NOLINT(modernize-use-using) +template +struct char_traits; +template <> +struct char_traits; +template +class basic_ostream; // NOLINT(fuchsia-virtual-inheritance) +typedef basic_ostream> ostream; // NOLINT(modernize-use-using) +template +// NOLINTNEXTLINE +basic_ostream& operator<<(basic_ostream&, const char*); +template +class basic_istream; +typedef basic_istream> istream; // NOLINT(modernize-use-using) +template +class tuple; +#if DOCTEST_MSVC >= DOCTEST_COMPILER(19, 20, 0) +// see this issue on why this is needed: https://github.com/doctest/doctest/issues/183 +template +class allocator; +template +class basic_string; +using string = basic_string, allocator>; +#endif // VS 2019 +} // namespace std + +DOCTEST_MSVC_SUPPRESS_WARNING_POP + +#endif // DOCTEST_CONFIG_USE_STD_HEADERS + +#ifdef DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS +#include +#endif // DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + +namespace doctest { + +using std::size_t; + +DOCTEST_INTERFACE extern bool is_running_in_test; + +#ifndef DOCTEST_CONFIG_STRING_SIZE_TYPE +#define DOCTEST_CONFIG_STRING_SIZE_TYPE unsigned +#endif + +// A 24 byte string class (can be as small as 17 for x64 and 13 for x86) that can hold strings with length +// of up to 23 chars on the stack before going on the heap - the last byte of the buffer is used for: +// - "is small" bit - the highest bit - if "0" then it is small - otherwise its "1" (128) +// - if small - capacity left before going on the heap - using the lowest 5 bits +// - if small - 2 bits are left unused - the second and third highest ones +// - if small - acts as a null terminator if strlen() is 23 (24 including the null terminator) +// and the "is small" bit remains "0" ("as well as the capacity left") so its OK +// Idea taken from this lecture about the string implementation of facebook/folly - fbstring +// https://www.youtube.com/watch?v=kPR8h4-qZdk +// TODO: +// - optimizations - like not deleting memory unnecessarily in operator= and etc. +// - resize/reserve/clear +// - replace +// - back/front +// - iterator stuff +// - find & friends +// - push_back/pop_back +// - assign/insert/erase +// - relational operators as free functions - taking const char* as one of the params +class DOCTEST_INTERFACE String +{ +public: + using size_type = DOCTEST_CONFIG_STRING_SIZE_TYPE; + +private: + static DOCTEST_CONSTEXPR size_type len = 24; //!OCLINT avoid private static members + static DOCTEST_CONSTEXPR size_type last = len - 1; //!OCLINT avoid private static members + + struct view // len should be more than sizeof(view) - because of the final byte for flags + { + char* ptr; + size_type size; + size_type capacity; + }; + + union + { + char buf[len]; // NOLINT(*-avoid-c-arrays) + view data; + }; + + char* allocate(size_type sz); + + bool isOnStack() const noexcept { return (buf[last] & 128) == 0; } + void setOnHeap() noexcept; + void setLast(size_type in = last) noexcept; + void setSize(size_type sz) noexcept; + + void copy(const String& other); + +public: + static DOCTEST_CONSTEXPR size_type npos = static_cast(-1); + + String() noexcept; + ~String(); + + // cppcheck-suppress noExplicitConstructor + String(const char* in); + String(const char* in, size_type in_size); + + String(std::istream& in, size_type in_size); + + String(const String& other); + String& operator=(const String& other); + + String& operator+=(const String& other); + + String(String&& other) noexcept; + String& operator=(String&& other) noexcept; + + char operator[](size_type i) const; + char& operator[](size_type i); + + // the only functions I'm willing to leave in the interface - available for inlining + const char* c_str() const { return const_cast(this)->c_str(); } // NOLINT + char* c_str() { + if (isOnStack()) { + return reinterpret_cast(buf); + } + return data.ptr; + } + + size_type size() const; + size_type capacity() const; + + String substr(size_type pos, size_type cnt = npos) &&; + String substr(size_type pos, size_type cnt = npos) const &; + + size_type find(char ch, size_type pos = 0) const; + size_type rfind(char ch, size_type pos = npos) const; + + int compare(const char* other, bool no_case = false) const; + int compare(const String& other, bool no_case = false) const; + +friend DOCTEST_INTERFACE std::ostream& operator<<(std::ostream& s, const String& in); +}; + +DOCTEST_INTERFACE String operator+(const String& lhs, const String& rhs); + +DOCTEST_INTERFACE bool operator==(const String& lhs, const String& rhs); +DOCTEST_INTERFACE bool operator!=(const String& lhs, const String& rhs); +DOCTEST_INTERFACE bool operator<(const String& lhs, const String& rhs); +DOCTEST_INTERFACE bool operator>(const String& lhs, const String& rhs); +DOCTEST_INTERFACE bool operator<=(const String& lhs, const String& rhs); +DOCTEST_INTERFACE bool operator>=(const String& lhs, const String& rhs); + +class DOCTEST_INTERFACE Contains { +public: + explicit Contains(const String& string); + + bool checkWith(const String& other) const; + + String string; +}; + +DOCTEST_INTERFACE String toString(const Contains& in); + +DOCTEST_INTERFACE bool operator==(const String& lhs, const Contains& rhs); +DOCTEST_INTERFACE bool operator==(const Contains& lhs, const String& rhs); +DOCTEST_INTERFACE bool operator!=(const String& lhs, const Contains& rhs); +DOCTEST_INTERFACE bool operator!=(const Contains& lhs, const String& rhs); + +namespace Color { + enum Enum + { + None = 0, + White, + Red, + Green, + Blue, + Cyan, + Yellow, + Grey, + + Bright = 0x10, + + BrightRed = Bright | Red, + BrightGreen = Bright | Green, + LightGrey = Bright | Grey, + BrightWhite = Bright | White + }; + + DOCTEST_INTERFACE std::ostream& operator<<(std::ostream& s, Color::Enum code); +} // namespace Color + +namespace assertType { + enum Enum + { + // macro traits + + is_warn = 1, + is_check = 2 * is_warn, + is_require = 2 * is_check, + + is_normal = 2 * is_require, + is_throws = 2 * is_normal, + is_throws_as = 2 * is_throws, + is_throws_with = 2 * is_throws_as, + is_nothrow = 2 * is_throws_with, + + is_false = 2 * is_nothrow, + is_unary = 2 * is_false, // not checked anywhere - used just to distinguish the types + + is_eq = 2 * is_unary, + is_ne = 2 * is_eq, + + is_lt = 2 * is_ne, + is_gt = 2 * is_lt, + + is_ge = 2 * is_gt, + is_le = 2 * is_ge, + + // macro types + + DT_WARN = is_normal | is_warn, + DT_CHECK = is_normal | is_check, + DT_REQUIRE = is_normal | is_require, + + DT_WARN_FALSE = is_normal | is_false | is_warn, + DT_CHECK_FALSE = is_normal | is_false | is_check, + DT_REQUIRE_FALSE = is_normal | is_false | is_require, + + DT_WARN_THROWS = is_throws | is_warn, + DT_CHECK_THROWS = is_throws | is_check, + DT_REQUIRE_THROWS = is_throws | is_require, + + DT_WARN_THROWS_AS = is_throws_as | is_warn, + DT_CHECK_THROWS_AS = is_throws_as | is_check, + DT_REQUIRE_THROWS_AS = is_throws_as | is_require, + + DT_WARN_THROWS_WITH = is_throws_with | is_warn, + DT_CHECK_THROWS_WITH = is_throws_with | is_check, + DT_REQUIRE_THROWS_WITH = is_throws_with | is_require, + + DT_WARN_THROWS_WITH_AS = is_throws_with | is_throws_as | is_warn, + DT_CHECK_THROWS_WITH_AS = is_throws_with | is_throws_as | is_check, + DT_REQUIRE_THROWS_WITH_AS = is_throws_with | is_throws_as | is_require, + + DT_WARN_NOTHROW = is_nothrow | is_warn, + DT_CHECK_NOTHROW = is_nothrow | is_check, + DT_REQUIRE_NOTHROW = is_nothrow | is_require, + + DT_WARN_EQ = is_normal | is_eq | is_warn, + DT_CHECK_EQ = is_normal | is_eq | is_check, + DT_REQUIRE_EQ = is_normal | is_eq | is_require, + + DT_WARN_NE = is_normal | is_ne | is_warn, + DT_CHECK_NE = is_normal | is_ne | is_check, + DT_REQUIRE_NE = is_normal | is_ne | is_require, + + DT_WARN_GT = is_normal | is_gt | is_warn, + DT_CHECK_GT = is_normal | is_gt | is_check, + DT_REQUIRE_GT = is_normal | is_gt | is_require, + + DT_WARN_LT = is_normal | is_lt | is_warn, + DT_CHECK_LT = is_normal | is_lt | is_check, + DT_REQUIRE_LT = is_normal | is_lt | is_require, + + DT_WARN_GE = is_normal | is_ge | is_warn, + DT_CHECK_GE = is_normal | is_ge | is_check, + DT_REQUIRE_GE = is_normal | is_ge | is_require, + + DT_WARN_LE = is_normal | is_le | is_warn, + DT_CHECK_LE = is_normal | is_le | is_check, + DT_REQUIRE_LE = is_normal | is_le | is_require, + + DT_WARN_UNARY = is_normal | is_unary | is_warn, + DT_CHECK_UNARY = is_normal | is_unary | is_check, + DT_REQUIRE_UNARY = is_normal | is_unary | is_require, + + DT_WARN_UNARY_FALSE = is_normal | is_false | is_unary | is_warn, + DT_CHECK_UNARY_FALSE = is_normal | is_false | is_unary | is_check, + DT_REQUIRE_UNARY_FALSE = is_normal | is_false | is_unary | is_require, + }; +} // namespace assertType + +DOCTEST_INTERFACE const char* assertString(assertType::Enum at); +DOCTEST_INTERFACE const char* failureString(assertType::Enum at); +DOCTEST_INTERFACE const char* skipPathFromFilename(const char* file); + +struct DOCTEST_INTERFACE TestCaseData +{ + String m_file; // the file in which the test was registered (using String - see #350) + unsigned m_line; // the line where the test was registered + const char* m_name; // name of the test case + const char* m_test_suite; // the test suite in which the test was added + const char* m_description; + bool m_skip; + bool m_no_breaks; + bool m_no_output; + bool m_may_fail; + bool m_should_fail; + int m_expected_failures; + double m_timeout; +}; + +struct DOCTEST_INTERFACE AssertData +{ + // common - for all asserts + const TestCaseData* m_test_case; + assertType::Enum m_at; + const char* m_file; + int m_line; + const char* m_expr; + bool m_failed; + + // exception-related - for all asserts + bool m_threw; + String m_exception; + + // for normal asserts + String m_decomp; + + // for specific exception-related asserts + bool m_threw_as; + const char* m_exception_type; + + class DOCTEST_INTERFACE StringContains { + private: + Contains content; + bool isContains; + + public: + StringContains(const String& str) : content(str), isContains(false) { } + StringContains(Contains cntn) : content(static_cast(cntn)), isContains(true) { } + + bool check(const String& str) { return isContains ? (content == str) : (content.string == str); } + + operator const String&() const { return content.string; } + + const char* c_str() const { return content.string.c_str(); } + } m_exception_string; + + AssertData(assertType::Enum at, const char* file, int line, const char* expr, + const char* exception_type, const StringContains& exception_string); +}; + +struct DOCTEST_INTERFACE MessageData +{ + String m_string; + const char* m_file; + int m_line; + assertType::Enum m_severity; +}; + +struct DOCTEST_INTERFACE SubcaseSignature +{ + String m_name; + const char* m_file; + int m_line; + + bool operator==(const SubcaseSignature& other) const; + bool operator<(const SubcaseSignature& other) const; +}; + +struct DOCTEST_INTERFACE IContextScope +{ + DOCTEST_DECLARE_INTERFACE(IContextScope) + virtual void stringify(std::ostream*) const = 0; +}; + +namespace detail { + struct DOCTEST_INTERFACE TestCase; +} // namespace detail + +struct ContextOptions //!OCLINT too many fields +{ + std::ostream* cout = nullptr; // stdout stream + String binary_name; // the test binary name + + const detail::TestCase* currentTest = nullptr; + + // == parameters from the command line + String out; // output filename + String order_by; // how tests should be ordered + unsigned rand_seed; // the seed for rand ordering + + unsigned first; // the first (matching) test to be executed + unsigned last; // the last (matching) test to be executed + + int abort_after; // stop tests after this many failed assertions + int subcase_filter_levels; // apply the subcase filters for the first N levels + + bool success; // include successful assertions in output + bool case_sensitive; // if filtering should be case sensitive + bool exit; // if the program should be exited after the tests are ran/whatever + bool duration; // print the time duration of each test case + bool minimal; // minimal console output (only test failures) + bool quiet; // no console output + bool no_throw; // to skip exceptions-related assertion macros + bool no_exitcode; // if the framework should return 0 as the exitcode + bool no_run; // to not run the tests at all (can be done with an "*" exclude) + bool no_intro; // to not print the intro of the framework + bool no_version; // to not print the version of the framework + bool no_colors; // if output to the console should be colorized + bool force_colors; // forces the use of colors even when a tty cannot be detected + bool no_breaks; // to not break into the debugger + bool no_skip; // don't skip test cases which are marked to be skipped + bool gnu_file_line; // if line numbers should be surrounded with :x: and not (x): + bool no_path_in_filenames; // if the path to files should be removed from the output + String strip_file_prefixes;// remove the longest matching one of these prefixes from any file paths in the output + bool no_line_numbers; // if source code line numbers should be omitted from the output + bool no_debug_output; // no output in the debug console when a debugger is attached + bool no_skipped_summary; // don't print "skipped" in the summary !!! UNDOCUMENTED !!! + bool no_time_in_output; // omit any time/timestamps from output !!! UNDOCUMENTED !!! + + bool help; // to print the help + bool version; // to print the version + bool count; // if only the count of matching tests is to be retrieved + bool list_test_cases; // to list all tests matching the filters + bool list_test_suites; // to list all suites matching the filters + bool list_reporters; // lists all registered reporters +}; + +namespace detail { + namespace types { +#ifdef DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + using namespace std; +#else + template + struct enable_if { }; + + template + struct enable_if { using type = T; }; + + struct true_type { static DOCTEST_CONSTEXPR bool value = true; }; + struct false_type { static DOCTEST_CONSTEXPR bool value = false; }; + + template struct remove_reference { using type = T; }; + template struct remove_reference { using type = T; }; + template struct remove_reference { using type = T; }; + + template struct is_rvalue_reference : false_type { }; + template struct is_rvalue_reference : true_type { }; + + template struct remove_const { using type = T; }; + template struct remove_const { using type = T; }; + + // Compiler intrinsics + template struct is_enum { static DOCTEST_CONSTEXPR bool value = __is_enum(T); }; + template struct underlying_type { using type = __underlying_type(T); }; + + template struct is_pointer : false_type { }; + template struct is_pointer : true_type { }; + + template struct is_array : false_type { }; + // NOLINTNEXTLINE(*-avoid-c-arrays) + template struct is_array : true_type { }; +#endif + } + + // + template + T&& declval(); + + template + DOCTEST_CONSTEXPR_FUNC T&& forward(typename types::remove_reference::type& t) DOCTEST_NOEXCEPT { + return static_cast(t); + } + + template + DOCTEST_CONSTEXPR_FUNC T&& forward(typename types::remove_reference::type&& t) DOCTEST_NOEXCEPT { + return static_cast(t); + } + + template + struct deferred_false : types::false_type { }; + +// MSVS 2015 :( +#if !DOCTEST_CLANG && defined(_MSC_VER) && _MSC_VER <= 1900 + template + struct has_global_insertion_operator : types::false_type { }; + + template + struct has_global_insertion_operator(), declval()), void())> : types::true_type { }; + + template + struct has_insertion_operator { static DOCTEST_CONSTEXPR bool value = has_global_insertion_operator::value; }; + + template + struct insert_hack; + + template + struct insert_hack { + static void insert(std::ostream& os, const T& t) { ::operator<<(os, t); } + }; + + template + struct insert_hack { + static void insert(std::ostream& os, const T& t) { operator<<(os, t); } + }; + + template + using insert_hack_t = insert_hack::value>; +#else + template + struct has_insertion_operator : types::false_type { }; +#endif + + template + struct has_insertion_operator(), declval()), void())> : types::true_type { }; + + template + struct should_stringify_as_underlying_type { + static DOCTEST_CONSTEXPR bool value = detail::types::is_enum::value && !doctest::detail::has_insertion_operator::value; + }; + + DOCTEST_INTERFACE std::ostream* tlssPush(); + DOCTEST_INTERFACE String tlssPop(); + + template + struct StringMakerBase { + template + static String convert(const DOCTEST_REF_WRAP(T)) { +#ifdef DOCTEST_CONFIG_REQUIRE_STRINGIFICATION_FOR_ALL_USED_TYPES + static_assert(deferred_false::value, "No stringification detected for type T. See string conversion manual"); +#endif + return "{?}"; + } + }; + + template + struct filldata; + + template + void filloss(std::ostream* stream, const T& in) { + filldata::fill(stream, in); + } + + template + void filloss(std::ostream* stream, const T (&in)[N]) { // NOLINT(*-avoid-c-arrays) + // T[N], T(&)[N], T(&&)[N] have same behaviour. + // Hence remove reference. + filloss::type>(stream, in); + } + + template + String toStream(const T& in) { + std::ostream* stream = tlssPush(); + filloss(stream, in); + return tlssPop(); + } + + template <> + struct StringMakerBase { + template + static String convert(const DOCTEST_REF_WRAP(T) in) { + return toStream(in); + } + }; +} // namespace detail + +template +struct StringMaker : public detail::StringMakerBase< + detail::has_insertion_operator::value || detail::types::is_pointer::value || detail::types::is_array::value> +{}; + +#ifndef DOCTEST_STRINGIFY +#ifdef DOCTEST_CONFIG_DOUBLE_STRINGIFY +#define DOCTEST_STRINGIFY(...) toString(toString(__VA_ARGS__)) +#else +#define DOCTEST_STRINGIFY(...) toString(__VA_ARGS__) +#endif +#endif + +template +String toString() { +#if DOCTEST_CLANG == 0 && DOCTEST_GCC == 0 && DOCTEST_ICC == 0 + String ret = __FUNCSIG__; // class doctest::String __cdecl doctest::toString(void) + String::size_type beginPos = ret.find('<'); + return ret.substr(beginPos + 1, ret.size() - beginPos - static_cast(sizeof(">(void)"))); +#else + String ret = __PRETTY_FUNCTION__; // doctest::String toString() [with T = TYPE] + String::size_type begin = ret.find('=') + 2; + return ret.substr(begin, ret.size() - begin - 1); +#endif +} + +template ::value, bool>::type = true> +String toString(const DOCTEST_REF_WRAP(T) value) { + return StringMaker::convert(value); +} + +#ifdef DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING +DOCTEST_INTERFACE String toString(const char* in); +#endif // DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING + +#if DOCTEST_MSVC >= DOCTEST_COMPILER(19, 20, 0) +// see this issue on why this is needed: https://github.com/doctest/doctest/issues/183 +DOCTEST_INTERFACE String toString(const std::string& in); +#endif // VS 2019 + +DOCTEST_INTERFACE String toString(String in); + +DOCTEST_INTERFACE String toString(std::nullptr_t); + +DOCTEST_INTERFACE String toString(bool in); + +DOCTEST_INTERFACE String toString(float in); +DOCTEST_INTERFACE String toString(double in); +DOCTEST_INTERFACE String toString(double long in); + +DOCTEST_INTERFACE String toString(char in); +DOCTEST_INTERFACE String toString(char signed in); +DOCTEST_INTERFACE String toString(char unsigned in); +DOCTEST_INTERFACE String toString(short in); +DOCTEST_INTERFACE String toString(short unsigned in); +DOCTEST_INTERFACE String toString(signed in); +DOCTEST_INTERFACE String toString(unsigned in); +DOCTEST_INTERFACE String toString(long in); +DOCTEST_INTERFACE String toString(long unsigned in); +DOCTEST_INTERFACE String toString(long long in); +DOCTEST_INTERFACE String toString(long long unsigned in); + +template ::value, bool>::type = true> +String toString(const DOCTEST_REF_WRAP(T) value) { + using UT = typename detail::types::underlying_type::type; + return (DOCTEST_STRINGIFY(static_cast(value))); +} + +namespace detail { + template + struct filldata + { + static void fill(std::ostream* stream, const T& in) { +#if defined(_MSC_VER) && _MSC_VER <= 1900 + insert_hack_t::insert(*stream, in); +#else + operator<<(*stream, in); +#endif + } + }; + +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4866) +// NOLINTBEGIN(*-avoid-c-arrays) + template + struct filldata { + static void fill(std::ostream* stream, const T(&in)[N]) { + *stream << "["; + for (size_t i = 0; i < N; i++) { + if (i != 0) { *stream << ", "; } + *stream << (DOCTEST_STRINGIFY(in[i])); + } + *stream << "]"; + } + }; +// NOLINTEND(*-avoid-c-arrays) +DOCTEST_MSVC_SUPPRESS_WARNING_POP + + // Specialized since we don't want the terminating null byte! +// NOLINTBEGIN(*-avoid-c-arrays) + template + struct filldata { + static void fill(std::ostream* stream, const char (&in)[N]) { + *stream << String(in, in[N - 1] ? N : N - 1); + } // NOLINT(clang-analyzer-cplusplus.NewDeleteLeaks) + }; +// NOLINTEND(*-avoid-c-arrays) + + template <> + struct filldata { + static void fill(std::ostream* stream, const void* in); + }; + + template + struct filldata { +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4180) + static void fill(std::ostream* stream, const T* in) { +DOCTEST_MSVC_SUPPRESS_WARNING_POP +DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wmicrosoft-cast") + filldata::fill(stream, +#if DOCTEST_GCC == 0 || DOCTEST_GCC >= DOCTEST_COMPILER(4, 9, 0) + reinterpret_cast(in) +#else + *reinterpret_cast(&in) +#endif + ); +DOCTEST_CLANG_SUPPRESS_WARNING_POP + } + }; +} + +struct DOCTEST_INTERFACE Approx +{ + Approx(double value); + + Approx operator()(double value) const; + +#ifdef DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + template + explicit Approx(const T& value, + typename detail::types::enable_if::value>::type* = + static_cast(nullptr)) { + *this = static_cast(value); + } +#endif // DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + + Approx& epsilon(double newEpsilon); + +#ifdef DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + template + typename std::enable_if::value, Approx&>::type epsilon( + const T& newEpsilon) { + m_epsilon = static_cast(newEpsilon); + return *this; + } +#endif // DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + + Approx& scale(double newScale); + +#ifdef DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + template + typename std::enable_if::value, Approx&>::type scale( + const T& newScale) { + m_scale = static_cast(newScale); + return *this; + } +#endif // DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + + // clang-format off + DOCTEST_INTERFACE friend bool operator==(double lhs, const Approx & rhs); + DOCTEST_INTERFACE friend bool operator==(const Approx & lhs, double rhs); + DOCTEST_INTERFACE friend bool operator!=(double lhs, const Approx & rhs); + DOCTEST_INTERFACE friend bool operator!=(const Approx & lhs, double rhs); + DOCTEST_INTERFACE friend bool operator<=(double lhs, const Approx & rhs); + DOCTEST_INTERFACE friend bool operator<=(const Approx & lhs, double rhs); + DOCTEST_INTERFACE friend bool operator>=(double lhs, const Approx & rhs); + DOCTEST_INTERFACE friend bool operator>=(const Approx & lhs, double rhs); + DOCTEST_INTERFACE friend bool operator< (double lhs, const Approx & rhs); + DOCTEST_INTERFACE friend bool operator< (const Approx & lhs, double rhs); + DOCTEST_INTERFACE friend bool operator> (double lhs, const Approx & rhs); + DOCTEST_INTERFACE friend bool operator> (const Approx & lhs, double rhs); + +#ifdef DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS +#define DOCTEST_APPROX_PREFIX \ + template friend typename std::enable_if::value, bool>::type + + DOCTEST_APPROX_PREFIX operator==(const T& lhs, const Approx& rhs) { return operator==(static_cast(lhs), rhs); } + DOCTEST_APPROX_PREFIX operator==(const Approx& lhs, const T& rhs) { return operator==(rhs, lhs); } + DOCTEST_APPROX_PREFIX operator!=(const T& lhs, const Approx& rhs) { return !operator==(lhs, rhs); } + DOCTEST_APPROX_PREFIX operator!=(const Approx& lhs, const T& rhs) { return !operator==(rhs, lhs); } + DOCTEST_APPROX_PREFIX operator<=(const T& lhs, const Approx& rhs) { return static_cast(lhs) < rhs.m_value || lhs == rhs; } + DOCTEST_APPROX_PREFIX operator<=(const Approx& lhs, const T& rhs) { return lhs.m_value < static_cast(rhs) || lhs == rhs; } + DOCTEST_APPROX_PREFIX operator>=(const T& lhs, const Approx& rhs) { return static_cast(lhs) > rhs.m_value || lhs == rhs; } + DOCTEST_APPROX_PREFIX operator>=(const Approx& lhs, const T& rhs) { return lhs.m_value > static_cast(rhs) || lhs == rhs; } + DOCTEST_APPROX_PREFIX operator< (const T& lhs, const Approx& rhs) { return static_cast(lhs) < rhs.m_value && lhs != rhs; } + DOCTEST_APPROX_PREFIX operator< (const Approx& lhs, const T& rhs) { return lhs.m_value < static_cast(rhs) && lhs != rhs; } + DOCTEST_APPROX_PREFIX operator> (const T& lhs, const Approx& rhs) { return static_cast(lhs) > rhs.m_value && lhs != rhs; } + DOCTEST_APPROX_PREFIX operator> (const Approx& lhs, const T& rhs) { return lhs.m_value > static_cast(rhs) && lhs != rhs; } +#undef DOCTEST_APPROX_PREFIX +#endif // DOCTEST_CONFIG_INCLUDE_TYPE_TRAITS + + // clang-format on + + double m_epsilon; + double m_scale; + double m_value; +}; + +DOCTEST_INTERFACE String toString(const Approx& in); + +DOCTEST_INTERFACE const ContextOptions* getContextOptions(); + +template +struct DOCTEST_INTERFACE_DECL IsNaN +{ + F value; bool flipped; + IsNaN(F f, bool flip = false) : value(f), flipped(flip) { } + IsNaN operator!() const { return { value, !flipped }; } + operator bool() const; +}; +#ifndef __MINGW32__ +extern template struct DOCTEST_INTERFACE_DECL IsNaN; +extern template struct DOCTEST_INTERFACE_DECL IsNaN; +extern template struct DOCTEST_INTERFACE_DECL IsNaN; +#endif +DOCTEST_INTERFACE String toString(IsNaN in); +DOCTEST_INTERFACE String toString(IsNaN in); +DOCTEST_INTERFACE String toString(IsNaN in); + +#ifndef DOCTEST_CONFIG_DISABLE + +namespace detail { + // clang-format off +#ifdef DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING + template struct decay_array { using type = T; }; + template struct decay_array { using type = T*; }; + template struct decay_array { using type = T*; }; + + template struct not_char_pointer { static DOCTEST_CONSTEXPR int value = 1; }; + template<> struct not_char_pointer { static DOCTEST_CONSTEXPR int value = 0; }; + template<> struct not_char_pointer { static DOCTEST_CONSTEXPR int value = 0; }; + + template struct can_use_op : public not_char_pointer::type> {}; +#endif // DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING + // clang-format on + + struct DOCTEST_INTERFACE TestFailureException + { + }; + + DOCTEST_INTERFACE bool checkIfShouldThrow(assertType::Enum at); + +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + DOCTEST_NORETURN +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + DOCTEST_INTERFACE void throwException(); + + struct DOCTEST_INTERFACE Subcase + { + SubcaseSignature m_signature; + bool m_entered = false; + + Subcase(const String& name, const char* file, int line); + Subcase(const Subcase&) = delete; + Subcase(Subcase&&) = delete; + Subcase& operator=(const Subcase&) = delete; + Subcase& operator=(Subcase&&) = delete; + ~Subcase(); + + operator bool() const; + + private: + bool checkFilters(); + }; + + template + String stringifyBinaryExpr(const DOCTEST_REF_WRAP(L) lhs, const char* op, + const DOCTEST_REF_WRAP(R) rhs) { + return (DOCTEST_STRINGIFY(lhs)) + op + (DOCTEST_STRINGIFY(rhs)); + } + +#if DOCTEST_CLANG && DOCTEST_CLANG < DOCTEST_COMPILER(3, 6, 0) +DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wunused-comparison") +#endif + +// This will check if there is any way it could find a operator like member or friend and uses it. +// If not it doesn't find the operator or if the operator at global scope is defined after +// this template, the template won't be instantiated due to SFINAE. Once the template is not +// instantiated it can look for global operator using normal conversions. +#ifdef __NVCC__ +#define SFINAE_OP(ret,op) ret +#else +#define SFINAE_OP(ret,op) decltype((void)(doctest::detail::declval() op doctest::detail::declval()),ret{}) +#endif + +#define DOCTEST_DO_BINARY_EXPRESSION_COMPARISON(op, op_str, op_macro) \ + template \ + DOCTEST_NOINLINE SFINAE_OP(Result,op) operator op(R&& rhs) { \ + bool res = op_macro(doctest::detail::forward(lhs), doctest::detail::forward(rhs)); \ + if(m_at & assertType::is_false) \ + res = !res; \ + if(!res || doctest::getContextOptions()->success) \ + return Result(res, stringifyBinaryExpr(lhs, op_str, rhs)); \ + return Result(res); \ + } + + // more checks could be added - like in Catch: + // https://github.com/catchorg/Catch2/pull/1480/files + // https://github.com/catchorg/Catch2/pull/1481/files +#define DOCTEST_FORBIT_EXPRESSION(rt, op) \ + template \ + rt& operator op(const R&) { \ + static_assert(deferred_false::value, \ + "Expression Too Complex Please Rewrite As Binary Comparison!"); \ + return *this; \ + } + + struct DOCTEST_INTERFACE Result // NOLINT(*-member-init) + { + bool m_passed; + String m_decomp; + + Result() = default; // TODO: Why do we need this? (To remove NOLINT) + Result(bool passed, const String& decomposition = String()); + + // forbidding some expressions based on this table: https://en.cppreference.com/w/cpp/language/operator_precedence + DOCTEST_FORBIT_EXPRESSION(Result, &) + DOCTEST_FORBIT_EXPRESSION(Result, ^) + DOCTEST_FORBIT_EXPRESSION(Result, |) + DOCTEST_FORBIT_EXPRESSION(Result, &&) + DOCTEST_FORBIT_EXPRESSION(Result, ||) + DOCTEST_FORBIT_EXPRESSION(Result, ==) + DOCTEST_FORBIT_EXPRESSION(Result, !=) + DOCTEST_FORBIT_EXPRESSION(Result, <) + DOCTEST_FORBIT_EXPRESSION(Result, >) + DOCTEST_FORBIT_EXPRESSION(Result, <=) + DOCTEST_FORBIT_EXPRESSION(Result, >=) + DOCTEST_FORBIT_EXPRESSION(Result, =) + DOCTEST_FORBIT_EXPRESSION(Result, +=) + DOCTEST_FORBIT_EXPRESSION(Result, -=) + DOCTEST_FORBIT_EXPRESSION(Result, *=) + DOCTEST_FORBIT_EXPRESSION(Result, /=) + DOCTEST_FORBIT_EXPRESSION(Result, %=) + DOCTEST_FORBIT_EXPRESSION(Result, <<=) + DOCTEST_FORBIT_EXPRESSION(Result, >>=) + DOCTEST_FORBIT_EXPRESSION(Result, &=) + DOCTEST_FORBIT_EXPRESSION(Result, ^=) + DOCTEST_FORBIT_EXPRESSION(Result, |=) + }; + +#ifndef DOCTEST_CONFIG_NO_COMPARISON_WARNING_SUPPRESSION + + DOCTEST_CLANG_SUPPRESS_WARNING_PUSH + DOCTEST_CLANG_SUPPRESS_WARNING("-Wsign-conversion") + DOCTEST_CLANG_SUPPRESS_WARNING("-Wsign-compare") + //DOCTEST_CLANG_SUPPRESS_WARNING("-Wdouble-promotion") + //DOCTEST_CLANG_SUPPRESS_WARNING("-Wconversion") + //DOCTEST_CLANG_SUPPRESS_WARNING("-Wfloat-equal") + + DOCTEST_GCC_SUPPRESS_WARNING_PUSH + DOCTEST_GCC_SUPPRESS_WARNING("-Wsign-conversion") + DOCTEST_GCC_SUPPRESS_WARNING("-Wsign-compare") + //DOCTEST_GCC_SUPPRESS_WARNING("-Wdouble-promotion") + //DOCTEST_GCC_SUPPRESS_WARNING("-Wconversion") + //DOCTEST_GCC_SUPPRESS_WARNING("-Wfloat-equal") + + DOCTEST_MSVC_SUPPRESS_WARNING_PUSH + // https://stackoverflow.com/questions/39479163 what's the difference between 4018 and 4389 + DOCTEST_MSVC_SUPPRESS_WARNING(4388) // signed/unsigned mismatch + DOCTEST_MSVC_SUPPRESS_WARNING(4389) // 'operator' : signed/unsigned mismatch + DOCTEST_MSVC_SUPPRESS_WARNING(4018) // 'expression' : signed/unsigned mismatch + //DOCTEST_MSVC_SUPPRESS_WARNING(4805) // 'operation' : unsafe mix of type 'type' and type 'type' in operation + +#endif // DOCTEST_CONFIG_NO_COMPARISON_WARNING_SUPPRESSION + + // clang-format off +#ifndef DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING +#define DOCTEST_COMPARISON_RETURN_TYPE bool +#else // DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING +#define DOCTEST_COMPARISON_RETURN_TYPE typename types::enable_if::value || can_use_op::value, bool>::type + inline bool eq(const char* lhs, const char* rhs) { return String(lhs) == String(rhs); } + inline bool ne(const char* lhs, const char* rhs) { return String(lhs) != String(rhs); } + inline bool lt(const char* lhs, const char* rhs) { return String(lhs) < String(rhs); } + inline bool gt(const char* lhs, const char* rhs) { return String(lhs) > String(rhs); } + inline bool le(const char* lhs, const char* rhs) { return String(lhs) <= String(rhs); } + inline bool ge(const char* lhs, const char* rhs) { return String(lhs) >= String(rhs); } +#endif // DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING + // clang-format on + +#define DOCTEST_RELATIONAL_OP(name, op) \ + template \ + DOCTEST_COMPARISON_RETURN_TYPE name(const DOCTEST_REF_WRAP(L) lhs, \ + const DOCTEST_REF_WRAP(R) rhs) { \ + return lhs op rhs; \ + } + + DOCTEST_RELATIONAL_OP(eq, ==) + DOCTEST_RELATIONAL_OP(ne, !=) + DOCTEST_RELATIONAL_OP(lt, <) + DOCTEST_RELATIONAL_OP(gt, >) + DOCTEST_RELATIONAL_OP(le, <=) + DOCTEST_RELATIONAL_OP(ge, >=) + +#ifndef DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING +#define DOCTEST_CMP_EQ(l, r) l == r +#define DOCTEST_CMP_NE(l, r) l != r +#define DOCTEST_CMP_GT(l, r) l > r +#define DOCTEST_CMP_LT(l, r) l < r +#define DOCTEST_CMP_GE(l, r) l >= r +#define DOCTEST_CMP_LE(l, r) l <= r +#else // DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING +#define DOCTEST_CMP_EQ(l, r) eq(l, r) +#define DOCTEST_CMP_NE(l, r) ne(l, r) +#define DOCTEST_CMP_GT(l, r) gt(l, r) +#define DOCTEST_CMP_LT(l, r) lt(l, r) +#define DOCTEST_CMP_GE(l, r) ge(l, r) +#define DOCTEST_CMP_LE(l, r) le(l, r) +#endif // DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING + + template + // cppcheck-suppress copyCtorAndEqOperator + struct Expression_lhs + { + L lhs; + assertType::Enum m_at; + + explicit Expression_lhs(L&& in, assertType::Enum at) + : lhs(static_cast(in)) + , m_at(at) {} + + DOCTEST_NOINLINE operator Result() { +// this is needed only for MSVC 2015 +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4800) // 'int': forcing value to bool + bool res = static_cast(lhs); +DOCTEST_MSVC_SUPPRESS_WARNING_POP + if(m_at & assertType::is_false) { //!OCLINT bitwise operator in conditional + res = !res; + } + + if(!res || getContextOptions()->success) { + return { res, (DOCTEST_STRINGIFY(lhs)) }; + } + return { res }; + } + + /* This is required for user-defined conversions from Expression_lhs to L */ + operator L() const { return lhs; } + + // clang-format off + DOCTEST_DO_BINARY_EXPRESSION_COMPARISON(==, " == ", DOCTEST_CMP_EQ) //!OCLINT bitwise operator in conditional + DOCTEST_DO_BINARY_EXPRESSION_COMPARISON(!=, " != ", DOCTEST_CMP_NE) //!OCLINT bitwise operator in conditional + DOCTEST_DO_BINARY_EXPRESSION_COMPARISON(>, " > ", DOCTEST_CMP_GT) //!OCLINT bitwise operator in conditional + DOCTEST_DO_BINARY_EXPRESSION_COMPARISON(<, " < ", DOCTEST_CMP_LT) //!OCLINT bitwise operator in conditional + DOCTEST_DO_BINARY_EXPRESSION_COMPARISON(>=, " >= ", DOCTEST_CMP_GE) //!OCLINT bitwise operator in conditional + DOCTEST_DO_BINARY_EXPRESSION_COMPARISON(<=, " <= ", DOCTEST_CMP_LE) //!OCLINT bitwise operator in conditional + // clang-format on + + // forbidding some expressions based on this table: https://en.cppreference.com/w/cpp/language/operator_precedence + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, &) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, ^) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, |) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, &&) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, ||) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, =) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, +=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, -=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, *=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, /=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, %=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, <<=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, >>=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, &=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, ^=) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, |=) + // these 2 are unfortunate because they should be allowed - they have higher precedence over the comparisons, but the + // ExpressionDecomposer class uses the left shift operator to capture the left operand of the binary expression... + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, <<) + DOCTEST_FORBIT_EXPRESSION(Expression_lhs, >>) + }; + +#ifndef DOCTEST_CONFIG_NO_COMPARISON_WARNING_SUPPRESSION + + DOCTEST_CLANG_SUPPRESS_WARNING_POP + DOCTEST_MSVC_SUPPRESS_WARNING_POP + DOCTEST_GCC_SUPPRESS_WARNING_POP + +#endif // DOCTEST_CONFIG_NO_COMPARISON_WARNING_SUPPRESSION + +#if DOCTEST_CLANG && DOCTEST_CLANG < DOCTEST_COMPILER(3, 6, 0) +DOCTEST_CLANG_SUPPRESS_WARNING_POP +#endif + + struct DOCTEST_INTERFACE ExpressionDecomposer + { + assertType::Enum m_at; + + ExpressionDecomposer(assertType::Enum at); + + // The right operator for capturing expressions is "<=" instead of "<<" (based on the operator precedence table) + // but then there will be warnings from GCC about "-Wparentheses" and since "_Pragma()" is problematic this will stay for now... + // https://github.com/catchorg/Catch2/issues/870 + // https://github.com/catchorg/Catch2/issues/565 + template + Expression_lhs operator<<(const L&& operand) { //bitfields bind to universal ref but not const rvalue ref + return Expression_lhs(static_cast(operand), m_at); + } + + template ::value,void >::type* = nullptr> + Expression_lhs operator<<(const L &operand) { + return Expression_lhs(operand, m_at); + } + }; + + struct DOCTEST_INTERFACE TestSuite + { + const char* m_test_suite = nullptr; + const char* m_description = nullptr; + bool m_skip = false; + bool m_no_breaks = false; + bool m_no_output = false; + bool m_may_fail = false; + bool m_should_fail = false; + int m_expected_failures = 0; + double m_timeout = 0; + + TestSuite& operator*(const char* in); + + template + TestSuite& operator*(const T& in) { + in.fill(*this); + return *this; + } + }; + + using funcType = void (*)(); + + struct DOCTEST_INTERFACE TestCase : public TestCaseData + { + funcType m_test; // a function pointer to the test case + + String m_type; // for templated test cases - gets appended to the real name + int m_template_id; // an ID used to distinguish between the different versions of a templated test case + String m_full_name; // contains the name (only for templated test cases!) + the template type + + TestCase(funcType test, const char* file, unsigned line, const TestSuite& test_suite, + const String& type = String(), int template_id = -1); + + TestCase(const TestCase& other); + TestCase(TestCase&&) = delete; + + DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(26434) // hides a non-virtual function + TestCase& operator=(const TestCase& other); + DOCTEST_MSVC_SUPPRESS_WARNING_POP + + TestCase& operator=(TestCase&&) = delete; + + TestCase& operator*(const char* in); + + template + TestCase& operator*(const T& in) { + in.fill(*this); + return *this; + } + + bool operator<(const TestCase& other) const; + + ~TestCase() = default; + }; + + // forward declarations of functions used by the macros + DOCTEST_INTERFACE int regTest(const TestCase& tc); + DOCTEST_INTERFACE int setTestSuite(const TestSuite& ts); + DOCTEST_INTERFACE bool isDebuggerActive(); + + template + int instantiationHelper(const T&) { return 0; } + + namespace binaryAssertComparison { + enum Enum + { + eq = 0, + ne, + gt, + lt, + ge, + le + }; + } // namespace binaryAssertComparison + + // clang-format off + template struct RelationalComparator { bool operator()(const DOCTEST_REF_WRAP(L), const DOCTEST_REF_WRAP(R) ) const { return false; } }; + +#define DOCTEST_BINARY_RELATIONAL_OP(n, op) \ + template struct RelationalComparator { bool operator()(const DOCTEST_REF_WRAP(L) lhs, const DOCTEST_REF_WRAP(R) rhs) const { return op(lhs, rhs); } }; + // clang-format on + + DOCTEST_BINARY_RELATIONAL_OP(0, doctest::detail::eq) + DOCTEST_BINARY_RELATIONAL_OP(1, doctest::detail::ne) + DOCTEST_BINARY_RELATIONAL_OP(2, doctest::detail::gt) + DOCTEST_BINARY_RELATIONAL_OP(3, doctest::detail::lt) + DOCTEST_BINARY_RELATIONAL_OP(4, doctest::detail::ge) + DOCTEST_BINARY_RELATIONAL_OP(5, doctest::detail::le) + + struct DOCTEST_INTERFACE ResultBuilder : public AssertData + { + ResultBuilder(assertType::Enum at, const char* file, int line, const char* expr, + const char* exception_type = "", const String& exception_string = ""); + + ResultBuilder(assertType::Enum at, const char* file, int line, const char* expr, + const char* exception_type, const Contains& exception_string); + + void setResult(const Result& res); + + template + DOCTEST_NOINLINE bool binary_assert(const DOCTEST_REF_WRAP(L) lhs, + const DOCTEST_REF_WRAP(R) rhs) { + m_failed = !RelationalComparator()(lhs, rhs); + if (m_failed || getContextOptions()->success) { + m_decomp = stringifyBinaryExpr(lhs, ", ", rhs); + } + return !m_failed; + } + + template + DOCTEST_NOINLINE bool unary_assert(const DOCTEST_REF_WRAP(L) val) { + m_failed = !val; + + if (m_at & assertType::is_false) { //!OCLINT bitwise operator in conditional + m_failed = !m_failed; + } + + if (m_failed || getContextOptions()->success) { + m_decomp = (DOCTEST_STRINGIFY(val)); + } + + return !m_failed; + } + + void translateException(); + + bool log(); + void react() const; + }; + + namespace assertAction { + enum Enum + { + nothing = 0, + dbgbreak = 1, + shouldthrow = 2 + }; + } // namespace assertAction + + DOCTEST_INTERFACE void failed_out_of_a_testing_context(const AssertData& ad); + + DOCTEST_INTERFACE bool decomp_assert(assertType::Enum at, const char* file, int line, + const char* expr, const Result& result); + +#define DOCTEST_ASSERT_OUT_OF_TESTS(decomp) \ + do { \ + if(!is_running_in_test) { \ + if(failed) { \ + ResultBuilder rb(at, file, line, expr); \ + rb.m_failed = failed; \ + rb.m_decomp = decomp; \ + failed_out_of_a_testing_context(rb); \ + if(isDebuggerActive() && !getContextOptions()->no_breaks) \ + DOCTEST_BREAK_INTO_DEBUGGER(); \ + if(checkIfShouldThrow(at)) \ + throwException(); \ + } \ + return !failed; \ + } \ + } while(false) + +#define DOCTEST_ASSERT_IN_TESTS(decomp) \ + ResultBuilder rb(at, file, line, expr); \ + rb.m_failed = failed; \ + if(rb.m_failed || getContextOptions()->success) \ + rb.m_decomp = decomp; \ + if(rb.log()) \ + DOCTEST_BREAK_INTO_DEBUGGER(); \ + if(rb.m_failed && checkIfShouldThrow(at)) \ + throwException() + + template + DOCTEST_NOINLINE bool binary_assert(assertType::Enum at, const char* file, int line, + const char* expr, const DOCTEST_REF_WRAP(L) lhs, + const DOCTEST_REF_WRAP(R) rhs) { + bool failed = !RelationalComparator()(lhs, rhs); + + // ################################################################################### + // IF THE DEBUGGER BREAKS HERE - GO 1 LEVEL UP IN THE CALLSTACK FOR THE FAILING ASSERT + // THIS IS THE EFFECT OF HAVING 'DOCTEST_CONFIG_SUPER_FAST_ASSERTS' DEFINED + // ################################################################################### + DOCTEST_ASSERT_OUT_OF_TESTS(stringifyBinaryExpr(lhs, ", ", rhs)); + DOCTEST_ASSERT_IN_TESTS(stringifyBinaryExpr(lhs, ", ", rhs)); + return !failed; + } + + template + DOCTEST_NOINLINE bool unary_assert(assertType::Enum at, const char* file, int line, + const char* expr, const DOCTEST_REF_WRAP(L) val) { + bool failed = !val; + + if(at & assertType::is_false) //!OCLINT bitwise operator in conditional + failed = !failed; + + // ################################################################################### + // IF THE DEBUGGER BREAKS HERE - GO 1 LEVEL UP IN THE CALLSTACK FOR THE FAILING ASSERT + // THIS IS THE EFFECT OF HAVING 'DOCTEST_CONFIG_SUPER_FAST_ASSERTS' DEFINED + // ################################################################################### + DOCTEST_ASSERT_OUT_OF_TESTS((DOCTEST_STRINGIFY(val))); + DOCTEST_ASSERT_IN_TESTS((DOCTEST_STRINGIFY(val))); + return !failed; + } + + struct DOCTEST_INTERFACE IExceptionTranslator + { + DOCTEST_DECLARE_INTERFACE(IExceptionTranslator) + virtual bool translate(String&) const = 0; + }; + + template + class ExceptionTranslator : public IExceptionTranslator //!OCLINT destructor of virtual class + { + public: + explicit ExceptionTranslator(String (*translateFunction)(T)) + : m_translateFunction(translateFunction) {} + + bool translate(String& res) const override { +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + try { + throw; // lgtm [cpp/rethrow-no-exception] + // cppcheck-suppress catchExceptionByValue + } catch(const T& ex) { + res = m_translateFunction(ex); //!OCLINT parameter reassignment + return true; + } catch(...) {} //!OCLINT - empty catch statement +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + static_cast(res); // to silence -Wunused-parameter + return false; + } + + private: + String (*m_translateFunction)(T); + }; + + DOCTEST_INTERFACE void registerExceptionTranslatorImpl(const IExceptionTranslator* et); + + // ContextScope base class used to allow implementing methods of ContextScope + // that don't depend on the template parameter in doctest.cpp. + struct DOCTEST_INTERFACE ContextScopeBase : public IContextScope { + ContextScopeBase(const ContextScopeBase&) = delete; + + ContextScopeBase& operator=(const ContextScopeBase&) = delete; + ContextScopeBase& operator=(ContextScopeBase&&) = delete; + + ~ContextScopeBase() override = default; + + protected: + ContextScopeBase(); + ContextScopeBase(ContextScopeBase&& other) noexcept; + + void destroy(); + bool need_to_destroy{true}; + }; + + template class ContextScope : public ContextScopeBase + { + L lambda_; + + public: + explicit ContextScope(const L &lambda) : lambda_(lambda) {} + explicit ContextScope(L&& lambda) : lambda_(static_cast(lambda)) { } + + ContextScope(const ContextScope&) = delete; + ContextScope(ContextScope&&) noexcept = default; + + ContextScope& operator=(const ContextScope&) = delete; + ContextScope& operator=(ContextScope&&) = delete; + + void stringify(std::ostream* s) const override { lambda_(s); } + + ~ContextScope() override { + if (need_to_destroy) { + destroy(); + } + } + }; + + struct DOCTEST_INTERFACE MessageBuilder : public MessageData + { + std::ostream* m_stream; + bool logged = false; + + MessageBuilder(const char* file, int line, assertType::Enum severity); + + MessageBuilder(const MessageBuilder&) = delete; + MessageBuilder(MessageBuilder&&) = delete; + + MessageBuilder& operator=(const MessageBuilder&) = delete; + MessageBuilder& operator=(MessageBuilder&&) = delete; + + ~MessageBuilder(); + + // the preferred way of chaining parameters for stringification +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4866) + template + MessageBuilder& operator,(const T& in) { + *m_stream << (DOCTEST_STRINGIFY(in)); + return *this; + } +DOCTEST_MSVC_SUPPRESS_WARNING_POP + + // kept here just for backwards-compatibility - the comma operator should be preferred now + template + MessageBuilder& operator<<(const T& in) { return this->operator,(in); } + + // the `,` operator has the lowest operator precedence - if `<<` is used by the user then + // the `,` operator will be called last which is not what we want and thus the `*` operator + // is used first (has higher operator precedence compared to `<<`) so that we guarantee that + // an operator of the MessageBuilder class is called first before the rest of the parameters + template + MessageBuilder& operator*(const T& in) { return this->operator,(in); } + + bool log(); + void react(); + }; + + template + ContextScope MakeContextScope(const L &lambda) { + return ContextScope(lambda); + } +} // namespace detail + +#define DOCTEST_DEFINE_DECORATOR(name, type, def) \ + struct name \ + { \ + type data; \ + name(type in = def) \ + : data(in) {} \ + void fill(detail::TestCase& state) const { state.DOCTEST_CAT(m_, name) = data; } \ + void fill(detail::TestSuite& state) const { state.DOCTEST_CAT(m_, name) = data; } \ + } + +DOCTEST_DEFINE_DECORATOR(test_suite, const char*, ""); +DOCTEST_DEFINE_DECORATOR(description, const char*, ""); +DOCTEST_DEFINE_DECORATOR(skip, bool, true); +DOCTEST_DEFINE_DECORATOR(no_breaks, bool, true); +DOCTEST_DEFINE_DECORATOR(no_output, bool, true); +DOCTEST_DEFINE_DECORATOR(timeout, double, 0); +DOCTEST_DEFINE_DECORATOR(may_fail, bool, true); +DOCTEST_DEFINE_DECORATOR(should_fail, bool, true); +DOCTEST_DEFINE_DECORATOR(expected_failures, int, 0); + +template +int registerExceptionTranslator(String (*translateFunction)(T)) { + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wexit-time-destructors") + static detail::ExceptionTranslator exceptionTranslator(translateFunction); + DOCTEST_CLANG_SUPPRESS_WARNING_POP + detail::registerExceptionTranslatorImpl(&exceptionTranslator); + return 0; +} + +} // namespace doctest + +// in a separate namespace outside of doctest because the DOCTEST_TEST_SUITE macro +// introduces an anonymous namespace in which getCurrentTestSuite gets overridden +namespace doctest_detail_test_suite_ns { +DOCTEST_INTERFACE doctest::detail::TestSuite& getCurrentTestSuite(); +} // namespace doctest_detail_test_suite_ns + +namespace doctest { +#else // DOCTEST_CONFIG_DISABLE +template +int registerExceptionTranslator(String (*)(T)) { + return 0; +} +#endif // DOCTEST_CONFIG_DISABLE + +namespace detail { + using assert_handler = void (*)(const AssertData&); + struct ContextState; +} // namespace detail + +class DOCTEST_INTERFACE Context +{ + detail::ContextState* p; + + void parseArgs(int argc, const char* const* argv, bool withDefaults = false); + +public: + explicit Context(int argc = 0, const char* const* argv = nullptr); + + Context(const Context&) = delete; + Context(Context&&) = delete; + + Context& operator=(const Context&) = delete; + Context& operator=(Context&&) = delete; + + ~Context(); // NOLINT(performance-trivially-destructible) + + void applyCommandLine(int argc, const char* const* argv); + + void addFilter(const char* filter, const char* value); + void clearFilters(); + void setOption(const char* option, bool value); + void setOption(const char* option, int value); + void setOption(const char* option, const char* value); + + bool shouldExit(); + + void setAsDefaultForAssertsOutOfTestCases(); + + void setAssertHandler(detail::assert_handler ah); + + void setCout(std::ostream* out); + + int run(); +}; + +namespace TestCaseFailureReason { + enum Enum + { + None = 0, + AssertFailure = 1, // an assertion has failed in the test case + Exception = 2, // test case threw an exception + Crash = 4, // a crash... + TooManyFailedAsserts = 8, // the abort-after option + Timeout = 16, // see the timeout decorator + ShouldHaveFailedButDidnt = 32, // see the should_fail decorator + ShouldHaveFailedAndDid = 64, // see the should_fail decorator + DidntFailExactlyNumTimes = 128, // see the expected_failures decorator + FailedExactlyNumTimes = 256, // see the expected_failures decorator + CouldHaveFailedAndDid = 512 // see the may_fail decorator + }; +} // namespace TestCaseFailureReason + +struct DOCTEST_INTERFACE CurrentTestCaseStats +{ + int numAssertsCurrentTest; + int numAssertsFailedCurrentTest; + double seconds; + int failure_flags; // use TestCaseFailureReason::Enum + bool testCaseSuccess; +}; + +struct DOCTEST_INTERFACE TestCaseException +{ + String error_string; + bool is_crash; +}; + +struct DOCTEST_INTERFACE TestRunStats +{ + unsigned numTestCases; + unsigned numTestCasesPassingFilters; + unsigned numTestSuitesPassingFilters; + unsigned numTestCasesFailed; + int numAsserts; + int numAssertsFailed; +}; + +struct QueryData +{ + const TestRunStats* run_stats = nullptr; + const TestCaseData** data = nullptr; + unsigned num_data = 0; +}; + +struct DOCTEST_INTERFACE IReporter +{ + // The constructor has to accept "const ContextOptions&" as a single argument + // which has most of the options for the run + a pointer to the stdout stream + // Reporter(const ContextOptions& in) + + // called when a query should be reported (listing test cases, printing the version, etc.) + virtual void report_query(const QueryData&) = 0; + + // called when the whole test run starts + virtual void test_run_start() = 0; + // called when the whole test run ends (caching a pointer to the input doesn't make sense here) + virtual void test_run_end(const TestRunStats&) = 0; + + // called when a test case is started (safe to cache a pointer to the input) + virtual void test_case_start(const TestCaseData&) = 0; + // called when a test case is reentered because of unfinished subcases (safe to cache a pointer to the input) + virtual void test_case_reenter(const TestCaseData&) = 0; + // called when a test case has ended + virtual void test_case_end(const CurrentTestCaseStats&) = 0; + + // called when an exception is thrown from the test case (or it crashes) + virtual void test_case_exception(const TestCaseException&) = 0; + + // called whenever a subcase is entered (don't cache pointers to the input) + virtual void subcase_start(const SubcaseSignature&) = 0; + // called whenever a subcase is exited (don't cache pointers to the input) + virtual void subcase_end() = 0; + + // called for each assert (don't cache pointers to the input) + virtual void log_assert(const AssertData&) = 0; + // called for each message (don't cache pointers to the input) + virtual void log_message(const MessageData&) = 0; + + // called when a test case is skipped either because it doesn't pass the filters, has a skip decorator + // or isn't in the execution range (between first and last) (safe to cache a pointer to the input) + virtual void test_case_skipped(const TestCaseData&) = 0; + + DOCTEST_DECLARE_INTERFACE(IReporter) + + // can obtain all currently active contexts and stringify them if one wishes to do so + static int get_num_active_contexts(); + static const IContextScope* const* get_active_contexts(); + + // can iterate through contexts which have been stringified automatically in their destructors when an exception has been thrown + static int get_num_stringified_contexts(); + static const String* get_stringified_contexts(); +}; + +namespace detail { + using reporterCreatorFunc = IReporter* (*)(const ContextOptions&); + + DOCTEST_INTERFACE void registerReporterImpl(const char* name, int prio, reporterCreatorFunc c, bool isReporter); + + template + IReporter* reporterCreator(const ContextOptions& o) { + return new Reporter(o); + } +} // namespace detail + +template +int registerReporter(const char* name, int priority, bool isReporter) { + detail::registerReporterImpl(name, priority, detail::reporterCreator, isReporter); + return 0; +} +} // namespace doctest + +#ifdef DOCTEST_CONFIG_ASSERTS_RETURN_VALUES +#define DOCTEST_FUNC_EMPTY [] { return false; }() +#else +#define DOCTEST_FUNC_EMPTY (void)0 +#endif + +// if registering is not disabled +#ifndef DOCTEST_CONFIG_DISABLE + +#ifdef DOCTEST_CONFIG_ASSERTS_RETURN_VALUES +#define DOCTEST_FUNC_SCOPE_BEGIN [&] +#define DOCTEST_FUNC_SCOPE_END () +#define DOCTEST_FUNC_SCOPE_RET(v) return v +#else +#define DOCTEST_FUNC_SCOPE_BEGIN do +#define DOCTEST_FUNC_SCOPE_END while(false) +#define DOCTEST_FUNC_SCOPE_RET(v) (void)0 +#endif + +// common code in asserts - for convenience +#define DOCTEST_ASSERT_LOG_REACT_RETURN(b) \ + if(b.log()) DOCTEST_BREAK_INTO_DEBUGGER(); \ + b.react(); \ + DOCTEST_FUNC_SCOPE_RET(!b.m_failed) + +#ifdef DOCTEST_CONFIG_NO_TRY_CATCH_IN_ASSERTS +#define DOCTEST_WRAP_IN_TRY(x) x; +#else // DOCTEST_CONFIG_NO_TRY_CATCH_IN_ASSERTS +#define DOCTEST_WRAP_IN_TRY(x) \ + try { \ + x; \ + } catch(...) { DOCTEST_RB.translateException(); } +#endif // DOCTEST_CONFIG_NO_TRY_CATCH_IN_ASSERTS + +#ifdef DOCTEST_CONFIG_VOID_CAST_EXPRESSIONS +#define DOCTEST_CAST_TO_VOID(...) \ + DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wuseless-cast") \ + static_cast(__VA_ARGS__); \ + DOCTEST_GCC_SUPPRESS_WARNING_POP +#else // DOCTEST_CONFIG_VOID_CAST_EXPRESSIONS +#define DOCTEST_CAST_TO_VOID(...) __VA_ARGS__; +#endif // DOCTEST_CONFIG_VOID_CAST_EXPRESSIONS + +// registers the test by initializing a dummy var with a function +#define DOCTEST_REGISTER_FUNCTION(global_prefix, f, decorators) \ + global_prefix DOCTEST_GLOBAL_NO_WARNINGS(DOCTEST_ANONYMOUS(DOCTEST_ANON_VAR_), /* NOLINT */ \ + doctest::detail::regTest( \ + doctest::detail::TestCase( \ + f, __FILE__, __LINE__, \ + doctest_detail_test_suite_ns::getCurrentTestSuite()) * \ + decorators)) + +#define DOCTEST_IMPLEMENT_FIXTURE(der, base, func, decorators) \ + namespace { /* NOLINT */ \ + struct der : public base \ + { \ + void f(); \ + }; \ + static DOCTEST_INLINE_NOINLINE void func() { \ + der v; \ + v.f(); \ + } \ + DOCTEST_REGISTER_FUNCTION(DOCTEST_EMPTY, func, decorators) \ + } \ + DOCTEST_INLINE_NOINLINE void der::f() // NOLINT(misc-definitions-in-headers) + +#define DOCTEST_CREATE_AND_REGISTER_FUNCTION(f, decorators) \ + static void f(); \ + DOCTEST_REGISTER_FUNCTION(DOCTEST_EMPTY, f, decorators) \ + static void f() + +#define DOCTEST_CREATE_AND_REGISTER_FUNCTION_IN_CLASS(f, proxy, decorators) \ + static doctest::detail::funcType proxy() { return f; } \ + DOCTEST_REGISTER_FUNCTION(inline, proxy(), decorators) \ + static void f() + +// for registering tests +#define DOCTEST_TEST_CASE(decorators) \ + DOCTEST_CREATE_AND_REGISTER_FUNCTION(DOCTEST_ANONYMOUS(DOCTEST_ANON_FUNC_), decorators) + +// for registering tests in classes - requires C++17 for inline variables! +#if DOCTEST_CPLUSPLUS >= 201703L +#define DOCTEST_TEST_CASE_CLASS(decorators) \ + DOCTEST_CREATE_AND_REGISTER_FUNCTION_IN_CLASS(DOCTEST_ANONYMOUS(DOCTEST_ANON_FUNC_), \ + DOCTEST_ANONYMOUS(DOCTEST_ANON_PROXY_), \ + decorators) +#else // DOCTEST_TEST_CASE_CLASS +#define DOCTEST_TEST_CASE_CLASS(...) \ + TEST_CASES_CAN_BE_REGISTERED_IN_CLASSES_ONLY_IN_CPP17_MODE_OR_WITH_VS_2017_OR_NEWER +#endif // DOCTEST_TEST_CASE_CLASS + +// for registering tests with a fixture +#define DOCTEST_TEST_CASE_FIXTURE(c, decorators) \ + DOCTEST_IMPLEMENT_FIXTURE(DOCTEST_ANONYMOUS(DOCTEST_ANON_CLASS_), c, \ + DOCTEST_ANONYMOUS(DOCTEST_ANON_FUNC_), decorators) + +// for converting types to strings without the header and demangling +#define DOCTEST_TYPE_TO_STRING_AS(str, ...) \ + namespace doctest { \ + template <> \ + inline String toString<__VA_ARGS__>() { \ + return str; \ + } \ + } \ + static_assert(true, "") + +#define DOCTEST_TYPE_TO_STRING(...) DOCTEST_TYPE_TO_STRING_AS(#__VA_ARGS__, __VA_ARGS__) + +#define DOCTEST_TEST_CASE_TEMPLATE_DEFINE_IMPL(dec, T, iter, func) \ + template \ + static void func(); \ + namespace { /* NOLINT */ \ + template \ + struct iter; \ + template \ + struct iter> \ + { \ + iter(const char* file, unsigned line, int index) { \ + doctest::detail::regTest(doctest::detail::TestCase(func, file, line, \ + doctest_detail_test_suite_ns::getCurrentTestSuite(), \ + doctest::toString(), \ + int(line) * 1000 + index) \ + * dec); \ + iter>(file, line, index + 1); \ + } \ + }; \ + template <> \ + struct iter> \ + { \ + iter(const char*, unsigned, int) {} \ + }; \ + } \ + template \ + static void func() + +#define DOCTEST_TEST_CASE_TEMPLATE_DEFINE(dec, T, id) \ + DOCTEST_TEST_CASE_TEMPLATE_DEFINE_IMPL(dec, T, DOCTEST_CAT(id, ITERATOR), \ + DOCTEST_ANONYMOUS(DOCTEST_ANON_TMP_)) + +#define DOCTEST_TEST_CASE_TEMPLATE_INSTANTIATE_IMPL(id, anon, ...) \ + DOCTEST_GLOBAL_NO_WARNINGS(DOCTEST_CAT(anon, DUMMY), /* NOLINT(cert-err58-cpp, fuchsia-statically-constructed-objects) */ \ + doctest::detail::instantiationHelper( \ + DOCTEST_CAT(id, ITERATOR)<__VA_ARGS__>(__FILE__, __LINE__, 0))) + +#define DOCTEST_TEST_CASE_TEMPLATE_INVOKE(id, ...) \ + DOCTEST_TEST_CASE_TEMPLATE_INSTANTIATE_IMPL(id, DOCTEST_ANONYMOUS(DOCTEST_ANON_TMP_), std::tuple<__VA_ARGS__>) \ + static_assert(true, "") + +#define DOCTEST_TEST_CASE_TEMPLATE_APPLY(id, ...) \ + DOCTEST_TEST_CASE_TEMPLATE_INSTANTIATE_IMPL(id, DOCTEST_ANONYMOUS(DOCTEST_ANON_TMP_), __VA_ARGS__) \ + static_assert(true, "") + +#define DOCTEST_TEST_CASE_TEMPLATE_IMPL(dec, T, anon, ...) \ + DOCTEST_TEST_CASE_TEMPLATE_DEFINE_IMPL(dec, T, DOCTEST_CAT(anon, ITERATOR), anon); \ + DOCTEST_TEST_CASE_TEMPLATE_INSTANTIATE_IMPL(anon, anon, std::tuple<__VA_ARGS__>) \ + template \ + static void anon() + +#define DOCTEST_TEST_CASE_TEMPLATE(dec, T, ...) \ + DOCTEST_TEST_CASE_TEMPLATE_IMPL(dec, T, DOCTEST_ANONYMOUS(DOCTEST_ANON_TMP_), __VA_ARGS__) + +// for subcases +#define DOCTEST_SUBCASE(name) \ + if(const doctest::detail::Subcase & DOCTEST_ANONYMOUS(DOCTEST_ANON_SUBCASE_) DOCTEST_UNUSED = \ + doctest::detail::Subcase(name, __FILE__, __LINE__)) + +// for grouping tests in test suites by using code blocks +#define DOCTEST_TEST_SUITE_IMPL(decorators, ns_name) \ + namespace ns_name { namespace doctest_detail_test_suite_ns { \ + static DOCTEST_NOINLINE doctest::detail::TestSuite& getCurrentTestSuite() noexcept { \ + DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4640) \ + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wexit-time-destructors") \ + DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wmissing-field-initializers") \ + static doctest::detail::TestSuite data{}; \ + static bool inited = false; \ + DOCTEST_MSVC_SUPPRESS_WARNING_POP \ + DOCTEST_CLANG_SUPPRESS_WARNING_POP \ + DOCTEST_GCC_SUPPRESS_WARNING_POP \ + if(!inited) { \ + data* decorators; \ + inited = true; \ + } \ + return data; \ + } \ + } \ + } \ + namespace ns_name + +#define DOCTEST_TEST_SUITE(decorators) \ + DOCTEST_TEST_SUITE_IMPL(decorators, DOCTEST_ANONYMOUS(DOCTEST_ANON_SUITE_)) + +// for starting a testsuite block +#define DOCTEST_TEST_SUITE_BEGIN(decorators) \ + DOCTEST_GLOBAL_NO_WARNINGS(DOCTEST_ANONYMOUS(DOCTEST_ANON_VAR_), /* NOLINT(cert-err58-cpp) */ \ + doctest::detail::setTestSuite(doctest::detail::TestSuite() * decorators)) \ + static_assert(true, "") + +// for ending a testsuite block +#define DOCTEST_TEST_SUITE_END \ + DOCTEST_GLOBAL_NO_WARNINGS(DOCTEST_ANONYMOUS(DOCTEST_ANON_VAR_), /* NOLINT(cert-err58-cpp) */ \ + doctest::detail::setTestSuite(doctest::detail::TestSuite() * "")) \ + using DOCTEST_ANONYMOUS(DOCTEST_ANON_FOR_SEMICOLON_) = int + +// for registering exception translators +#define DOCTEST_REGISTER_EXCEPTION_TRANSLATOR_IMPL(translatorName, signature) \ + inline doctest::String translatorName(signature); \ + DOCTEST_GLOBAL_NO_WARNINGS(DOCTEST_ANONYMOUS(DOCTEST_ANON_TRANSLATOR_), /* NOLINT(cert-err58-cpp) */ \ + doctest::registerExceptionTranslator(translatorName)) \ + doctest::String translatorName(signature) + +#define DOCTEST_REGISTER_EXCEPTION_TRANSLATOR(signature) \ + DOCTEST_REGISTER_EXCEPTION_TRANSLATOR_IMPL(DOCTEST_ANONYMOUS(DOCTEST_ANON_TRANSLATOR_), \ + signature) + +// for registering reporters +#define DOCTEST_REGISTER_REPORTER(name, priority, reporter) \ + DOCTEST_GLOBAL_NO_WARNINGS(DOCTEST_ANONYMOUS(DOCTEST_ANON_REPORTER_), /* NOLINT(cert-err58-cpp) */ \ + doctest::registerReporter(name, priority, true)) \ + static_assert(true, "") + +// for registering listeners +#define DOCTEST_REGISTER_LISTENER(name, priority, reporter) \ + DOCTEST_GLOBAL_NO_WARNINGS(DOCTEST_ANONYMOUS(DOCTEST_ANON_REPORTER_), /* NOLINT(cert-err58-cpp) */ \ + doctest::registerReporter(name, priority, false)) \ + static_assert(true, "") + +// clang-format off +// for logging - disabling formatting because it's important to have these on 2 separate lines - see PR #557 +#define DOCTEST_INFO(...) \ + DOCTEST_INFO_IMPL(DOCTEST_ANONYMOUS(DOCTEST_CAPTURE_), \ + DOCTEST_ANONYMOUS(DOCTEST_CAPTURE_OTHER_), \ + __VA_ARGS__) +// clang-format on + +#define DOCTEST_INFO_IMPL(mb_name, s_name, ...) \ + auto DOCTEST_ANONYMOUS(DOCTEST_CAPTURE_) = doctest::detail::MakeContextScope( \ + [&](std::ostream* s_name) { \ + doctest::detail::MessageBuilder mb_name(__FILE__, __LINE__, doctest::assertType::is_warn); \ + mb_name.m_stream = s_name; \ + mb_name * __VA_ARGS__; \ + }) + +#define DOCTEST_CAPTURE(x) DOCTEST_INFO(#x " := ", x) + +#define DOCTEST_ADD_AT_IMPL(type, file, line, mb, ...) \ + DOCTEST_FUNC_SCOPE_BEGIN { \ + doctest::detail::MessageBuilder mb(file, line, doctest::assertType::type); \ + mb * __VA_ARGS__; \ + if(mb.log()) \ + DOCTEST_BREAK_INTO_DEBUGGER(); \ + mb.react(); \ + } DOCTEST_FUNC_SCOPE_END + +// clang-format off +#define DOCTEST_ADD_MESSAGE_AT(file, line, ...) DOCTEST_ADD_AT_IMPL(is_warn, file, line, DOCTEST_ANONYMOUS(DOCTEST_MESSAGE_), __VA_ARGS__) +#define DOCTEST_ADD_FAIL_CHECK_AT(file, line, ...) DOCTEST_ADD_AT_IMPL(is_check, file, line, DOCTEST_ANONYMOUS(DOCTEST_MESSAGE_), __VA_ARGS__) +#define DOCTEST_ADD_FAIL_AT(file, line, ...) DOCTEST_ADD_AT_IMPL(is_require, file, line, DOCTEST_ANONYMOUS(DOCTEST_MESSAGE_), __VA_ARGS__) +// clang-format on + +#define DOCTEST_MESSAGE(...) DOCTEST_ADD_MESSAGE_AT(__FILE__, __LINE__, __VA_ARGS__) +#define DOCTEST_FAIL_CHECK(...) DOCTEST_ADD_FAIL_CHECK_AT(__FILE__, __LINE__, __VA_ARGS__) +#define DOCTEST_FAIL(...) DOCTEST_ADD_FAIL_AT(__FILE__, __LINE__, __VA_ARGS__) + +#define DOCTEST_TO_LVALUE(...) __VA_ARGS__ // Not removed to keep backwards compatibility. + +#ifndef DOCTEST_CONFIG_SUPER_FAST_ASSERTS + +#define DOCTEST_ASSERT_IMPLEMENT_2(assert_type, ...) \ + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Woverloaded-shift-op-parentheses") \ + /* NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) */ \ + doctest::detail::ResultBuilder DOCTEST_RB(doctest::assertType::assert_type, __FILE__, \ + __LINE__, #__VA_ARGS__); \ + DOCTEST_WRAP_IN_TRY(DOCTEST_RB.setResult( \ + doctest::detail::ExpressionDecomposer(doctest::assertType::assert_type) \ + << __VA_ARGS__)) /* NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) */ \ + DOCTEST_ASSERT_LOG_REACT_RETURN(DOCTEST_RB) \ + DOCTEST_CLANG_SUPPRESS_WARNING_POP + +#define DOCTEST_ASSERT_IMPLEMENT_1(assert_type, ...) \ + DOCTEST_FUNC_SCOPE_BEGIN { \ + DOCTEST_ASSERT_IMPLEMENT_2(assert_type, __VA_ARGS__); \ + } DOCTEST_FUNC_SCOPE_END // NOLINT(clang-analyzer-cplusplus.NewDeleteLeaks) + +#define DOCTEST_BINARY_ASSERT(assert_type, comp, ...) \ + DOCTEST_FUNC_SCOPE_BEGIN { \ + doctest::detail::ResultBuilder DOCTEST_RB(doctest::assertType::assert_type, __FILE__, \ + __LINE__, #__VA_ARGS__); \ + DOCTEST_WRAP_IN_TRY( \ + DOCTEST_RB.binary_assert( \ + __VA_ARGS__)) \ + DOCTEST_ASSERT_LOG_REACT_RETURN(DOCTEST_RB); \ + } DOCTEST_FUNC_SCOPE_END + +#define DOCTEST_UNARY_ASSERT(assert_type, ...) \ + DOCTEST_FUNC_SCOPE_BEGIN { \ + doctest::detail::ResultBuilder DOCTEST_RB(doctest::assertType::assert_type, __FILE__, \ + __LINE__, #__VA_ARGS__); \ + DOCTEST_WRAP_IN_TRY(DOCTEST_RB.unary_assert(__VA_ARGS__)) \ + DOCTEST_ASSERT_LOG_REACT_RETURN(DOCTEST_RB); \ + } DOCTEST_FUNC_SCOPE_END + +#else // DOCTEST_CONFIG_SUPER_FAST_ASSERTS + +// necessary for _MESSAGE +#define DOCTEST_ASSERT_IMPLEMENT_2 DOCTEST_ASSERT_IMPLEMENT_1 + +#define DOCTEST_ASSERT_IMPLEMENT_1(assert_type, ...) \ + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Woverloaded-shift-op-parentheses") \ + doctest::detail::decomp_assert( \ + doctest::assertType::assert_type, __FILE__, __LINE__, #__VA_ARGS__, \ + doctest::detail::ExpressionDecomposer(doctest::assertType::assert_type) \ + << __VA_ARGS__) DOCTEST_CLANG_SUPPRESS_WARNING_POP + +#define DOCTEST_BINARY_ASSERT(assert_type, comparison, ...) \ + doctest::detail::binary_assert( \ + doctest::assertType::assert_type, __FILE__, __LINE__, #__VA_ARGS__, __VA_ARGS__) + +#define DOCTEST_UNARY_ASSERT(assert_type, ...) \ + doctest::detail::unary_assert(doctest::assertType::assert_type, __FILE__, __LINE__, \ + #__VA_ARGS__, __VA_ARGS__) + +#endif // DOCTEST_CONFIG_SUPER_FAST_ASSERTS + +#define DOCTEST_WARN(...) DOCTEST_ASSERT_IMPLEMENT_1(DT_WARN, __VA_ARGS__) +#define DOCTEST_CHECK(...) DOCTEST_ASSERT_IMPLEMENT_1(DT_CHECK, __VA_ARGS__) +#define DOCTEST_REQUIRE(...) DOCTEST_ASSERT_IMPLEMENT_1(DT_REQUIRE, __VA_ARGS__) +#define DOCTEST_WARN_FALSE(...) DOCTEST_ASSERT_IMPLEMENT_1(DT_WARN_FALSE, __VA_ARGS__) +#define DOCTEST_CHECK_FALSE(...) DOCTEST_ASSERT_IMPLEMENT_1(DT_CHECK_FALSE, __VA_ARGS__) +#define DOCTEST_REQUIRE_FALSE(...) DOCTEST_ASSERT_IMPLEMENT_1(DT_REQUIRE_FALSE, __VA_ARGS__) + +// clang-format off +#define DOCTEST_WARN_MESSAGE(cond, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_ASSERT_IMPLEMENT_2(DT_WARN, cond); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_CHECK_MESSAGE(cond, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_ASSERT_IMPLEMENT_2(DT_CHECK, cond); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_REQUIRE_MESSAGE(cond, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_ASSERT_IMPLEMENT_2(DT_REQUIRE, cond); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_WARN_FALSE_MESSAGE(cond, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_ASSERT_IMPLEMENT_2(DT_WARN_FALSE, cond); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_CHECK_FALSE_MESSAGE(cond, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_ASSERT_IMPLEMENT_2(DT_CHECK_FALSE, cond); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_REQUIRE_FALSE_MESSAGE(cond, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_ASSERT_IMPLEMENT_2(DT_REQUIRE_FALSE, cond); } DOCTEST_FUNC_SCOPE_END +// clang-format on + +#define DOCTEST_WARN_EQ(...) DOCTEST_BINARY_ASSERT(DT_WARN_EQ, eq, __VA_ARGS__) +#define DOCTEST_CHECK_EQ(...) DOCTEST_BINARY_ASSERT(DT_CHECK_EQ, eq, __VA_ARGS__) +#define DOCTEST_REQUIRE_EQ(...) DOCTEST_BINARY_ASSERT(DT_REQUIRE_EQ, eq, __VA_ARGS__) +#define DOCTEST_WARN_NE(...) DOCTEST_BINARY_ASSERT(DT_WARN_NE, ne, __VA_ARGS__) +#define DOCTEST_CHECK_NE(...) DOCTEST_BINARY_ASSERT(DT_CHECK_NE, ne, __VA_ARGS__) +#define DOCTEST_REQUIRE_NE(...) DOCTEST_BINARY_ASSERT(DT_REQUIRE_NE, ne, __VA_ARGS__) +#define DOCTEST_WARN_GT(...) DOCTEST_BINARY_ASSERT(DT_WARN_GT, gt, __VA_ARGS__) +#define DOCTEST_CHECK_GT(...) DOCTEST_BINARY_ASSERT(DT_CHECK_GT, gt, __VA_ARGS__) +#define DOCTEST_REQUIRE_GT(...) DOCTEST_BINARY_ASSERT(DT_REQUIRE_GT, gt, __VA_ARGS__) +#define DOCTEST_WARN_LT(...) DOCTEST_BINARY_ASSERT(DT_WARN_LT, lt, __VA_ARGS__) +#define DOCTEST_CHECK_LT(...) DOCTEST_BINARY_ASSERT(DT_CHECK_LT, lt, __VA_ARGS__) +#define DOCTEST_REQUIRE_LT(...) DOCTEST_BINARY_ASSERT(DT_REQUIRE_LT, lt, __VA_ARGS__) +#define DOCTEST_WARN_GE(...) DOCTEST_BINARY_ASSERT(DT_WARN_GE, ge, __VA_ARGS__) +#define DOCTEST_CHECK_GE(...) DOCTEST_BINARY_ASSERT(DT_CHECK_GE, ge, __VA_ARGS__) +#define DOCTEST_REQUIRE_GE(...) DOCTEST_BINARY_ASSERT(DT_REQUIRE_GE, ge, __VA_ARGS__) +#define DOCTEST_WARN_LE(...) DOCTEST_BINARY_ASSERT(DT_WARN_LE, le, __VA_ARGS__) +#define DOCTEST_CHECK_LE(...) DOCTEST_BINARY_ASSERT(DT_CHECK_LE, le, __VA_ARGS__) +#define DOCTEST_REQUIRE_LE(...) DOCTEST_BINARY_ASSERT(DT_REQUIRE_LE, le, __VA_ARGS__) + +#define DOCTEST_WARN_UNARY(...) DOCTEST_UNARY_ASSERT(DT_WARN_UNARY, __VA_ARGS__) +#define DOCTEST_CHECK_UNARY(...) DOCTEST_UNARY_ASSERT(DT_CHECK_UNARY, __VA_ARGS__) +#define DOCTEST_REQUIRE_UNARY(...) DOCTEST_UNARY_ASSERT(DT_REQUIRE_UNARY, __VA_ARGS__) +#define DOCTEST_WARN_UNARY_FALSE(...) DOCTEST_UNARY_ASSERT(DT_WARN_UNARY_FALSE, __VA_ARGS__) +#define DOCTEST_CHECK_UNARY_FALSE(...) DOCTEST_UNARY_ASSERT(DT_CHECK_UNARY_FALSE, __VA_ARGS__) +#define DOCTEST_REQUIRE_UNARY_FALSE(...) DOCTEST_UNARY_ASSERT(DT_REQUIRE_UNARY_FALSE, __VA_ARGS__) + +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + +#define DOCTEST_ASSERT_THROWS_AS(expr, assert_type, message, ...) \ + DOCTEST_FUNC_SCOPE_BEGIN { \ + if(!doctest::getContextOptions()->no_throw) { \ + doctest::detail::ResultBuilder DOCTEST_RB(doctest::assertType::assert_type, __FILE__, \ + __LINE__, #expr, #__VA_ARGS__, message); \ + try { \ + DOCTEST_CAST_TO_VOID(expr) \ + } catch(const typename doctest::detail::types::remove_const< \ + typename doctest::detail::types::remove_reference<__VA_ARGS__>::type>::type&) {\ + DOCTEST_RB.translateException(); \ + DOCTEST_RB.m_threw_as = true; \ + } catch(...) { DOCTEST_RB.translateException(); } \ + DOCTEST_ASSERT_LOG_REACT_RETURN(DOCTEST_RB); \ + } else { /* NOLINT(*-else-after-return) */ \ + DOCTEST_FUNC_SCOPE_RET(false); \ + } \ + } DOCTEST_FUNC_SCOPE_END + +#define DOCTEST_ASSERT_THROWS_WITH(expr, expr_str, assert_type, ...) \ + DOCTEST_FUNC_SCOPE_BEGIN { \ + if(!doctest::getContextOptions()->no_throw) { \ + doctest::detail::ResultBuilder DOCTEST_RB(doctest::assertType::assert_type, __FILE__, \ + __LINE__, expr_str, "", __VA_ARGS__); \ + try { \ + DOCTEST_CAST_TO_VOID(expr) \ + } catch(...) { DOCTEST_RB.translateException(); } \ + DOCTEST_ASSERT_LOG_REACT_RETURN(DOCTEST_RB); \ + } else { /* NOLINT(*-else-after-return) */ \ + DOCTEST_FUNC_SCOPE_RET(false); \ + } \ + } DOCTEST_FUNC_SCOPE_END + +#define DOCTEST_ASSERT_NOTHROW(assert_type, ...) \ + DOCTEST_FUNC_SCOPE_BEGIN { \ + doctest::detail::ResultBuilder DOCTEST_RB(doctest::assertType::assert_type, __FILE__, \ + __LINE__, #__VA_ARGS__); \ + try { \ + DOCTEST_CAST_TO_VOID(__VA_ARGS__) \ + } catch(...) { DOCTEST_RB.translateException(); } \ + DOCTEST_ASSERT_LOG_REACT_RETURN(DOCTEST_RB); \ + } DOCTEST_FUNC_SCOPE_END + +// clang-format off +#define DOCTEST_WARN_THROWS(...) DOCTEST_ASSERT_THROWS_WITH((__VA_ARGS__), #__VA_ARGS__, DT_WARN_THROWS, "") +#define DOCTEST_CHECK_THROWS(...) DOCTEST_ASSERT_THROWS_WITH((__VA_ARGS__), #__VA_ARGS__, DT_CHECK_THROWS, "") +#define DOCTEST_REQUIRE_THROWS(...) DOCTEST_ASSERT_THROWS_WITH((__VA_ARGS__), #__VA_ARGS__, DT_REQUIRE_THROWS, "") + +#define DOCTEST_WARN_THROWS_AS(expr, ...) DOCTEST_ASSERT_THROWS_AS(expr, DT_WARN_THROWS_AS, "", __VA_ARGS__) +#define DOCTEST_CHECK_THROWS_AS(expr, ...) DOCTEST_ASSERT_THROWS_AS(expr, DT_CHECK_THROWS_AS, "", __VA_ARGS__) +#define DOCTEST_REQUIRE_THROWS_AS(expr, ...) DOCTEST_ASSERT_THROWS_AS(expr, DT_REQUIRE_THROWS_AS, "", __VA_ARGS__) + +#define DOCTEST_WARN_THROWS_WITH(expr, ...) DOCTEST_ASSERT_THROWS_WITH(expr, #expr, DT_WARN_THROWS_WITH, __VA_ARGS__) +#define DOCTEST_CHECK_THROWS_WITH(expr, ...) DOCTEST_ASSERT_THROWS_WITH(expr, #expr, DT_CHECK_THROWS_WITH, __VA_ARGS__) +#define DOCTEST_REQUIRE_THROWS_WITH(expr, ...) DOCTEST_ASSERT_THROWS_WITH(expr, #expr, DT_REQUIRE_THROWS_WITH, __VA_ARGS__) + +#define DOCTEST_WARN_THROWS_WITH_AS(expr, message, ...) DOCTEST_ASSERT_THROWS_AS(expr, DT_WARN_THROWS_WITH_AS, message, __VA_ARGS__) +#define DOCTEST_CHECK_THROWS_WITH_AS(expr, message, ...) DOCTEST_ASSERT_THROWS_AS(expr, DT_CHECK_THROWS_WITH_AS, message, __VA_ARGS__) +#define DOCTEST_REQUIRE_THROWS_WITH_AS(expr, message, ...) DOCTEST_ASSERT_THROWS_AS(expr, DT_REQUIRE_THROWS_WITH_AS, message, __VA_ARGS__) + +#define DOCTEST_WARN_NOTHROW(...) DOCTEST_ASSERT_NOTHROW(DT_WARN_NOTHROW, __VA_ARGS__) +#define DOCTEST_CHECK_NOTHROW(...) DOCTEST_ASSERT_NOTHROW(DT_CHECK_NOTHROW, __VA_ARGS__) +#define DOCTEST_REQUIRE_NOTHROW(...) DOCTEST_ASSERT_NOTHROW(DT_REQUIRE_NOTHROW, __VA_ARGS__) + +#define DOCTEST_WARN_THROWS_MESSAGE(expr, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_WARN_THROWS(expr); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_CHECK_THROWS_MESSAGE(expr, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_CHECK_THROWS(expr); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_REQUIRE_THROWS_MESSAGE(expr, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_REQUIRE_THROWS(expr); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_WARN_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_WARN_THROWS_AS(expr, ex); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_CHECK_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_CHECK_THROWS_AS(expr, ex); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_REQUIRE_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_REQUIRE_THROWS_AS(expr, ex); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_WARN_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_WARN_THROWS_WITH(expr, with); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_CHECK_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_CHECK_THROWS_WITH(expr, with); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_REQUIRE_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_REQUIRE_THROWS_WITH(expr, with); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_WARN_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_WARN_THROWS_WITH_AS(expr, with, ex); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_CHECK_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_CHECK_THROWS_WITH_AS(expr, with, ex); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_REQUIRE_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_REQUIRE_THROWS_WITH_AS(expr, with, ex); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_WARN_NOTHROW_MESSAGE(expr, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_WARN_NOTHROW(expr); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_CHECK_NOTHROW_MESSAGE(expr, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_CHECK_NOTHROW(expr); } DOCTEST_FUNC_SCOPE_END +#define DOCTEST_REQUIRE_NOTHROW_MESSAGE(expr, ...) DOCTEST_FUNC_SCOPE_BEGIN { DOCTEST_INFO(__VA_ARGS__); DOCTEST_REQUIRE_NOTHROW(expr); } DOCTEST_FUNC_SCOPE_END +// clang-format on + +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + +// ================================================================================================= +// == WHAT FOLLOWS IS VERSIONS OF THE MACROS THAT DO NOT DO ANY REGISTERING! == +// == THIS CAN BE ENABLED BY DEFINING DOCTEST_CONFIG_DISABLE GLOBALLY! == +// ================================================================================================= +#else // DOCTEST_CONFIG_DISABLE + +#define DOCTEST_IMPLEMENT_FIXTURE(der, base, func, name) \ + namespace /* NOLINT */ { \ + template \ + struct der : public base \ + { void f(); }; \ + } \ + template \ + inline void der::f() + +#define DOCTEST_CREATE_AND_REGISTER_FUNCTION(f, name) \ + template \ + static inline void f() + +// for registering tests +#define DOCTEST_TEST_CASE(name) \ + DOCTEST_CREATE_AND_REGISTER_FUNCTION(DOCTEST_ANONYMOUS(DOCTEST_ANON_FUNC_), name) + +// for registering tests in classes +#define DOCTEST_TEST_CASE_CLASS(name) \ + DOCTEST_CREATE_AND_REGISTER_FUNCTION(DOCTEST_ANONYMOUS(DOCTEST_ANON_FUNC_), name) + +// for registering tests with a fixture +#define DOCTEST_TEST_CASE_FIXTURE(x, name) \ + DOCTEST_IMPLEMENT_FIXTURE(DOCTEST_ANONYMOUS(DOCTEST_ANON_CLASS_), x, \ + DOCTEST_ANONYMOUS(DOCTEST_ANON_FUNC_), name) + +// for converting types to strings without the header and demangling +#define DOCTEST_TYPE_TO_STRING_AS(str, ...) static_assert(true, "") +#define DOCTEST_TYPE_TO_STRING(...) static_assert(true, "") + +// for typed tests +#define DOCTEST_TEST_CASE_TEMPLATE(name, type, ...) \ + template \ + inline void DOCTEST_ANONYMOUS(DOCTEST_ANON_TMP_)() + +#define DOCTEST_TEST_CASE_TEMPLATE_DEFINE(name, type, id) \ + template \ + inline void DOCTEST_ANONYMOUS(DOCTEST_ANON_TMP_)() + +#define DOCTEST_TEST_CASE_TEMPLATE_INVOKE(id, ...) static_assert(true, "") +#define DOCTEST_TEST_CASE_TEMPLATE_APPLY(id, ...) static_assert(true, "") + +// for subcases +#define DOCTEST_SUBCASE(name) + +// for a testsuite block +#define DOCTEST_TEST_SUITE(name) namespace // NOLINT + +// for starting a testsuite block +#define DOCTEST_TEST_SUITE_BEGIN(name) static_assert(true, "") + +// for ending a testsuite block +#define DOCTEST_TEST_SUITE_END using DOCTEST_ANONYMOUS(DOCTEST_ANON_FOR_SEMICOLON_) = int + +#define DOCTEST_REGISTER_EXCEPTION_TRANSLATOR(signature) \ + template \ + static inline doctest::String DOCTEST_ANONYMOUS(DOCTEST_ANON_TRANSLATOR_)(signature) + +#define DOCTEST_REGISTER_REPORTER(name, priority, reporter) +#define DOCTEST_REGISTER_LISTENER(name, priority, reporter) + +#define DOCTEST_INFO(...) (static_cast(0)) +#define DOCTEST_CAPTURE(x) (static_cast(0)) +#define DOCTEST_ADD_MESSAGE_AT(file, line, ...) (static_cast(0)) +#define DOCTEST_ADD_FAIL_CHECK_AT(file, line, ...) (static_cast(0)) +#define DOCTEST_ADD_FAIL_AT(file, line, ...) (static_cast(0)) +#define DOCTEST_MESSAGE(...) (static_cast(0)) +#define DOCTEST_FAIL_CHECK(...) (static_cast(0)) +#define DOCTEST_FAIL(...) (static_cast(0)) + +#if defined(DOCTEST_CONFIG_EVALUATE_ASSERTS_EVEN_WHEN_DISABLED) \ + && defined(DOCTEST_CONFIG_ASSERTS_RETURN_VALUES) + +#define DOCTEST_WARN(...) [&] { return __VA_ARGS__; }() +#define DOCTEST_CHECK(...) [&] { return __VA_ARGS__; }() +#define DOCTEST_REQUIRE(...) [&] { return __VA_ARGS__; }() +#define DOCTEST_WARN_FALSE(...) [&] { return !(__VA_ARGS__); }() +#define DOCTEST_CHECK_FALSE(...) [&] { return !(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_FALSE(...) [&] { return !(__VA_ARGS__); }() + +#define DOCTEST_WARN_MESSAGE(cond, ...) [&] { return cond; }() +#define DOCTEST_CHECK_MESSAGE(cond, ...) [&] { return cond; }() +#define DOCTEST_REQUIRE_MESSAGE(cond, ...) [&] { return cond; }() +#define DOCTEST_WARN_FALSE_MESSAGE(cond, ...) [&] { return !(cond); }() +#define DOCTEST_CHECK_FALSE_MESSAGE(cond, ...) [&] { return !(cond); }() +#define DOCTEST_REQUIRE_FALSE_MESSAGE(cond, ...) [&] { return !(cond); }() + +namespace doctest { +namespace detail { +#define DOCTEST_RELATIONAL_OP(name, op) \ + template \ + bool name(const DOCTEST_REF_WRAP(L) lhs, const DOCTEST_REF_WRAP(R) rhs) { return lhs op rhs; } + + DOCTEST_RELATIONAL_OP(eq, ==) + DOCTEST_RELATIONAL_OP(ne, !=) + DOCTEST_RELATIONAL_OP(lt, <) + DOCTEST_RELATIONAL_OP(gt, >) + DOCTEST_RELATIONAL_OP(le, <=) + DOCTEST_RELATIONAL_OP(ge, >=) +} // namespace detail +} // namespace doctest + +#define DOCTEST_WARN_EQ(...) [&] { return doctest::detail::eq(__VA_ARGS__); }() +#define DOCTEST_CHECK_EQ(...) [&] { return doctest::detail::eq(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_EQ(...) [&] { return doctest::detail::eq(__VA_ARGS__); }() +#define DOCTEST_WARN_NE(...) [&] { return doctest::detail::ne(__VA_ARGS__); }() +#define DOCTEST_CHECK_NE(...) [&] { return doctest::detail::ne(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_NE(...) [&] { return doctest::detail::ne(__VA_ARGS__); }() +#define DOCTEST_WARN_LT(...) [&] { return doctest::detail::lt(__VA_ARGS__); }() +#define DOCTEST_CHECK_LT(...) [&] { return doctest::detail::lt(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_LT(...) [&] { return doctest::detail::lt(__VA_ARGS__); }() +#define DOCTEST_WARN_GT(...) [&] { return doctest::detail::gt(__VA_ARGS__); }() +#define DOCTEST_CHECK_GT(...) [&] { return doctest::detail::gt(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_GT(...) [&] { return doctest::detail::gt(__VA_ARGS__); }() +#define DOCTEST_WARN_LE(...) [&] { return doctest::detail::le(__VA_ARGS__); }() +#define DOCTEST_CHECK_LE(...) [&] { return doctest::detail::le(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_LE(...) [&] { return doctest::detail::le(__VA_ARGS__); }() +#define DOCTEST_WARN_GE(...) [&] { return doctest::detail::ge(__VA_ARGS__); }() +#define DOCTEST_CHECK_GE(...) [&] { return doctest::detail::ge(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_GE(...) [&] { return doctest::detail::ge(__VA_ARGS__); }() +#define DOCTEST_WARN_UNARY(...) [&] { return __VA_ARGS__; }() +#define DOCTEST_CHECK_UNARY(...) [&] { return __VA_ARGS__; }() +#define DOCTEST_REQUIRE_UNARY(...) [&] { return __VA_ARGS__; }() +#define DOCTEST_WARN_UNARY_FALSE(...) [&] { return !(__VA_ARGS__); }() +#define DOCTEST_CHECK_UNARY_FALSE(...) [&] { return !(__VA_ARGS__); }() +#define DOCTEST_REQUIRE_UNARY_FALSE(...) [&] { return !(__VA_ARGS__); }() + +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + +#define DOCTEST_WARN_THROWS_WITH(expr, with, ...) [] { static_assert(false, "Exception translation is not available when doctest is disabled."); return false; }() +#define DOCTEST_CHECK_THROWS_WITH(expr, with, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_REQUIRE_THROWS_WITH(expr, with, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_WARN_THROWS_WITH_AS(expr, with, ex, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_CHECK_THROWS_WITH_AS(expr, with, ex, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_REQUIRE_THROWS_WITH_AS(expr, with, ex, ...) DOCTEST_WARN_THROWS_WITH(,,) + +#define DOCTEST_WARN_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_CHECK_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_REQUIRE_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_WARN_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_CHECK_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_WARN_THROWS_WITH(,,) +#define DOCTEST_REQUIRE_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_WARN_THROWS_WITH(,,) + +#define DOCTEST_WARN_THROWS(...) [&] { try { __VA_ARGS__; return false; } catch (...) { return true; } }() +#define DOCTEST_CHECK_THROWS(...) [&] { try { __VA_ARGS__; return false; } catch (...) { return true; } }() +#define DOCTEST_REQUIRE_THROWS(...) [&] { try { __VA_ARGS__; return false; } catch (...) { return true; } }() +#define DOCTEST_WARN_THROWS_AS(expr, ...) [&] { try { expr; } catch (__VA_ARGS__) { return true; } catch (...) { } return false; }() +#define DOCTEST_CHECK_THROWS_AS(expr, ...) [&] { try { expr; } catch (__VA_ARGS__) { return true; } catch (...) { } return false; }() +#define DOCTEST_REQUIRE_THROWS_AS(expr, ...) [&] { try { expr; } catch (__VA_ARGS__) { return true; } catch (...) { } return false; }() +#define DOCTEST_WARN_NOTHROW(...) [&] { try { __VA_ARGS__; return true; } catch (...) { return false; } }() +#define DOCTEST_CHECK_NOTHROW(...) [&] { try { __VA_ARGS__; return true; } catch (...) { return false; } }() +#define DOCTEST_REQUIRE_NOTHROW(...) [&] { try { __VA_ARGS__; return true; } catch (...) { return false; } }() + +#define DOCTEST_WARN_THROWS_MESSAGE(expr, ...) [&] { try { __VA_ARGS__; return false; } catch (...) { return true; } }() +#define DOCTEST_CHECK_THROWS_MESSAGE(expr, ...) [&] { try { __VA_ARGS__; return false; } catch (...) { return true; } }() +#define DOCTEST_REQUIRE_THROWS_MESSAGE(expr, ...) [&] { try { __VA_ARGS__; return false; } catch (...) { return true; } }() +#define DOCTEST_WARN_THROWS_AS_MESSAGE(expr, ex, ...) [&] { try { expr; } catch (__VA_ARGS__) { return true; } catch (...) { } return false; }() +#define DOCTEST_CHECK_THROWS_AS_MESSAGE(expr, ex, ...) [&] { try { expr; } catch (__VA_ARGS__) { return true; } catch (...) { } return false; }() +#define DOCTEST_REQUIRE_THROWS_AS_MESSAGE(expr, ex, ...) [&] { try { expr; } catch (__VA_ARGS__) { return true; } catch (...) { } return false; }() +#define DOCTEST_WARN_NOTHROW_MESSAGE(expr, ...) [&] { try { __VA_ARGS__; return true; } catch (...) { return false; } }() +#define DOCTEST_CHECK_NOTHROW_MESSAGE(expr, ...) [&] { try { __VA_ARGS__; return true; } catch (...) { return false; } }() +#define DOCTEST_REQUIRE_NOTHROW_MESSAGE(expr, ...) [&] { try { __VA_ARGS__; return true; } catch (...) { return false; } }() + +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + +#else // DOCTEST_CONFIG_EVALUATE_ASSERTS_EVEN_WHEN_DISABLED + +#define DOCTEST_WARN(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_FALSE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_FALSE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_FALSE(...) DOCTEST_FUNC_EMPTY + +#define DOCTEST_WARN_MESSAGE(cond, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_MESSAGE(cond, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_MESSAGE(cond, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_FALSE_MESSAGE(cond, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_FALSE_MESSAGE(cond, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_FALSE_MESSAGE(cond, ...) DOCTEST_FUNC_EMPTY + +#define DOCTEST_WARN_EQ(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_EQ(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_EQ(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_NE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_NE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_NE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_GT(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_GT(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_GT(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_LT(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_LT(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_LT(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_GE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_GE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_GE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_LE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_LE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_LE(...) DOCTEST_FUNC_EMPTY + +#define DOCTEST_WARN_UNARY(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_UNARY(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_UNARY(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_UNARY_FALSE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_UNARY_FALSE(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_UNARY_FALSE(...) DOCTEST_FUNC_EMPTY + +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + +#define DOCTEST_WARN_THROWS(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_THROWS_AS(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS_AS(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS_AS(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_THROWS_WITH(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS_WITH(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS_WITH(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_THROWS_WITH_AS(expr, with, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS_WITH_AS(expr, with, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS_WITH_AS(expr, with, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_NOTHROW(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_NOTHROW(...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_NOTHROW(...) DOCTEST_FUNC_EMPTY + +#define DOCTEST_WARN_THROWS_MESSAGE(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS_MESSAGE(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS_MESSAGE(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_WARN_NOTHROW_MESSAGE(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_CHECK_NOTHROW_MESSAGE(expr, ...) DOCTEST_FUNC_EMPTY +#define DOCTEST_REQUIRE_NOTHROW_MESSAGE(expr, ...) DOCTEST_FUNC_EMPTY + +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + +#endif // DOCTEST_CONFIG_EVALUATE_ASSERTS_EVEN_WHEN_DISABLED + +#endif // DOCTEST_CONFIG_DISABLE + +#ifdef DOCTEST_CONFIG_NO_EXCEPTIONS + +#ifdef DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS +#define DOCTEST_EXCEPTION_EMPTY_FUNC DOCTEST_FUNC_EMPTY +#else // DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS +#define DOCTEST_EXCEPTION_EMPTY_FUNC [] { static_assert(false, "Exceptions are disabled! " \ + "Use DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS if you want to compile with exceptions disabled."); return false; }() + +#undef DOCTEST_REQUIRE +#undef DOCTEST_REQUIRE_FALSE +#undef DOCTEST_REQUIRE_MESSAGE +#undef DOCTEST_REQUIRE_FALSE_MESSAGE +#undef DOCTEST_REQUIRE_EQ +#undef DOCTEST_REQUIRE_NE +#undef DOCTEST_REQUIRE_GT +#undef DOCTEST_REQUIRE_LT +#undef DOCTEST_REQUIRE_GE +#undef DOCTEST_REQUIRE_LE +#undef DOCTEST_REQUIRE_UNARY +#undef DOCTEST_REQUIRE_UNARY_FALSE + +#define DOCTEST_REQUIRE DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_FALSE DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_MESSAGE DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_FALSE_MESSAGE DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_EQ DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_NE DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_GT DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_LT DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_GE DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_LE DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_UNARY DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_UNARY_FALSE DOCTEST_EXCEPTION_EMPTY_FUNC + +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS + +#define DOCTEST_WARN_THROWS(...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS(...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS(...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_THROWS_AS(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS_AS(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS_AS(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_THROWS_WITH(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS_WITH(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS_WITH(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_THROWS_WITH_AS(expr, with, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS_WITH_AS(expr, with, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS_WITH_AS(expr, with, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_NOTHROW(...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_NOTHROW(...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_NOTHROW(...) DOCTEST_EXCEPTION_EMPTY_FUNC + +#define DOCTEST_WARN_THROWS_MESSAGE(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS_MESSAGE(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS_MESSAGE(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_WARN_NOTHROW_MESSAGE(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_CHECK_NOTHROW_MESSAGE(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC +#define DOCTEST_REQUIRE_NOTHROW_MESSAGE(expr, ...) DOCTEST_EXCEPTION_EMPTY_FUNC + +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + +// clang-format off +// KEPT FOR BACKWARDS COMPATIBILITY - FORWARDING TO THE RIGHT MACROS +#define DOCTEST_FAST_WARN_EQ DOCTEST_WARN_EQ +#define DOCTEST_FAST_CHECK_EQ DOCTEST_CHECK_EQ +#define DOCTEST_FAST_REQUIRE_EQ DOCTEST_REQUIRE_EQ +#define DOCTEST_FAST_WARN_NE DOCTEST_WARN_NE +#define DOCTEST_FAST_CHECK_NE DOCTEST_CHECK_NE +#define DOCTEST_FAST_REQUIRE_NE DOCTEST_REQUIRE_NE +#define DOCTEST_FAST_WARN_GT DOCTEST_WARN_GT +#define DOCTEST_FAST_CHECK_GT DOCTEST_CHECK_GT +#define DOCTEST_FAST_REQUIRE_GT DOCTEST_REQUIRE_GT +#define DOCTEST_FAST_WARN_LT DOCTEST_WARN_LT +#define DOCTEST_FAST_CHECK_LT DOCTEST_CHECK_LT +#define DOCTEST_FAST_REQUIRE_LT DOCTEST_REQUIRE_LT +#define DOCTEST_FAST_WARN_GE DOCTEST_WARN_GE +#define DOCTEST_FAST_CHECK_GE DOCTEST_CHECK_GE +#define DOCTEST_FAST_REQUIRE_GE DOCTEST_REQUIRE_GE +#define DOCTEST_FAST_WARN_LE DOCTEST_WARN_LE +#define DOCTEST_FAST_CHECK_LE DOCTEST_CHECK_LE +#define DOCTEST_FAST_REQUIRE_LE DOCTEST_REQUIRE_LE + +#define DOCTEST_FAST_WARN_UNARY DOCTEST_WARN_UNARY +#define DOCTEST_FAST_CHECK_UNARY DOCTEST_CHECK_UNARY +#define DOCTEST_FAST_REQUIRE_UNARY DOCTEST_REQUIRE_UNARY +#define DOCTEST_FAST_WARN_UNARY_FALSE DOCTEST_WARN_UNARY_FALSE +#define DOCTEST_FAST_CHECK_UNARY_FALSE DOCTEST_CHECK_UNARY_FALSE +#define DOCTEST_FAST_REQUIRE_UNARY_FALSE DOCTEST_REQUIRE_UNARY_FALSE + +#define DOCTEST_TEST_CASE_TEMPLATE_INSTANTIATE(id, ...) DOCTEST_TEST_CASE_TEMPLATE_INVOKE(id,__VA_ARGS__) +// clang-format on + +// BDD style macros +// clang-format off +#define DOCTEST_SCENARIO(name) DOCTEST_TEST_CASE(" Scenario: " name) +#define DOCTEST_SCENARIO_CLASS(name) DOCTEST_TEST_CASE_CLASS(" Scenario: " name) +#define DOCTEST_SCENARIO_TEMPLATE(name, T, ...) DOCTEST_TEST_CASE_TEMPLATE(" Scenario: " name, T, __VA_ARGS__) +#define DOCTEST_SCENARIO_TEMPLATE_DEFINE(name, T, id) DOCTEST_TEST_CASE_TEMPLATE_DEFINE(" Scenario: " name, T, id) + +#define DOCTEST_GIVEN(name) DOCTEST_SUBCASE(" Given: " name) +#define DOCTEST_WHEN(name) DOCTEST_SUBCASE(" When: " name) +#define DOCTEST_AND_WHEN(name) DOCTEST_SUBCASE("And when: " name) +#define DOCTEST_THEN(name) DOCTEST_SUBCASE(" Then: " name) +#define DOCTEST_AND_THEN(name) DOCTEST_SUBCASE(" And: " name) +// clang-format on + +// == SHORT VERSIONS OF THE MACROS +#ifndef DOCTEST_CONFIG_NO_SHORT_MACRO_NAMES + +#define TEST_CASE(name) DOCTEST_TEST_CASE(name) +#define TEST_CASE_CLASS(name) DOCTEST_TEST_CASE_CLASS(name) +#define TEST_CASE_FIXTURE(x, name) DOCTEST_TEST_CASE_FIXTURE(x, name) +#define TYPE_TO_STRING_AS(str, ...) DOCTEST_TYPE_TO_STRING_AS(str, __VA_ARGS__) +#define TYPE_TO_STRING(...) DOCTEST_TYPE_TO_STRING(__VA_ARGS__) +#define TEST_CASE_TEMPLATE(name, T, ...) DOCTEST_TEST_CASE_TEMPLATE(name, T, __VA_ARGS__) +#define TEST_CASE_TEMPLATE_DEFINE(name, T, id) DOCTEST_TEST_CASE_TEMPLATE_DEFINE(name, T, id) +#define TEST_CASE_TEMPLATE_INVOKE(id, ...) DOCTEST_TEST_CASE_TEMPLATE_INVOKE(id, __VA_ARGS__) +#define TEST_CASE_TEMPLATE_APPLY(id, ...) DOCTEST_TEST_CASE_TEMPLATE_APPLY(id, __VA_ARGS__) +#define SUBCASE(name) DOCTEST_SUBCASE(name) +#define TEST_SUITE(decorators) DOCTEST_TEST_SUITE(decorators) +#define TEST_SUITE_BEGIN(name) DOCTEST_TEST_SUITE_BEGIN(name) +#define TEST_SUITE_END DOCTEST_TEST_SUITE_END +#define REGISTER_EXCEPTION_TRANSLATOR(signature) DOCTEST_REGISTER_EXCEPTION_TRANSLATOR(signature) +#define REGISTER_REPORTER(name, priority, reporter) DOCTEST_REGISTER_REPORTER(name, priority, reporter) +#define REGISTER_LISTENER(name, priority, reporter) DOCTEST_REGISTER_LISTENER(name, priority, reporter) +#define INFO(...) DOCTEST_INFO(__VA_ARGS__) +#define CAPTURE(x) DOCTEST_CAPTURE(x) +#define ADD_MESSAGE_AT(file, line, ...) DOCTEST_ADD_MESSAGE_AT(file, line, __VA_ARGS__) +#define ADD_FAIL_CHECK_AT(file, line, ...) DOCTEST_ADD_FAIL_CHECK_AT(file, line, __VA_ARGS__) +#define ADD_FAIL_AT(file, line, ...) DOCTEST_ADD_FAIL_AT(file, line, __VA_ARGS__) +#define MESSAGE(...) DOCTEST_MESSAGE(__VA_ARGS__) +#define FAIL_CHECK(...) DOCTEST_FAIL_CHECK(__VA_ARGS__) +#define FAIL(...) DOCTEST_FAIL(__VA_ARGS__) +#define TO_LVALUE(...) DOCTEST_TO_LVALUE(__VA_ARGS__) + +#define WARN(...) DOCTEST_WARN(__VA_ARGS__) +#define WARN_FALSE(...) DOCTEST_WARN_FALSE(__VA_ARGS__) +#define WARN_THROWS(...) DOCTEST_WARN_THROWS(__VA_ARGS__) +#define WARN_THROWS_AS(expr, ...) DOCTEST_WARN_THROWS_AS(expr, __VA_ARGS__) +#define WARN_THROWS_WITH(expr, ...) DOCTEST_WARN_THROWS_WITH(expr, __VA_ARGS__) +#define WARN_THROWS_WITH_AS(expr, with, ...) DOCTEST_WARN_THROWS_WITH_AS(expr, with, __VA_ARGS__) +#define WARN_NOTHROW(...) DOCTEST_WARN_NOTHROW(__VA_ARGS__) +#define CHECK(...) DOCTEST_CHECK(__VA_ARGS__) +#define CHECK_FALSE(...) DOCTEST_CHECK_FALSE(__VA_ARGS__) +#define CHECK_THROWS(...) DOCTEST_CHECK_THROWS(__VA_ARGS__) +#define CHECK_THROWS_AS(expr, ...) DOCTEST_CHECK_THROWS_AS(expr, __VA_ARGS__) +#define CHECK_THROWS_WITH(expr, ...) DOCTEST_CHECK_THROWS_WITH(expr, __VA_ARGS__) +#define CHECK_THROWS_WITH_AS(expr, with, ...) DOCTEST_CHECK_THROWS_WITH_AS(expr, with, __VA_ARGS__) +#define CHECK_NOTHROW(...) DOCTEST_CHECK_NOTHROW(__VA_ARGS__) +#define REQUIRE(...) DOCTEST_REQUIRE(__VA_ARGS__) +#define REQUIRE_FALSE(...) DOCTEST_REQUIRE_FALSE(__VA_ARGS__) +#define REQUIRE_THROWS(...) DOCTEST_REQUIRE_THROWS(__VA_ARGS__) +#define REQUIRE_THROWS_AS(expr, ...) DOCTEST_REQUIRE_THROWS_AS(expr, __VA_ARGS__) +#define REQUIRE_THROWS_WITH(expr, ...) DOCTEST_REQUIRE_THROWS_WITH(expr, __VA_ARGS__) +#define REQUIRE_THROWS_WITH_AS(expr, with, ...) DOCTEST_REQUIRE_THROWS_WITH_AS(expr, with, __VA_ARGS__) +#define REQUIRE_NOTHROW(...) DOCTEST_REQUIRE_NOTHROW(__VA_ARGS__) + +#define WARN_MESSAGE(cond, ...) DOCTEST_WARN_MESSAGE(cond, __VA_ARGS__) +#define WARN_FALSE_MESSAGE(cond, ...) DOCTEST_WARN_FALSE_MESSAGE(cond, __VA_ARGS__) +#define WARN_THROWS_MESSAGE(expr, ...) DOCTEST_WARN_THROWS_MESSAGE(expr, __VA_ARGS__) +#define WARN_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_WARN_THROWS_AS_MESSAGE(expr, ex, __VA_ARGS__) +#define WARN_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_WARN_THROWS_WITH_MESSAGE(expr, with, __VA_ARGS__) +#define WARN_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_WARN_THROWS_WITH_AS_MESSAGE(expr, with, ex, __VA_ARGS__) +#define WARN_NOTHROW_MESSAGE(expr, ...) DOCTEST_WARN_NOTHROW_MESSAGE(expr, __VA_ARGS__) +#define CHECK_MESSAGE(cond, ...) DOCTEST_CHECK_MESSAGE(cond, __VA_ARGS__) +#define CHECK_FALSE_MESSAGE(cond, ...) DOCTEST_CHECK_FALSE_MESSAGE(cond, __VA_ARGS__) +#define CHECK_THROWS_MESSAGE(expr, ...) DOCTEST_CHECK_THROWS_MESSAGE(expr, __VA_ARGS__) +#define CHECK_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_CHECK_THROWS_AS_MESSAGE(expr, ex, __VA_ARGS__) +#define CHECK_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_CHECK_THROWS_WITH_MESSAGE(expr, with, __VA_ARGS__) +#define CHECK_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_CHECK_THROWS_WITH_AS_MESSAGE(expr, with, ex, __VA_ARGS__) +#define CHECK_NOTHROW_MESSAGE(expr, ...) DOCTEST_CHECK_NOTHROW_MESSAGE(expr, __VA_ARGS__) +#define REQUIRE_MESSAGE(cond, ...) DOCTEST_REQUIRE_MESSAGE(cond, __VA_ARGS__) +#define REQUIRE_FALSE_MESSAGE(cond, ...) DOCTEST_REQUIRE_FALSE_MESSAGE(cond, __VA_ARGS__) +#define REQUIRE_THROWS_MESSAGE(expr, ...) DOCTEST_REQUIRE_THROWS_MESSAGE(expr, __VA_ARGS__) +#define REQUIRE_THROWS_AS_MESSAGE(expr, ex, ...) DOCTEST_REQUIRE_THROWS_AS_MESSAGE(expr, ex, __VA_ARGS__) +#define REQUIRE_THROWS_WITH_MESSAGE(expr, with, ...) DOCTEST_REQUIRE_THROWS_WITH_MESSAGE(expr, with, __VA_ARGS__) +#define REQUIRE_THROWS_WITH_AS_MESSAGE(expr, with, ex, ...) DOCTEST_REQUIRE_THROWS_WITH_AS_MESSAGE(expr, with, ex, __VA_ARGS__) +#define REQUIRE_NOTHROW_MESSAGE(expr, ...) DOCTEST_REQUIRE_NOTHROW_MESSAGE(expr, __VA_ARGS__) + +#define SCENARIO(name) DOCTEST_SCENARIO(name) +#define SCENARIO_CLASS(name) DOCTEST_SCENARIO_CLASS(name) +#define SCENARIO_TEMPLATE(name, T, ...) DOCTEST_SCENARIO_TEMPLATE(name, T, __VA_ARGS__) +#define SCENARIO_TEMPLATE_DEFINE(name, T, id) DOCTEST_SCENARIO_TEMPLATE_DEFINE(name, T, id) +#define GIVEN(name) DOCTEST_GIVEN(name) +#define WHEN(name) DOCTEST_WHEN(name) +#define AND_WHEN(name) DOCTEST_AND_WHEN(name) +#define THEN(name) DOCTEST_THEN(name) +#define AND_THEN(name) DOCTEST_AND_THEN(name) + +#define WARN_EQ(...) DOCTEST_WARN_EQ(__VA_ARGS__) +#define CHECK_EQ(...) DOCTEST_CHECK_EQ(__VA_ARGS__) +#define REQUIRE_EQ(...) DOCTEST_REQUIRE_EQ(__VA_ARGS__) +#define WARN_NE(...) DOCTEST_WARN_NE(__VA_ARGS__) +#define CHECK_NE(...) DOCTEST_CHECK_NE(__VA_ARGS__) +#define REQUIRE_NE(...) DOCTEST_REQUIRE_NE(__VA_ARGS__) +#define WARN_GT(...) DOCTEST_WARN_GT(__VA_ARGS__) +#define CHECK_GT(...) DOCTEST_CHECK_GT(__VA_ARGS__) +#define REQUIRE_GT(...) DOCTEST_REQUIRE_GT(__VA_ARGS__) +#define WARN_LT(...) DOCTEST_WARN_LT(__VA_ARGS__) +#define CHECK_LT(...) DOCTEST_CHECK_LT(__VA_ARGS__) +#define REQUIRE_LT(...) DOCTEST_REQUIRE_LT(__VA_ARGS__) +#define WARN_GE(...) DOCTEST_WARN_GE(__VA_ARGS__) +#define CHECK_GE(...) DOCTEST_CHECK_GE(__VA_ARGS__) +#define REQUIRE_GE(...) DOCTEST_REQUIRE_GE(__VA_ARGS__) +#define WARN_LE(...) DOCTEST_WARN_LE(__VA_ARGS__) +#define CHECK_LE(...) DOCTEST_CHECK_LE(__VA_ARGS__) +#define REQUIRE_LE(...) DOCTEST_REQUIRE_LE(__VA_ARGS__) +#define WARN_UNARY(...) DOCTEST_WARN_UNARY(__VA_ARGS__) +#define CHECK_UNARY(...) DOCTEST_CHECK_UNARY(__VA_ARGS__) +#define REQUIRE_UNARY(...) DOCTEST_REQUIRE_UNARY(__VA_ARGS__) +#define WARN_UNARY_FALSE(...) DOCTEST_WARN_UNARY_FALSE(__VA_ARGS__) +#define CHECK_UNARY_FALSE(...) DOCTEST_CHECK_UNARY_FALSE(__VA_ARGS__) +#define REQUIRE_UNARY_FALSE(...) DOCTEST_REQUIRE_UNARY_FALSE(__VA_ARGS__) + +// KEPT FOR BACKWARDS COMPATIBILITY +#define FAST_WARN_EQ(...) DOCTEST_FAST_WARN_EQ(__VA_ARGS__) +#define FAST_CHECK_EQ(...) DOCTEST_FAST_CHECK_EQ(__VA_ARGS__) +#define FAST_REQUIRE_EQ(...) DOCTEST_FAST_REQUIRE_EQ(__VA_ARGS__) +#define FAST_WARN_NE(...) DOCTEST_FAST_WARN_NE(__VA_ARGS__) +#define FAST_CHECK_NE(...) DOCTEST_FAST_CHECK_NE(__VA_ARGS__) +#define FAST_REQUIRE_NE(...) DOCTEST_FAST_REQUIRE_NE(__VA_ARGS__) +#define FAST_WARN_GT(...) DOCTEST_FAST_WARN_GT(__VA_ARGS__) +#define FAST_CHECK_GT(...) DOCTEST_FAST_CHECK_GT(__VA_ARGS__) +#define FAST_REQUIRE_GT(...) DOCTEST_FAST_REQUIRE_GT(__VA_ARGS__) +#define FAST_WARN_LT(...) DOCTEST_FAST_WARN_LT(__VA_ARGS__) +#define FAST_CHECK_LT(...) DOCTEST_FAST_CHECK_LT(__VA_ARGS__) +#define FAST_REQUIRE_LT(...) DOCTEST_FAST_REQUIRE_LT(__VA_ARGS__) +#define FAST_WARN_GE(...) DOCTEST_FAST_WARN_GE(__VA_ARGS__) +#define FAST_CHECK_GE(...) DOCTEST_FAST_CHECK_GE(__VA_ARGS__) +#define FAST_REQUIRE_GE(...) DOCTEST_FAST_REQUIRE_GE(__VA_ARGS__) +#define FAST_WARN_LE(...) DOCTEST_FAST_WARN_LE(__VA_ARGS__) +#define FAST_CHECK_LE(...) DOCTEST_FAST_CHECK_LE(__VA_ARGS__) +#define FAST_REQUIRE_LE(...) DOCTEST_FAST_REQUIRE_LE(__VA_ARGS__) + +#define FAST_WARN_UNARY(...) DOCTEST_FAST_WARN_UNARY(__VA_ARGS__) +#define FAST_CHECK_UNARY(...) DOCTEST_FAST_CHECK_UNARY(__VA_ARGS__) +#define FAST_REQUIRE_UNARY(...) DOCTEST_FAST_REQUIRE_UNARY(__VA_ARGS__) +#define FAST_WARN_UNARY_FALSE(...) DOCTEST_FAST_WARN_UNARY_FALSE(__VA_ARGS__) +#define FAST_CHECK_UNARY_FALSE(...) DOCTEST_FAST_CHECK_UNARY_FALSE(__VA_ARGS__) +#define FAST_REQUIRE_UNARY_FALSE(...) DOCTEST_FAST_REQUIRE_UNARY_FALSE(__VA_ARGS__) + +#define TEST_CASE_TEMPLATE_INSTANTIATE(id, ...) DOCTEST_TEST_CASE_TEMPLATE_INSTANTIATE(id, __VA_ARGS__) + +#endif // DOCTEST_CONFIG_NO_SHORT_MACRO_NAMES + +#ifndef DOCTEST_CONFIG_DISABLE + +// this is here to clear the 'current test suite' for the current translation unit - at the top +DOCTEST_TEST_SUITE_END(); + +#endif // DOCTEST_CONFIG_DISABLE + +DOCTEST_CLANG_SUPPRESS_WARNING_POP +DOCTEST_MSVC_SUPPRESS_WARNING_POP +DOCTEST_GCC_SUPPRESS_WARNING_POP + +DOCTEST_SUPPRESS_COMMON_WARNINGS_POP + +#endif // DOCTEST_LIBRARY_INCLUDED + +#ifndef DOCTEST_SINGLE_HEADER +#define DOCTEST_SINGLE_HEADER +#endif // DOCTEST_SINGLE_HEADER + +#if defined(DOCTEST_CONFIG_IMPLEMENT) || !defined(DOCTEST_SINGLE_HEADER) + +#ifndef DOCTEST_SINGLE_HEADER +#include "doctest_fwd.h" +#endif // DOCTEST_SINGLE_HEADER + +DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wunused-macros") + +#ifndef DOCTEST_LIBRARY_IMPLEMENTATION +#define DOCTEST_LIBRARY_IMPLEMENTATION + +DOCTEST_CLANG_SUPPRESS_WARNING_POP + +DOCTEST_SUPPRESS_COMMON_WARNINGS_PUSH + +DOCTEST_CLANG_SUPPRESS_WARNING_PUSH +DOCTEST_CLANG_SUPPRESS_WARNING("-Wglobal-constructors") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wexit-time-destructors") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wsign-conversion") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wshorten-64-to-32") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wmissing-variable-declarations") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wswitch") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wswitch-enum") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wcovered-switch-default") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wmissing-noreturn") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wdisabled-macro-expansion") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wmissing-braces") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wmissing-field-initializers") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wunused-member-function") +DOCTEST_CLANG_SUPPRESS_WARNING("-Wnonportable-system-include-path") + +DOCTEST_GCC_SUPPRESS_WARNING_PUSH +DOCTEST_GCC_SUPPRESS_WARNING("-Wconversion") +DOCTEST_GCC_SUPPRESS_WARNING("-Wsign-conversion") +DOCTEST_GCC_SUPPRESS_WARNING("-Wmissing-field-initializers") +DOCTEST_GCC_SUPPRESS_WARNING("-Wmissing-braces") +DOCTEST_GCC_SUPPRESS_WARNING("-Wswitch") +DOCTEST_GCC_SUPPRESS_WARNING("-Wswitch-enum") +DOCTEST_GCC_SUPPRESS_WARNING("-Wswitch-default") +DOCTEST_GCC_SUPPRESS_WARNING("-Wunsafe-loop-optimizations") +DOCTEST_GCC_SUPPRESS_WARNING("-Wold-style-cast") +DOCTEST_GCC_SUPPRESS_WARNING("-Wunused-function") +DOCTEST_GCC_SUPPRESS_WARNING("-Wmultiple-inheritance") +DOCTEST_GCC_SUPPRESS_WARNING("-Wsuggest-attribute") + +DOCTEST_MSVC_SUPPRESS_WARNING_PUSH +DOCTEST_MSVC_SUPPRESS_WARNING(4267) // 'var' : conversion from 'x' to 'y', possible loss of data +DOCTEST_MSVC_SUPPRESS_WARNING(4530) // C++ exception handler used, but unwind semantics not enabled +DOCTEST_MSVC_SUPPRESS_WARNING(4577) // 'noexcept' used with no exception handling mode specified +DOCTEST_MSVC_SUPPRESS_WARNING(4774) // format string expected in argument is not a string literal +DOCTEST_MSVC_SUPPRESS_WARNING(4365) // conversion from 'int' to 'unsigned', signed/unsigned mismatch +DOCTEST_MSVC_SUPPRESS_WARNING(5039) // pointer to potentially throwing function passed to extern C +DOCTEST_MSVC_SUPPRESS_WARNING(4800) // forcing value to bool 'true' or 'false' (performance warning) +DOCTEST_MSVC_SUPPRESS_WARNING(5245) // unreferenced function with internal linkage has been removed + +DOCTEST_MAKE_STD_HEADERS_CLEAN_FROM_WARNINGS_ON_WALL_BEGIN + +// required includes - will go only in one translation unit! +#include +#include +#include +// borland (Embarcadero) compiler requires math.h and not cmath - https://github.com/doctest/doctest/pull/37 +#ifdef __BORLANDC__ +#include +#endif // __BORLANDC__ +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM +#include +#endif // DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM +#include +#include +#include +#ifndef DOCTEST_CONFIG_NO_MULTITHREADING +#include +#include +#define DOCTEST_DECLARE_MUTEX(name) std::mutex name; +#define DOCTEST_DECLARE_STATIC_MUTEX(name) static DOCTEST_DECLARE_MUTEX(name) +#define DOCTEST_LOCK_MUTEX(name) std::lock_guard DOCTEST_ANONYMOUS(DOCTEST_ANON_LOCK_)(name); +#else // DOCTEST_CONFIG_NO_MULTITHREADING +#define DOCTEST_DECLARE_MUTEX(name) +#define DOCTEST_DECLARE_STATIC_MUTEX(name) +#define DOCTEST_LOCK_MUTEX(name) +#endif // DOCTEST_CONFIG_NO_MULTITHREADING +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef DOCTEST_PLATFORM_MAC +#include +#include +#include +#endif // DOCTEST_PLATFORM_MAC + +#ifdef DOCTEST_PLATFORM_WINDOWS + +// defines for a leaner windows.h +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#define DOCTEST_UNDEF_WIN32_LEAN_AND_MEAN +#endif // WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#define DOCTEST_UNDEF_NOMINMAX +#endif // NOMINMAX + +// not sure what AfxWin.h is for - here I do what Catch does +#ifdef __AFXDLL +#include +#else +#include +#endif +#include + +#else // DOCTEST_PLATFORM_WINDOWS + +#include +#include + +#endif // DOCTEST_PLATFORM_WINDOWS + +// this is a fix for https://github.com/doctest/doctest/issues/348 +// https://mail.gnome.org/archives/xml/2012-January/msg00000.html +#if !defined(HAVE_UNISTD_H) && !defined(STDOUT_FILENO) +#define STDOUT_FILENO fileno(stdout) +#endif // HAVE_UNISTD_H + +DOCTEST_MAKE_STD_HEADERS_CLEAN_FROM_WARNINGS_ON_WALL_END + +// counts the number of elements in a C array +#define DOCTEST_COUNTOF(x) (sizeof(x) / sizeof(x[0])) + +#ifdef DOCTEST_CONFIG_DISABLE +#define DOCTEST_BRANCH_ON_DISABLED(if_disabled, if_not_disabled) if_disabled +#else // DOCTEST_CONFIG_DISABLE +#define DOCTEST_BRANCH_ON_DISABLED(if_disabled, if_not_disabled) if_not_disabled +#endif // DOCTEST_CONFIG_DISABLE + +#ifndef DOCTEST_CONFIG_OPTIONS_PREFIX +#define DOCTEST_CONFIG_OPTIONS_PREFIX "dt-" +#endif + +#ifndef DOCTEST_CONFIG_OPTIONS_FILE_PREFIX_SEPARATOR +#define DOCTEST_CONFIG_OPTIONS_FILE_PREFIX_SEPARATOR ':' +#endif + +#ifndef DOCTEST_THREAD_LOCAL +#if defined(DOCTEST_CONFIG_NO_MULTITHREADING) || DOCTEST_MSVC && (DOCTEST_MSVC < DOCTEST_COMPILER(19, 0, 0)) +#define DOCTEST_THREAD_LOCAL +#else // DOCTEST_MSVC +#define DOCTEST_THREAD_LOCAL thread_local +#endif // DOCTEST_MSVC +#endif // DOCTEST_THREAD_LOCAL + +#ifndef DOCTEST_MULTI_LANE_ATOMICS_THREAD_LANES +#define DOCTEST_MULTI_LANE_ATOMICS_THREAD_LANES 32 +#endif + +#ifndef DOCTEST_MULTI_LANE_ATOMICS_CACHE_LINE_SIZE +#define DOCTEST_MULTI_LANE_ATOMICS_CACHE_LINE_SIZE 64 +#endif + +#ifdef DOCTEST_CONFIG_NO_UNPREFIXED_OPTIONS +#define DOCTEST_OPTIONS_PREFIX_DISPLAY DOCTEST_CONFIG_OPTIONS_PREFIX +#else +#define DOCTEST_OPTIONS_PREFIX_DISPLAY "" +#endif + +#if defined(WINAPI_FAMILY) && (WINAPI_FAMILY == WINAPI_FAMILY_APP) +#define DOCTEST_CONFIG_NO_MULTI_LANE_ATOMICS +#endif + +#ifndef DOCTEST_CDECL +#define DOCTEST_CDECL __cdecl +#endif + +namespace doctest { + +bool is_running_in_test = false; + +namespace { + using namespace detail; + + template + DOCTEST_NORETURN void throw_exception(Ex const& e) { +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + throw e; +#else // DOCTEST_CONFIG_NO_EXCEPTIONS +#ifdef DOCTEST_CONFIG_HANDLE_EXCEPTION + DOCTEST_CONFIG_HANDLE_EXCEPTION(e); +#else // DOCTEST_CONFIG_HANDLE_EXCEPTION +#ifndef DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM + std::cerr << "doctest will terminate because it needed to throw an exception.\n" + << "The message was: " << e.what() << '\n'; +#endif // DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM +#endif // DOCTEST_CONFIG_HANDLE_EXCEPTION + std::terminate(); +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + } + +#ifndef DOCTEST_INTERNAL_ERROR +#define DOCTEST_INTERNAL_ERROR(msg) \ + throw_exception(std::logic_error( \ + __FILE__ ":" DOCTEST_TOSTR(__LINE__) ": Internal doctest error: " msg)) +#endif // DOCTEST_INTERNAL_ERROR + + // case insensitive strcmp + int stricmp(const char* a, const char* b) { + for(;; a++, b++) { + const int d = tolower(*a) - tolower(*b); + if(d != 0 || !*a) + return d; + } + } + + struct Endianness + { + enum Arch + { + Big, + Little + }; + + static Arch which() { + int x = 1; + // casting any data pointer to char* is allowed + auto ptr = reinterpret_cast(&x); + if(*ptr) + return Little; + return Big; + } + }; +} // namespace + +namespace detail { + DOCTEST_THREAD_LOCAL class + { + std::vector stack; + std::stringstream ss; + + public: + std::ostream* push() { + stack.push_back(ss.tellp()); + return &ss; + } + + String pop() { + if (stack.empty()) + DOCTEST_INTERNAL_ERROR("TLSS was empty when trying to pop!"); + + std::streampos pos = stack.back(); + stack.pop_back(); + unsigned sz = static_cast(ss.tellp() - pos); + ss.rdbuf()->pubseekpos(pos, std::ios::in | std::ios::out); + return String(ss, sz); + } + } g_oss; + + std::ostream* tlssPush() { + return g_oss.push(); + } + + String tlssPop() { + return g_oss.pop(); + } + +#ifndef DOCTEST_CONFIG_DISABLE + +namespace timer_large_integer +{ + +#if defined(DOCTEST_PLATFORM_WINDOWS) + using type = ULONGLONG; +#else // DOCTEST_PLATFORM_WINDOWS + using type = std::uint64_t; +#endif // DOCTEST_PLATFORM_WINDOWS +} + +using ticks_t = timer_large_integer::type; + +#ifdef DOCTEST_CONFIG_GETCURRENTTICKS + ticks_t getCurrentTicks() { return DOCTEST_CONFIG_GETCURRENTTICKS(); } +#elif defined(DOCTEST_PLATFORM_WINDOWS) + ticks_t getCurrentTicks() { + static LARGE_INTEGER hz = { {0} }, hzo = { {0} }; + if(!hz.QuadPart) { + QueryPerformanceFrequency(&hz); + QueryPerformanceCounter(&hzo); + } + LARGE_INTEGER t; + QueryPerformanceCounter(&t); + return ((t.QuadPart - hzo.QuadPart) * LONGLONG(1000000)) / hz.QuadPart; + } +#else // DOCTEST_PLATFORM_WINDOWS + ticks_t getCurrentTicks() { + timeval t; + gettimeofday(&t, nullptr); + return static_cast(t.tv_sec) * 1000000 + static_cast(t.tv_usec); + } +#endif // DOCTEST_PLATFORM_WINDOWS + + struct Timer + { + void start() { m_ticks = getCurrentTicks(); } + unsigned int getElapsedMicroseconds() const { + return static_cast(getCurrentTicks() - m_ticks); + } + //unsigned int getElapsedMilliseconds() const { + // return static_cast(getElapsedMicroseconds() / 1000); + //} + double getElapsedSeconds() const { return static_cast(getCurrentTicks() - m_ticks) / 1000000.0; } + + private: + ticks_t m_ticks = 0; + }; + +#ifdef DOCTEST_CONFIG_NO_MULTITHREADING + template + using Atomic = T; +#else // DOCTEST_CONFIG_NO_MULTITHREADING + template + using Atomic = std::atomic; +#endif // DOCTEST_CONFIG_NO_MULTITHREADING + +#if defined(DOCTEST_CONFIG_NO_MULTI_LANE_ATOMICS) || defined(DOCTEST_CONFIG_NO_MULTITHREADING) + template + using MultiLaneAtomic = Atomic; +#else // DOCTEST_CONFIG_NO_MULTI_LANE_ATOMICS + // Provides a multilane implementation of an atomic variable that supports add, sub, load, + // store. Instead of using a single atomic variable, this splits up into multiple ones, + // each sitting on a separate cache line. The goal is to provide a speedup when most + // operations are modifying. It achieves this with two properties: + // + // * Multiple atomics are used, so chance of congestion from the same atomic is reduced. + // * Each atomic sits on a separate cache line, so false sharing is reduced. + // + // The disadvantage is that there is a small overhead due to the use of TLS, and load/store + // is slower because all atomics have to be accessed. + template + class MultiLaneAtomic + { + struct CacheLineAlignedAtomic + { + Atomic atomic{}; + char padding[DOCTEST_MULTI_LANE_ATOMICS_CACHE_LINE_SIZE - sizeof(Atomic)]; + }; + CacheLineAlignedAtomic m_atomics[DOCTEST_MULTI_LANE_ATOMICS_THREAD_LANES]; + + static_assert(sizeof(CacheLineAlignedAtomic) == DOCTEST_MULTI_LANE_ATOMICS_CACHE_LINE_SIZE, + "guarantee one atomic takes exactly one cache line"); + + public: + T operator++() DOCTEST_NOEXCEPT { return fetch_add(1) + 1; } + + T operator++(int) DOCTEST_NOEXCEPT { return fetch_add(1); } + + T fetch_add(T arg, std::memory_order order = std::memory_order_seq_cst) DOCTEST_NOEXCEPT { + return myAtomic().fetch_add(arg, order); + } + + T fetch_sub(T arg, std::memory_order order = std::memory_order_seq_cst) DOCTEST_NOEXCEPT { + return myAtomic().fetch_sub(arg, order); + } + + operator T() const DOCTEST_NOEXCEPT { return load(); } + + T load(std::memory_order order = std::memory_order_seq_cst) const DOCTEST_NOEXCEPT { + auto result = T(); + for(auto const& c : m_atomics) { + result += c.atomic.load(order); + } + return result; + } + + T operator=(T desired) DOCTEST_NOEXCEPT { // lgtm [cpp/assignment-does-not-return-this] + store(desired); + return desired; + } + + void store(T desired, std::memory_order order = std::memory_order_seq_cst) DOCTEST_NOEXCEPT { + // first value becomes desired", all others become 0. + for(auto& c : m_atomics) { + c.atomic.store(desired, order); + desired = {}; + } + } + + private: + // Each thread has a different atomic that it operates on. If more than NumLanes threads + // use this, some will use the same atomic. So performance will degrade a bit, but still + // everything will work. + // + // The logic here is a bit tricky. The call should be as fast as possible, so that there + // is minimal to no overhead in determining the correct atomic for the current thread. + // + // 1. A global static counter laneCounter counts continuously up. + // 2. Each successive thread will use modulo operation of that counter so it gets an atomic + // assigned in a round-robin fashion. + // 3. This tlsLaneIdx is stored in the thread local data, so it is directly available with + // little overhead. + Atomic& myAtomic() DOCTEST_NOEXCEPT { + static Atomic laneCounter; + DOCTEST_THREAD_LOCAL size_t tlsLaneIdx = + laneCounter++ % DOCTEST_MULTI_LANE_ATOMICS_THREAD_LANES; + + return m_atomics[tlsLaneIdx].atomic; + } + }; +#endif // DOCTEST_CONFIG_NO_MULTI_LANE_ATOMICS + + // this holds both parameters from the command line and runtime data for tests + struct ContextState : ContextOptions, TestRunStats, CurrentTestCaseStats + { + MultiLaneAtomic numAssertsCurrentTest_atomic; + MultiLaneAtomic numAssertsFailedCurrentTest_atomic; + + std::vector> filters = decltype(filters)(9); // 9 different filters + + std::vector reporters_currently_used; + + assert_handler ah = nullptr; + + Timer timer; + + std::vector stringifiedContexts; // logging from INFO() due to an exception + + // stuff for subcases + bool reachedLeaf; + std::vector subcaseStack; + std::vector nextSubcaseStack; + std::unordered_set fullyTraversedSubcases; + size_t currentSubcaseDepth; + Atomic shouldLogCurrentException; + + void resetRunData() { + numTestCases = 0; + numTestCasesPassingFilters = 0; + numTestSuitesPassingFilters = 0; + numTestCasesFailed = 0; + numAsserts = 0; + numAssertsFailed = 0; + numAssertsCurrentTest = 0; + numAssertsFailedCurrentTest = 0; + } + + void finalizeTestCaseData() { + seconds = timer.getElapsedSeconds(); + + // update the non-atomic counters + numAsserts += numAssertsCurrentTest_atomic; + numAssertsFailed += numAssertsFailedCurrentTest_atomic; + numAssertsCurrentTest = numAssertsCurrentTest_atomic; + numAssertsFailedCurrentTest = numAssertsFailedCurrentTest_atomic; + + if(numAssertsFailedCurrentTest) + failure_flags |= TestCaseFailureReason::AssertFailure; + + if(Approx(currentTest->m_timeout).epsilon(DBL_EPSILON) != 0 && + Approx(seconds).epsilon(DBL_EPSILON) > currentTest->m_timeout) + failure_flags |= TestCaseFailureReason::Timeout; + + if(currentTest->m_should_fail) { + if(failure_flags) { + failure_flags |= TestCaseFailureReason::ShouldHaveFailedAndDid; + } else { + failure_flags |= TestCaseFailureReason::ShouldHaveFailedButDidnt; + } + } else if(failure_flags && currentTest->m_may_fail) { + failure_flags |= TestCaseFailureReason::CouldHaveFailedAndDid; + } else if(currentTest->m_expected_failures > 0) { + if(numAssertsFailedCurrentTest == currentTest->m_expected_failures) { + failure_flags |= TestCaseFailureReason::FailedExactlyNumTimes; + } else { + failure_flags |= TestCaseFailureReason::DidntFailExactlyNumTimes; + } + } + + bool ok_to_fail = (TestCaseFailureReason::ShouldHaveFailedAndDid & failure_flags) || + (TestCaseFailureReason::CouldHaveFailedAndDid & failure_flags) || + (TestCaseFailureReason::FailedExactlyNumTimes & failure_flags); + + // if any subcase has failed - the whole test case has failed + testCaseSuccess = !(failure_flags && !ok_to_fail); + if(!testCaseSuccess) + numTestCasesFailed++; + } + }; + + ContextState* g_cs = nullptr; + + // used to avoid locks for the debug output + // TODO: figure out if this is indeed necessary/correct - seems like either there still + // could be a race or that there wouldn't be a race even if using the context directly + DOCTEST_THREAD_LOCAL bool g_no_colors; + +#endif // DOCTEST_CONFIG_DISABLE +} // namespace detail + +char* String::allocate(size_type sz) { + if (sz <= last) { + buf[sz] = '\0'; + setLast(last - sz); + return buf; + } else { + setOnHeap(); + data.size = sz; + data.capacity = data.size + 1; + data.ptr = new char[data.capacity]; + data.ptr[sz] = '\0'; + return data.ptr; + } +} + +void String::setOnHeap() noexcept { *reinterpret_cast(&buf[last]) = 128; } +void String::setLast(size_type in) noexcept { buf[last] = char(in); } +void String::setSize(size_type sz) noexcept { + if (isOnStack()) { buf[sz] = '\0'; setLast(last - sz); } + else { data.ptr[sz] = '\0'; data.size = sz; } +} + +void String::copy(const String& other) { + if(other.isOnStack()) { + memcpy(buf, other.buf, len); + } else { + memcpy(allocate(other.data.size), other.data.ptr, other.data.size); + } +} + +String::String() noexcept { + buf[0] = '\0'; + setLast(); +} + +String::~String() { + if(!isOnStack()) + delete[] data.ptr; +} // NOLINT(clang-analyzer-cplusplus.NewDeleteLeaks) + +String::String(const char* in) + : String(in, strlen(in)) {} + +String::String(const char* in, size_type in_size) { + memcpy(allocate(in_size), in, in_size); +} + +String::String(std::istream& in, size_type in_size) { + in.read(allocate(in_size), in_size); +} + +String::String(const String& other) { copy(other); } + +String& String::operator=(const String& other) { + if(this != &other) { + if(!isOnStack()) + delete[] data.ptr; + + copy(other); + } + + return *this; +} + +String& String::operator+=(const String& other) { + const size_type my_old_size = size(); + const size_type other_size = other.size(); + const size_type total_size = my_old_size + other_size; + if(isOnStack()) { + if(total_size < len) { + // append to the current stack space + memcpy(buf + my_old_size, other.c_str(), other_size + 1); + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) + setLast(last - total_size); + } else { + // alloc new chunk + char* temp = new char[total_size + 1]; + // copy current data to new location before writing in the union + memcpy(temp, buf, my_old_size); // skip the +1 ('\0') for speed + // update data in union + setOnHeap(); + data.size = total_size; + data.capacity = data.size + 1; + data.ptr = temp; + // transfer the rest of the data + memcpy(data.ptr + my_old_size, other.c_str(), other_size + 1); + } + } else { + if(data.capacity > total_size) { + // append to the current heap block + data.size = total_size; + memcpy(data.ptr + my_old_size, other.c_str(), other_size + 1); + } else { + // resize + data.capacity *= 2; + if(data.capacity <= total_size) + data.capacity = total_size + 1; + // alloc new chunk + char* temp = new char[data.capacity]; + // copy current data to new location before releasing it + memcpy(temp, data.ptr, my_old_size); // skip the +1 ('\0') for speed + // release old chunk + delete[] data.ptr; + // update the rest of the union members + data.size = total_size; + data.ptr = temp; + // transfer the rest of the data + memcpy(data.ptr + my_old_size, other.c_str(), other_size + 1); + } + } + + return *this; +} + +String::String(String&& other) noexcept { + memcpy(buf, other.buf, len); + other.buf[0] = '\0'; + other.setLast(); +} + +String& String::operator=(String&& other) noexcept { + if(this != &other) { + if(!isOnStack()) + delete[] data.ptr; + memcpy(buf, other.buf, len); + other.buf[0] = '\0'; + other.setLast(); + } + return *this; +} + +char String::operator[](size_type i) const { + return const_cast(this)->operator[](i); +} + +char& String::operator[](size_type i) { + if(isOnStack()) + return reinterpret_cast(buf)[i]; + return data.ptr[i]; +} + +DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wmaybe-uninitialized") +String::size_type String::size() const { + if(isOnStack()) + return last - (size_type(buf[last]) & 31); // using "last" would work only if "len" is 32 + return data.size; +} +DOCTEST_GCC_SUPPRESS_WARNING_POP + +String::size_type String::capacity() const { + if(isOnStack()) + return len; + return data.capacity; +} + +String String::substr(size_type pos, size_type cnt) && { + cnt = std::min(cnt, size() - pos); + char* cptr = c_str(); + memmove(cptr, cptr + pos, cnt); + setSize(cnt); + return std::move(*this); +} + +String String::substr(size_type pos, size_type cnt) const & { + cnt = std::min(cnt, size() - pos); + return String{ c_str() + pos, cnt }; +} + +String::size_type String::find(char ch, size_type pos) const { + const char* begin = c_str(); + const char* end = begin + size(); + const char* it = begin + pos; + for (; it < end && *it != ch; it++); + if (it < end) { return static_cast(it - begin); } + else { return npos; } +} + +String::size_type String::rfind(char ch, size_type pos) const { + const char* begin = c_str(); + const char* it = begin + std::min(pos, size() - 1); + for (; it >= begin && *it != ch; it--); + if (it >= begin) { return static_cast(it - begin); } + else { return npos; } +} + +int String::compare(const char* other, bool no_case) const { + if(no_case) + return doctest::stricmp(c_str(), other); + return std::strcmp(c_str(), other); +} + +int String::compare(const String& other, bool no_case) const { + return compare(other.c_str(), no_case); +} + +String operator+(const String& lhs, const String& rhs) { return String(lhs) += rhs; } + +bool operator==(const String& lhs, const String& rhs) { return lhs.compare(rhs) == 0; } +bool operator!=(const String& lhs, const String& rhs) { return lhs.compare(rhs) != 0; } +bool operator< (const String& lhs, const String& rhs) { return lhs.compare(rhs) < 0; } +bool operator> (const String& lhs, const String& rhs) { return lhs.compare(rhs) > 0; } +bool operator<=(const String& lhs, const String& rhs) { return (lhs != rhs) ? lhs.compare(rhs) < 0 : true; } +bool operator>=(const String& lhs, const String& rhs) { return (lhs != rhs) ? lhs.compare(rhs) > 0 : true; } + +std::ostream& operator<<(std::ostream& s, const String& in) { return s << in.c_str(); } + +Contains::Contains(const String& str) : string(str) { } + +bool Contains::checkWith(const String& other) const { + return strstr(other.c_str(), string.c_str()) != nullptr; +} + +String toString(const Contains& in) { + return "Contains( " + in.string + " )"; +} + +bool operator==(const String& lhs, const Contains& rhs) { return rhs.checkWith(lhs); } +bool operator==(const Contains& lhs, const String& rhs) { return lhs.checkWith(rhs); } +bool operator!=(const String& lhs, const Contains& rhs) { return !rhs.checkWith(lhs); } +bool operator!=(const Contains& lhs, const String& rhs) { return !lhs.checkWith(rhs); } + +namespace { + void color_to_stream(std::ostream&, Color::Enum) DOCTEST_BRANCH_ON_DISABLED({}, ;) +} // namespace + +namespace Color { + std::ostream& operator<<(std::ostream& s, Color::Enum code) { + color_to_stream(s, code); + return s; + } +} // namespace Color + +// clang-format off +const char* assertString(assertType::Enum at) { + DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4061) // enum 'x' in switch of enum 'y' is not explicitly handled + #define DOCTEST_GENERATE_ASSERT_TYPE_CASE(assert_type) case assertType::DT_ ## assert_type: return #assert_type + #define DOCTEST_GENERATE_ASSERT_TYPE_CASES(assert_type) \ + DOCTEST_GENERATE_ASSERT_TYPE_CASE(WARN_ ## assert_type); \ + DOCTEST_GENERATE_ASSERT_TYPE_CASE(CHECK_ ## assert_type); \ + DOCTEST_GENERATE_ASSERT_TYPE_CASE(REQUIRE_ ## assert_type) + switch(at) { + DOCTEST_GENERATE_ASSERT_TYPE_CASE(WARN); + DOCTEST_GENERATE_ASSERT_TYPE_CASE(CHECK); + DOCTEST_GENERATE_ASSERT_TYPE_CASE(REQUIRE); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(FALSE); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(THROWS); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(THROWS_AS); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(THROWS_WITH); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(THROWS_WITH_AS); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(NOTHROW); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(EQ); + DOCTEST_GENERATE_ASSERT_TYPE_CASES(NE); + DOCTEST_GENERATE_ASSERT_TYPE_CASES(GT); + DOCTEST_GENERATE_ASSERT_TYPE_CASES(LT); + DOCTEST_GENERATE_ASSERT_TYPE_CASES(GE); + DOCTEST_GENERATE_ASSERT_TYPE_CASES(LE); + + DOCTEST_GENERATE_ASSERT_TYPE_CASES(UNARY); + DOCTEST_GENERATE_ASSERT_TYPE_CASES(UNARY_FALSE); + + default: DOCTEST_INTERNAL_ERROR("Tried stringifying invalid assert type!"); + } + DOCTEST_MSVC_SUPPRESS_WARNING_POP +} +// clang-format on + +const char* failureString(assertType::Enum at) { + if(at & assertType::is_warn) //!OCLINT bitwise operator in conditional + return "WARNING"; + if(at & assertType::is_check) //!OCLINT bitwise operator in conditional + return "ERROR"; + if(at & assertType::is_require) //!OCLINT bitwise operator in conditional + return "FATAL ERROR"; + return ""; +} + +DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wnull-dereference") +DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wnull-dereference") +// depending on the current options this will remove the path of filenames +const char* skipPathFromFilename(const char* file) { +#ifndef DOCTEST_CONFIG_DISABLE + if(getContextOptions()->no_path_in_filenames) { + auto back = std::strrchr(file, '\\'); + auto forward = std::strrchr(file, '/'); + if(back || forward) { + if(back > forward) + forward = back; + return forward + 1; + } + } else { + const auto prefixes = getContextOptions()->strip_file_prefixes; + const char separator = DOCTEST_CONFIG_OPTIONS_FILE_PREFIX_SEPARATOR; + String::size_type longest_match = 0U; + for(String::size_type pos = 0U; pos < prefixes.size(); ++pos) + { + const auto prefix_start = pos; + pos = std::min(prefixes.find(separator, prefix_start), prefixes.size()); + + const auto prefix_size = pos - prefix_start; + if(prefix_size > longest_match) + { + // TODO under DOCTEST_MSVC: does the comparison need strnicmp() to work with drive letter capitalization? + if(0 == std::strncmp(prefixes.c_str() + prefix_start, file, prefix_size)) + { + longest_match = prefix_size; + } + } + } + return &file[longest_match]; + } +#endif // DOCTEST_CONFIG_DISABLE + return file; +} +DOCTEST_CLANG_SUPPRESS_WARNING_POP +DOCTEST_GCC_SUPPRESS_WARNING_POP + +bool SubcaseSignature::operator==(const SubcaseSignature& other) const { + return m_line == other.m_line + && std::strcmp(m_file, other.m_file) == 0 + && m_name == other.m_name; +} + +bool SubcaseSignature::operator<(const SubcaseSignature& other) const { + if(m_line != other.m_line) + return m_line < other.m_line; + if(std::strcmp(m_file, other.m_file) != 0) + return std::strcmp(m_file, other.m_file) < 0; + return m_name.compare(other.m_name) < 0; +} + +DOCTEST_DEFINE_INTERFACE(IContextScope) + +namespace detail { + void filldata::fill(std::ostream* stream, const void* in) { + if (in) { *stream << in; } + else { *stream << "nullptr"; } + } + + template + String toStreamLit(T t) { + std::ostream* os = tlssPush(); + os->operator<<(t); + return tlssPop(); + } +} + +#ifdef DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING +String toString(const char* in) { return String("\"") + (in ? in : "{null string}") + "\""; } +#endif // DOCTEST_CONFIG_TREAT_CHAR_STAR_AS_STRING + +#if DOCTEST_MSVC >= DOCTEST_COMPILER(19, 20, 0) +// see this issue on why this is needed: https://github.com/doctest/doctest/issues/183 +String toString(const std::string& in) { return in.c_str(); } +#endif // VS 2019 + +String toString(String in) { return in; } + +String toString(std::nullptr_t) { return "nullptr"; } + +String toString(bool in) { return in ? "true" : "false"; } + +String toString(float in) { return toStreamLit(in); } +String toString(double in) { return toStreamLit(in); } +String toString(double long in) { return toStreamLit(in); } + +String toString(char in) { return toStreamLit(static_cast(in)); } +String toString(char signed in) { return toStreamLit(static_cast(in)); } +String toString(char unsigned in) { return toStreamLit(static_cast(in)); } +String toString(short in) { return toStreamLit(in); } +String toString(short unsigned in) { return toStreamLit(in); } +String toString(signed in) { return toStreamLit(in); } +String toString(unsigned in) { return toStreamLit(in); } +String toString(long in) { return toStreamLit(in); } +String toString(long unsigned in) { return toStreamLit(in); } +String toString(long long in) { return toStreamLit(in); } +String toString(long long unsigned in) { return toStreamLit(in); } + +Approx::Approx(double value) + : m_epsilon(static_cast(std::numeric_limits::epsilon()) * 100) + , m_scale(1.0) + , m_value(value) {} + +Approx Approx::operator()(double value) const { + Approx approx(value); + approx.epsilon(m_epsilon); + approx.scale(m_scale); + return approx; +} + +Approx& Approx::epsilon(double newEpsilon) { + m_epsilon = newEpsilon; + return *this; +} +Approx& Approx::scale(double newScale) { + m_scale = newScale; + return *this; +} + +bool operator==(double lhs, const Approx& rhs) { + // Thanks to Richard Harris for his help refining this formula + return std::fabs(lhs - rhs.m_value) < + rhs.m_epsilon * (rhs.m_scale + std::max(std::fabs(lhs), std::fabs(rhs.m_value))); +} +bool operator==(const Approx& lhs, double rhs) { return operator==(rhs, lhs); } +bool operator!=(double lhs, const Approx& rhs) { return !operator==(lhs, rhs); } +bool operator!=(const Approx& lhs, double rhs) { return !operator==(rhs, lhs); } +bool operator<=(double lhs, const Approx& rhs) { return lhs < rhs.m_value || lhs == rhs; } +bool operator<=(const Approx& lhs, double rhs) { return lhs.m_value < rhs || lhs == rhs; } +bool operator>=(double lhs, const Approx& rhs) { return lhs > rhs.m_value || lhs == rhs; } +bool operator>=(const Approx& lhs, double rhs) { return lhs.m_value > rhs || lhs == rhs; } +bool operator<(double lhs, const Approx& rhs) { return lhs < rhs.m_value && lhs != rhs; } +bool operator<(const Approx& lhs, double rhs) { return lhs.m_value < rhs && lhs != rhs; } +bool operator>(double lhs, const Approx& rhs) { return lhs > rhs.m_value && lhs != rhs; } +bool operator>(const Approx& lhs, double rhs) { return lhs.m_value > rhs && lhs != rhs; } + +String toString(const Approx& in) { + return "Approx( " + doctest::toString(in.m_value) + " )"; +} +const ContextOptions* getContextOptions() { return DOCTEST_BRANCH_ON_DISABLED(nullptr, g_cs); } + +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4738) +template +IsNaN::operator bool() const { + return std::isnan(value) ^ flipped; +} +DOCTEST_MSVC_SUPPRESS_WARNING_POP +template struct DOCTEST_INTERFACE_DEF IsNaN; +template struct DOCTEST_INTERFACE_DEF IsNaN; +template struct DOCTEST_INTERFACE_DEF IsNaN; +template +String toString(IsNaN in) { return String(in.flipped ? "! " : "") + "IsNaN( " + doctest::toString(in.value) + " )"; } +String toString(IsNaN in) { return toString(in); } +String toString(IsNaN in) { return toString(in); } +String toString(IsNaN in) { return toString(in); } + +} // namespace doctest + +#ifdef DOCTEST_CONFIG_DISABLE +namespace doctest { +Context::Context(int, const char* const*) {} +Context::~Context() = default; +void Context::applyCommandLine(int, const char* const*) {} +void Context::addFilter(const char*, const char*) {} +void Context::clearFilters() {} +void Context::setOption(const char*, bool) {} +void Context::setOption(const char*, int) {} +void Context::setOption(const char*, const char*) {} +bool Context::shouldExit() { return false; } +void Context::setAsDefaultForAssertsOutOfTestCases() {} +void Context::setAssertHandler(detail::assert_handler) {} +void Context::setCout(std::ostream*) {} +int Context::run() { return 0; } + +int IReporter::get_num_active_contexts() { return 0; } +const IContextScope* const* IReporter::get_active_contexts() { return nullptr; } +int IReporter::get_num_stringified_contexts() { return 0; } +const String* IReporter::get_stringified_contexts() { return nullptr; } + +int registerReporter(const char*, int, IReporter*) { return 0; } + +} // namespace doctest +#else // DOCTEST_CONFIG_DISABLE + +#if !defined(DOCTEST_CONFIG_COLORS_NONE) +#if !defined(DOCTEST_CONFIG_COLORS_WINDOWS) && !defined(DOCTEST_CONFIG_COLORS_ANSI) +#ifdef DOCTEST_PLATFORM_WINDOWS +#define DOCTEST_CONFIG_COLORS_WINDOWS +#else // linux +#define DOCTEST_CONFIG_COLORS_ANSI +#endif // platform +#endif // DOCTEST_CONFIG_COLORS_WINDOWS && DOCTEST_CONFIG_COLORS_ANSI +#endif // DOCTEST_CONFIG_COLORS_NONE + +namespace doctest_detail_test_suite_ns { +// holds the current test suite +doctest::detail::TestSuite& getCurrentTestSuite() { + static doctest::detail::TestSuite data{}; + return data; +} +} // namespace doctest_detail_test_suite_ns + +namespace doctest { +namespace { + // the int (priority) is part of the key for automatic sorting - sadly one can register a + // reporter with a duplicate name and a different priority but hopefully that won't happen often :| + using reporterMap = std::map, reporterCreatorFunc>; + + reporterMap& getReporters() { + static reporterMap data; + return data; + } + reporterMap& getListeners() { + static reporterMap data; + return data; + } +} // namespace +namespace detail { +#define DOCTEST_ITERATE_THROUGH_REPORTERS(function, ...) \ + for(auto& curr_rep : g_cs->reporters_currently_used) \ + curr_rep->function(__VA_ARGS__) + + bool checkIfShouldThrow(assertType::Enum at) { + if(at & assertType::is_require) //!OCLINT bitwise operator in conditional + return true; + + if((at & assertType::is_check) //!OCLINT bitwise operator in conditional + && getContextOptions()->abort_after > 0 && + (g_cs->numAssertsFailed + g_cs->numAssertsFailedCurrentTest_atomic) >= + getContextOptions()->abort_after) + return true; + + return false; + } + +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + DOCTEST_NORETURN void throwException() { + g_cs->shouldLogCurrentException = false; + throw TestFailureException(); // NOLINT(hicpp-exception-baseclass) + } +#else // DOCTEST_CONFIG_NO_EXCEPTIONS + void throwException() {} +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS +} // namespace detail + +namespace { + using namespace detail; + // matching of a string against a wildcard mask (case sensitivity configurable) taken from + // https://www.codeproject.com/Articles/1088/Wildcard-string-compare-globbing + int wildcmp(const char* str, const char* wild, bool caseSensitive) { + const char* cp = str; + const char* mp = wild; + + while((*str) && (*wild != '*')) { + if((caseSensitive ? (*wild != *str) : (tolower(*wild) != tolower(*str))) && + (*wild != '?')) { + return 0; + } + wild++; + str++; + } + + while(*str) { + if(*wild == '*') { + if(!*++wild) { + return 1; + } + mp = wild; + cp = str + 1; + } else if((caseSensitive ? (*wild == *str) : (tolower(*wild) == tolower(*str))) || + (*wild == '?')) { + wild++; + str++; + } else { + wild = mp; //!OCLINT parameter reassignment + str = cp++; //!OCLINT parameter reassignment + } + } + + while(*wild == '*') { + wild++; + } + return !*wild; + } + + // checks if the name matches any of the filters (and can be configured what to do when empty) + bool matchesAny(const char* name, const std::vector& filters, bool matchEmpty, + bool caseSensitive) { + if (filters.empty() && matchEmpty) + return true; + for (auto& curr : filters) + if (wildcmp(name, curr.c_str(), caseSensitive)) + return true; + return false; + } + + DOCTEST_NO_SANITIZE_INTEGER + unsigned long long hash(unsigned long long a, unsigned long long b) { + return (a << 5) + b; + } + + // C string hash function (djb2) - taken from http://www.cse.yorku.ca/~oz/hash.html + DOCTEST_NO_SANITIZE_INTEGER + unsigned long long hash(const char* str) { + unsigned long long hash = 5381; + char c; + while ((c = *str++)) + hash = ((hash << 5) + hash) + c; // hash * 33 + c + return hash; + } + + unsigned long long hash(const SubcaseSignature& sig) { + return hash(hash(hash(sig.m_file), hash(sig.m_name.c_str())), sig.m_line); + } + + unsigned long long hash(const std::vector& sigs, size_t count) { + unsigned long long running = 0; + auto end = sigs.begin() + count; + for (auto it = sigs.begin(); it != end; it++) { + running = hash(running, hash(*it)); + } + return running; + } + + unsigned long long hash(const std::vector& sigs) { + unsigned long long running = 0; + for (const SubcaseSignature& sig : sigs) { + running = hash(running, hash(sig)); + } + return running; + } +} // namespace +namespace detail { + bool Subcase::checkFilters() { + if (g_cs->subcaseStack.size() < size_t(g_cs->subcase_filter_levels)) { + if (!matchesAny(m_signature.m_name.c_str(), g_cs->filters[6], true, g_cs->case_sensitive)) + return true; + if (matchesAny(m_signature.m_name.c_str(), g_cs->filters[7], false, g_cs->case_sensitive)) + return true; + } + return false; + } + + Subcase::Subcase(const String& name, const char* file, int line) + : m_signature({name, file, line}) { + if (!g_cs->reachedLeaf) { + if (g_cs->nextSubcaseStack.size() <= g_cs->subcaseStack.size() + || g_cs->nextSubcaseStack[g_cs->subcaseStack.size()] == m_signature) { + // Going down. + if (checkFilters()) { return; } + + g_cs->subcaseStack.push_back(m_signature); + g_cs->currentSubcaseDepth++; + m_entered = true; + DOCTEST_ITERATE_THROUGH_REPORTERS(subcase_start, m_signature); + } + } else { + if (g_cs->subcaseStack[g_cs->currentSubcaseDepth] == m_signature) { + // This subcase is reentered via control flow. + g_cs->currentSubcaseDepth++; + m_entered = true; + DOCTEST_ITERATE_THROUGH_REPORTERS(subcase_start, m_signature); + } else if (g_cs->nextSubcaseStack.size() <= g_cs->currentSubcaseDepth + && g_cs->fullyTraversedSubcases.find(hash(hash(g_cs->subcaseStack, g_cs->currentSubcaseDepth), hash(m_signature))) + == g_cs->fullyTraversedSubcases.end()) { + if (checkFilters()) { return; } + // This subcase is part of the one to be executed next. + g_cs->nextSubcaseStack.clear(); + g_cs->nextSubcaseStack.insert(g_cs->nextSubcaseStack.end(), + g_cs->subcaseStack.begin(), g_cs->subcaseStack.begin() + g_cs->currentSubcaseDepth); + g_cs->nextSubcaseStack.push_back(m_signature); + } + } + } + + DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4996) // std::uncaught_exception is deprecated in C++17 + DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wdeprecated-declarations") + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wdeprecated-declarations") + + Subcase::~Subcase() { + if (m_entered) { + g_cs->currentSubcaseDepth--; + + if (!g_cs->reachedLeaf) { + // Leaf. + g_cs->fullyTraversedSubcases.insert(hash(g_cs->subcaseStack)); + g_cs->nextSubcaseStack.clear(); + g_cs->reachedLeaf = true; + } else if (g_cs->nextSubcaseStack.empty()) { + // All children are finished. + g_cs->fullyTraversedSubcases.insert(hash(g_cs->subcaseStack)); + } + +#if defined(__cpp_lib_uncaught_exceptions) && __cpp_lib_uncaught_exceptions >= 201411L && (!defined(__MAC_OS_X_VERSION_MIN_REQUIRED) || __MAC_OS_X_VERSION_MIN_REQUIRED >= 101200) + if(std::uncaught_exceptions() > 0 +#else + if(std::uncaught_exception() +#endif + && g_cs->shouldLogCurrentException) { + DOCTEST_ITERATE_THROUGH_REPORTERS( + test_case_exception, {"exception thrown in subcase - will translate later " + "when the whole test case has been exited (cannot " + "translate while there is an active exception)", + false}); + g_cs->shouldLogCurrentException = false; + } + + DOCTEST_ITERATE_THROUGH_REPORTERS(subcase_end, DOCTEST_EMPTY); + } + } + + DOCTEST_CLANG_SUPPRESS_WARNING_POP + DOCTEST_GCC_SUPPRESS_WARNING_POP + DOCTEST_MSVC_SUPPRESS_WARNING_POP + + Subcase::operator bool() const { return m_entered; } + + Result::Result(bool passed, const String& decomposition) + : m_passed(passed) + , m_decomp(decomposition) {} + + ExpressionDecomposer::ExpressionDecomposer(assertType::Enum at) + : m_at(at) {} + + TestSuite& TestSuite::operator*(const char* in) { + m_test_suite = in; + return *this; + } + + TestCase::TestCase(funcType test, const char* file, unsigned line, const TestSuite& test_suite, + const String& type, int template_id) { + m_file = file; + m_line = line; + m_name = nullptr; // will be later overridden in operator* + m_test_suite = test_suite.m_test_suite; + m_description = test_suite.m_description; + m_skip = test_suite.m_skip; + m_no_breaks = test_suite.m_no_breaks; + m_no_output = test_suite.m_no_output; + m_may_fail = test_suite.m_may_fail; + m_should_fail = test_suite.m_should_fail; + m_expected_failures = test_suite.m_expected_failures; + m_timeout = test_suite.m_timeout; + + m_test = test; + m_type = type; + m_template_id = template_id; + } + + TestCase::TestCase(const TestCase& other) + : TestCaseData() { + *this = other; + } + + DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(26434) // hides a non-virtual function + TestCase& TestCase::operator=(const TestCase& other) { + TestCaseData::operator=(other); + m_test = other.m_test; + m_type = other.m_type; + m_template_id = other.m_template_id; + m_full_name = other.m_full_name; + + if(m_template_id != -1) + m_name = m_full_name.c_str(); + return *this; + } + DOCTEST_MSVC_SUPPRESS_WARNING_POP + + TestCase& TestCase::operator*(const char* in) { + m_name = in; + // make a new name with an appended type for templated test case + if(m_template_id != -1) { + m_full_name = String(m_name) + "<" + m_type + ">"; + // redirect the name to point to the newly constructed full name + m_name = m_full_name.c_str(); + } + return *this; + } + + bool TestCase::operator<(const TestCase& other) const { + // this will be used only to differentiate between test cases - not relevant for sorting + if(m_line != other.m_line) + return m_line < other.m_line; + const int name_cmp = strcmp(m_name, other.m_name); + if(name_cmp != 0) + return name_cmp < 0; + const int file_cmp = m_file.compare(other.m_file); + if(file_cmp != 0) + return file_cmp < 0; + return m_template_id < other.m_template_id; + } + + // all the registered tests + std::set& getRegisteredTests() { + static std::set data; + return data; + } +} // namespace detail +namespace { + using namespace detail; + // for sorting tests by file/line + bool fileOrderComparator(const TestCase* lhs, const TestCase* rhs) { + // this is needed because MSVC gives different case for drive letters + // for __FILE__ when evaluated in a header and a source file + const int res = lhs->m_file.compare(rhs->m_file, bool(DOCTEST_MSVC)); + if(res != 0) + return res < 0; + if(lhs->m_line != rhs->m_line) + return lhs->m_line < rhs->m_line; + return lhs->m_template_id < rhs->m_template_id; + } + + // for sorting tests by suite/file/line + bool suiteOrderComparator(const TestCase* lhs, const TestCase* rhs) { + const int res = std::strcmp(lhs->m_test_suite, rhs->m_test_suite); + if(res != 0) + return res < 0; + return fileOrderComparator(lhs, rhs); + } + + // for sorting tests by name/suite/file/line + bool nameOrderComparator(const TestCase* lhs, const TestCase* rhs) { + const int res = std::strcmp(lhs->m_name, rhs->m_name); + if(res != 0) + return res < 0; + return suiteOrderComparator(lhs, rhs); + } + + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wdeprecated-declarations") + void color_to_stream(std::ostream& s, Color::Enum code) { + static_cast(s); // for DOCTEST_CONFIG_COLORS_NONE or DOCTEST_CONFIG_COLORS_WINDOWS + static_cast(code); // for DOCTEST_CONFIG_COLORS_NONE +#ifdef DOCTEST_CONFIG_COLORS_ANSI + if(g_no_colors || + (isatty(STDOUT_FILENO) == false && getContextOptions()->force_colors == false)) + return; + + auto col = ""; + // clang-format off + switch(code) { //!OCLINT missing break in switch statement / unnecessary default statement in covered switch statement + case Color::Red: col = "[0;31m"; break; + case Color::Green: col = "[0;32m"; break; + case Color::Blue: col = "[0;34m"; break; + case Color::Cyan: col = "[0;36m"; break; + case Color::Yellow: col = "[0;33m"; break; + case Color::Grey: col = "[1;30m"; break; + case Color::LightGrey: col = "[0;37m"; break; + case Color::BrightRed: col = "[1;31m"; break; + case Color::BrightGreen: col = "[1;32m"; break; + case Color::BrightWhite: col = "[1;37m"; break; + case Color::Bright: // invalid + case Color::None: + case Color::White: + default: col = "[0m"; + } + // clang-format on + s << "\033" << col; +#endif // DOCTEST_CONFIG_COLORS_ANSI + +#ifdef DOCTEST_CONFIG_COLORS_WINDOWS + if(g_no_colors || + (_isatty(_fileno(stdout)) == false && getContextOptions()->force_colors == false)) + return; + + static struct ConsoleHelper { + HANDLE stdoutHandle; + WORD origFgAttrs; + WORD origBgAttrs; + + ConsoleHelper() { + stdoutHandle = GetStdHandle(STD_OUTPUT_HANDLE); + CONSOLE_SCREEN_BUFFER_INFO csbiInfo; + GetConsoleScreenBufferInfo(stdoutHandle, &csbiInfo); + origFgAttrs = csbiInfo.wAttributes & ~(BACKGROUND_GREEN | BACKGROUND_RED | + BACKGROUND_BLUE | BACKGROUND_INTENSITY); + origBgAttrs = csbiInfo.wAttributes & ~(FOREGROUND_GREEN | FOREGROUND_RED | + FOREGROUND_BLUE | FOREGROUND_INTENSITY); + } + } ch; + +#define DOCTEST_SET_ATTR(x) SetConsoleTextAttribute(ch.stdoutHandle, x | ch.origBgAttrs) + + // clang-format off + switch (code) { + case Color::White: DOCTEST_SET_ATTR(FOREGROUND_GREEN | FOREGROUND_RED | FOREGROUND_BLUE); break; + case Color::Red: DOCTEST_SET_ATTR(FOREGROUND_RED); break; + case Color::Green: DOCTEST_SET_ATTR(FOREGROUND_GREEN); break; + case Color::Blue: DOCTEST_SET_ATTR(FOREGROUND_BLUE); break; + case Color::Cyan: DOCTEST_SET_ATTR(FOREGROUND_BLUE | FOREGROUND_GREEN); break; + case Color::Yellow: DOCTEST_SET_ATTR(FOREGROUND_RED | FOREGROUND_GREEN); break; + case Color::Grey: DOCTEST_SET_ATTR(0); break; + case Color::LightGrey: DOCTEST_SET_ATTR(FOREGROUND_INTENSITY); break; + case Color::BrightRed: DOCTEST_SET_ATTR(FOREGROUND_INTENSITY | FOREGROUND_RED); break; + case Color::BrightGreen: DOCTEST_SET_ATTR(FOREGROUND_INTENSITY | FOREGROUND_GREEN); break; + case Color::BrightWhite: DOCTEST_SET_ATTR(FOREGROUND_INTENSITY | FOREGROUND_GREEN | FOREGROUND_RED | FOREGROUND_BLUE); break; + case Color::None: + case Color::Bright: // invalid + default: DOCTEST_SET_ATTR(ch.origFgAttrs); + } + // clang-format on +#endif // DOCTEST_CONFIG_COLORS_WINDOWS + } + DOCTEST_CLANG_SUPPRESS_WARNING_POP + + std::vector& getExceptionTranslators() { + static std::vector data; + return data; + } + + String translateActiveException() { +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + String res; + auto& translators = getExceptionTranslators(); + for(auto& curr : translators) + if(curr->translate(res)) + return res; + // clang-format off + DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wcatch-value") + try { + throw; + } catch(std::exception& ex) { + return ex.what(); + } catch(std::string& msg) { + return msg.c_str(); + } catch(const char* msg) { + return msg; + } catch(...) { + return "unknown exception"; + } + DOCTEST_GCC_SUPPRESS_WARNING_POP +// clang-format on +#else // DOCTEST_CONFIG_NO_EXCEPTIONS + return ""; +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + } +} // namespace + +namespace detail { + // used by the macros for registering tests + int regTest(const TestCase& tc) { + getRegisteredTests().insert(tc); + return 0; + } + + // sets the current test suite + int setTestSuite(const TestSuite& ts) { + doctest_detail_test_suite_ns::getCurrentTestSuite() = ts; + return 0; + } + +#ifdef DOCTEST_IS_DEBUGGER_ACTIVE + bool isDebuggerActive() { return DOCTEST_IS_DEBUGGER_ACTIVE(); } +#else // DOCTEST_IS_DEBUGGER_ACTIVE +#ifdef DOCTEST_PLATFORM_LINUX + class ErrnoGuard { + public: + ErrnoGuard() : m_oldErrno(errno) {} + ~ErrnoGuard() { errno = m_oldErrno; } + private: + int m_oldErrno; + }; + // See the comments in Catch2 for the reasoning behind this implementation: + // https://github.com/catchorg/Catch2/blob/v2.13.1/include/internal/catch_debugger.cpp#L79-L102 + bool isDebuggerActive() { + ErrnoGuard guard; + std::ifstream in("/proc/self/status"); + for(std::string line; std::getline(in, line);) { + static const int PREFIX_LEN = 11; + if(line.compare(0, PREFIX_LEN, "TracerPid:\t") == 0) { + return line.length() > PREFIX_LEN && line[PREFIX_LEN] != '0'; + } + } + return false; + } +#elif defined(DOCTEST_PLATFORM_MAC) + // The following function is taken directly from the following technical note: + // https://developer.apple.com/library/archive/qa/qa1361/_index.html + // Returns true if the current process is being debugged (either + // running under the debugger or has a debugger attached post facto). + bool isDebuggerActive() { + int mib[4]; + kinfo_proc info; + size_t size; + // Initialize the flags so that, if sysctl fails for some bizarre + // reason, we get a predictable result. + info.kp_proc.p_flag = 0; + // Initialize mib, which tells sysctl the info we want, in this case + // we're looking for information about a specific process ID. + mib[0] = CTL_KERN; + mib[1] = KERN_PROC; + mib[2] = KERN_PROC_PID; + mib[3] = getpid(); + // Call sysctl. + size = sizeof(info); + if(sysctl(mib, DOCTEST_COUNTOF(mib), &info, &size, 0, 0) != 0) { + std::cerr << "\nCall to sysctl failed - unable to determine if debugger is active **\n"; + return false; + } + // We're being debugged if the P_TRACED flag is set. + return ((info.kp_proc.p_flag & P_TRACED) != 0); + } +#elif DOCTEST_MSVC || defined(__MINGW32__) || defined(__MINGW64__) + bool isDebuggerActive() { return ::IsDebuggerPresent() != 0; } +#else + bool isDebuggerActive() { return false; } +#endif // Platform +#endif // DOCTEST_IS_DEBUGGER_ACTIVE + + void registerExceptionTranslatorImpl(const IExceptionTranslator* et) { + if(std::find(getExceptionTranslators().begin(), getExceptionTranslators().end(), et) == + getExceptionTranslators().end()) + getExceptionTranslators().push_back(et); + } + + DOCTEST_THREAD_LOCAL std::vector g_infoContexts; // for logging with INFO() + + ContextScopeBase::ContextScopeBase() { + g_infoContexts.push_back(this); + } + + ContextScopeBase::ContextScopeBase(ContextScopeBase&& other) noexcept { + if (other.need_to_destroy) { + other.destroy(); + } + other.need_to_destroy = false; + g_infoContexts.push_back(this); + } + + DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4996) // std::uncaught_exception is deprecated in C++17 + DOCTEST_GCC_SUPPRESS_WARNING_WITH_PUSH("-Wdeprecated-declarations") + DOCTEST_CLANG_SUPPRESS_WARNING_WITH_PUSH("-Wdeprecated-declarations") + + // destroy cannot be inlined into the destructor because that would mean calling stringify after + // ContextScope has been destroyed (base class destructors run after derived class destructors). + // Instead, ContextScope calls this method directly from its destructor. + void ContextScopeBase::destroy() { +#if defined(__cpp_lib_uncaught_exceptions) && __cpp_lib_uncaught_exceptions >= 201411L && (!defined(__MAC_OS_X_VERSION_MIN_REQUIRED) || __MAC_OS_X_VERSION_MIN_REQUIRED >= 101200) + if(std::uncaught_exceptions() > 0) { +#else + if(std::uncaught_exception()) { +#endif + std::ostringstream s; + this->stringify(&s); + g_cs->stringifiedContexts.push_back(s.str().c_str()); + } + g_infoContexts.pop_back(); + } + + DOCTEST_CLANG_SUPPRESS_WARNING_POP + DOCTEST_GCC_SUPPRESS_WARNING_POP + DOCTEST_MSVC_SUPPRESS_WARNING_POP +} // namespace detail +namespace { + using namespace detail; + +#if !defined(DOCTEST_CONFIG_POSIX_SIGNALS) && !defined(DOCTEST_CONFIG_WINDOWS_SEH) + struct FatalConditionHandler + { + static void reset() {} + static void allocateAltStackMem() {} + static void freeAltStackMem() {} + }; +#else // DOCTEST_CONFIG_POSIX_SIGNALS || DOCTEST_CONFIG_WINDOWS_SEH + + void reportFatal(const std::string&); + +#ifdef DOCTEST_PLATFORM_WINDOWS + + struct SignalDefs + { + DWORD id; + const char* name; + }; + // There is no 1-1 mapping between signals and windows exceptions. + // Windows can easily distinguish between SO and SigSegV, + // but SigInt, SigTerm, etc are handled differently. + SignalDefs signalDefs[] = { + {static_cast(EXCEPTION_ILLEGAL_INSTRUCTION), + "SIGILL - Illegal instruction signal"}, + {static_cast(EXCEPTION_STACK_OVERFLOW), "SIGSEGV - Stack overflow"}, + {static_cast(EXCEPTION_ACCESS_VIOLATION), + "SIGSEGV - Segmentation violation signal"}, + {static_cast(EXCEPTION_INT_DIVIDE_BY_ZERO), "Divide by zero error"}, + }; + + struct FatalConditionHandler + { + static LONG CALLBACK handleException(PEXCEPTION_POINTERS ExceptionInfo) { + // Multiple threads may enter this filter/handler at once. We want the error message to be printed on the + // console just once no matter how many threads have crashed. + DOCTEST_DECLARE_STATIC_MUTEX(mutex) + static bool execute = true; + { + DOCTEST_LOCK_MUTEX(mutex) + if(execute) { + bool reported = false; + for(size_t i = 0; i < DOCTEST_COUNTOF(signalDefs); ++i) { + if(ExceptionInfo->ExceptionRecord->ExceptionCode == signalDefs[i].id) { + reportFatal(signalDefs[i].name); + reported = true; + break; + } + } + if(reported == false) + reportFatal("Unhandled SEH exception caught"); + if(isDebuggerActive() && !g_cs->no_breaks) + DOCTEST_BREAK_INTO_DEBUGGER(); + } + execute = false; + } + std::exit(EXIT_FAILURE); + } + + static void allocateAltStackMem() {} + static void freeAltStackMem() {} + + FatalConditionHandler() { + isSet = true; + // 32k seems enough for doctest to handle stack overflow, + // but the value was found experimentally, so there is no strong guarantee + guaranteeSize = 32 * 1024; + // Register an unhandled exception filter + previousTop = SetUnhandledExceptionFilter(handleException); + // Pass in guarantee size to be filled + SetThreadStackGuarantee(&guaranteeSize); + + // On Windows uncaught exceptions from another thread, exceptions from + // destructors, or calls to std::terminate are not a SEH exception + + // The terminal handler gets called when: + // - std::terminate is called FROM THE TEST RUNNER THREAD + // - an exception is thrown from a destructor FROM THE TEST RUNNER THREAD + original_terminate_handler = std::get_terminate(); + std::set_terminate([]() DOCTEST_NOEXCEPT { + reportFatal("Terminate handler called"); + if(isDebuggerActive() && !g_cs->no_breaks) + DOCTEST_BREAK_INTO_DEBUGGER(); + std::exit(EXIT_FAILURE); // explicitly exit - otherwise the SIGABRT handler may be called as well + }); + + // SIGABRT is raised when: + // - std::terminate is called FROM A DIFFERENT THREAD + // - an exception is thrown from a destructor FROM A DIFFERENT THREAD + // - an uncaught exception is thrown FROM A DIFFERENT THREAD + prev_sigabrt_handler = std::signal(SIGABRT, [](int signal) DOCTEST_NOEXCEPT { + if(signal == SIGABRT) { + reportFatal("SIGABRT - Abort (abnormal termination) signal"); + if(isDebuggerActive() && !g_cs->no_breaks) + DOCTEST_BREAK_INTO_DEBUGGER(); + std::exit(EXIT_FAILURE); + } + }); + + // The following settings are taken from google test, and more + // specifically from UnitTest::Run() inside of gtest.cc + + // the user does not want to see pop-up dialogs about crashes + prev_error_mode_1 = SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOALIGNMENTFAULTEXCEPT | + SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); + // This forces the abort message to go to stderr in all circumstances. + prev_error_mode_2 = _set_error_mode(_OUT_TO_STDERR); + // In the debug version, Visual Studio pops up a separate dialog + // offering a choice to debug the aborted program - we want to disable that. + prev_abort_behavior = _set_abort_behavior(0x0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + // In debug mode, the Windows CRT can crash with an assertion over invalid + // input (e.g. passing an invalid file descriptor). The default handling + // for these assertions is to pop up a dialog and wait for user input. + // Instead ask the CRT to dump such assertions to stderr non-interactively. + prev_report_mode = _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE | _CRTDBG_MODE_DEBUG); + prev_report_file = _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR); + } + + static void reset() { + if(isSet) { + // Unregister handler and restore the old guarantee + SetUnhandledExceptionFilter(previousTop); + SetThreadStackGuarantee(&guaranteeSize); + std::set_terminate(original_terminate_handler); + std::signal(SIGABRT, prev_sigabrt_handler); + SetErrorMode(prev_error_mode_1); + _set_error_mode(prev_error_mode_2); + _set_abort_behavior(prev_abort_behavior, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + static_cast(_CrtSetReportMode(_CRT_ASSERT, prev_report_mode)); + static_cast(_CrtSetReportFile(_CRT_ASSERT, prev_report_file)); + isSet = false; + } + } + + ~FatalConditionHandler() { reset(); } + + private: + static UINT prev_error_mode_1; + static int prev_error_mode_2; + static unsigned int prev_abort_behavior; + static int prev_report_mode; + static _HFILE prev_report_file; + static void (DOCTEST_CDECL *prev_sigabrt_handler)(int); + static std::terminate_handler original_terminate_handler; + static bool isSet; + static ULONG guaranteeSize; + static LPTOP_LEVEL_EXCEPTION_FILTER previousTop; + }; + + UINT FatalConditionHandler::prev_error_mode_1; + int FatalConditionHandler::prev_error_mode_2; + unsigned int FatalConditionHandler::prev_abort_behavior; + int FatalConditionHandler::prev_report_mode; + _HFILE FatalConditionHandler::prev_report_file; + void (DOCTEST_CDECL *FatalConditionHandler::prev_sigabrt_handler)(int); + std::terminate_handler FatalConditionHandler::original_terminate_handler; + bool FatalConditionHandler::isSet = false; + ULONG FatalConditionHandler::guaranteeSize = 0; + LPTOP_LEVEL_EXCEPTION_FILTER FatalConditionHandler::previousTop = nullptr; + +#else // DOCTEST_PLATFORM_WINDOWS + + struct SignalDefs + { + int id; + const char* name; + }; + SignalDefs signalDefs[] = {{SIGINT, "SIGINT - Terminal interrupt signal"}, + {SIGILL, "SIGILL - Illegal instruction signal"}, + {SIGFPE, "SIGFPE - Floating point error signal"}, + {SIGSEGV, "SIGSEGV - Segmentation violation signal"}, + {SIGTERM, "SIGTERM - Termination request signal"}, + {SIGABRT, "SIGABRT - Abort (abnormal termination) signal"}}; + + struct FatalConditionHandler + { + static bool isSet; + static struct sigaction oldSigActions[DOCTEST_COUNTOF(signalDefs)]; + static stack_t oldSigStack; + static size_t altStackSize; + static char* altStackMem; + + static void handleSignal(int sig) { + const char* name = ""; + for(std::size_t i = 0; i < DOCTEST_COUNTOF(signalDefs); ++i) { + SignalDefs& def = signalDefs[i]; + if(sig == def.id) { + name = def.name; + break; + } + } + reset(); + reportFatal(name); + raise(sig); + } + + static void allocateAltStackMem() { + altStackMem = new char[altStackSize]; + } + + static void freeAltStackMem() { + delete[] altStackMem; + } + + FatalConditionHandler() { + isSet = true; + stack_t sigStack; + sigStack.ss_sp = altStackMem; + sigStack.ss_size = altStackSize; + sigStack.ss_flags = 0; + sigaltstack(&sigStack, &oldSigStack); + struct sigaction sa = {}; + sa.sa_handler = handleSignal; + sa.sa_flags = SA_ONSTACK; + for(std::size_t i = 0; i < DOCTEST_COUNTOF(signalDefs); ++i) { + sigaction(signalDefs[i].id, &sa, &oldSigActions[i]); + } + } + + ~FatalConditionHandler() { reset(); } + static void reset() { + if(isSet) { + // Set signals back to previous values -- hopefully nobody overwrote them in the meantime + for(std::size_t i = 0; i < DOCTEST_COUNTOF(signalDefs); ++i) { + sigaction(signalDefs[i].id, &oldSigActions[i], nullptr); + } + // Return the old stack + sigaltstack(&oldSigStack, nullptr); + isSet = false; + } + } + }; + + bool FatalConditionHandler::isSet = false; + struct sigaction FatalConditionHandler::oldSigActions[DOCTEST_COUNTOF(signalDefs)] = {}; + stack_t FatalConditionHandler::oldSigStack = {}; + size_t FatalConditionHandler::altStackSize = 4 * SIGSTKSZ; + char* FatalConditionHandler::altStackMem = nullptr; + +#endif // DOCTEST_PLATFORM_WINDOWS +#endif // DOCTEST_CONFIG_POSIX_SIGNALS || DOCTEST_CONFIG_WINDOWS_SEH + +} // namespace + +namespace { + using namespace detail; + +#ifdef DOCTEST_PLATFORM_WINDOWS +#define DOCTEST_OUTPUT_DEBUG_STRING(text) ::OutputDebugStringA(text) +#else + // TODO: integration with XCode and other IDEs +#define DOCTEST_OUTPUT_DEBUG_STRING(text) +#endif // Platform + + void addAssert(assertType::Enum at) { + if((at & assertType::is_warn) == 0) //!OCLINT bitwise operator in conditional + g_cs->numAssertsCurrentTest_atomic++; + } + + void addFailedAssert(assertType::Enum at) { + if((at & assertType::is_warn) == 0) //!OCLINT bitwise operator in conditional + g_cs->numAssertsFailedCurrentTest_atomic++; + } + +#if defined(DOCTEST_CONFIG_POSIX_SIGNALS) || defined(DOCTEST_CONFIG_WINDOWS_SEH) + void reportFatal(const std::string& message) { + g_cs->failure_flags |= TestCaseFailureReason::Crash; + + DOCTEST_ITERATE_THROUGH_REPORTERS(test_case_exception, {message.c_str(), true}); + + while (g_cs->subcaseStack.size()) { + g_cs->subcaseStack.pop_back(); + DOCTEST_ITERATE_THROUGH_REPORTERS(subcase_end, DOCTEST_EMPTY); + } + + g_cs->finalizeTestCaseData(); + + DOCTEST_ITERATE_THROUGH_REPORTERS(test_case_end, *g_cs); + + DOCTEST_ITERATE_THROUGH_REPORTERS(test_run_end, *g_cs); + } +#endif // DOCTEST_CONFIG_POSIX_SIGNALS || DOCTEST_CONFIG_WINDOWS_SEH +} // namespace + +AssertData::AssertData(assertType::Enum at, const char* file, int line, const char* expr, + const char* exception_type, const StringContains& exception_string) + : m_test_case(g_cs->currentTest), m_at(at), m_file(file), m_line(line), m_expr(expr), + m_failed(true), m_threw(false), m_threw_as(false), m_exception_type(exception_type), + m_exception_string(exception_string) { +#if DOCTEST_MSVC + if (m_expr[0] == ' ') // this happens when variadic macros are disabled under MSVC + ++m_expr; +#endif // MSVC +} + +namespace detail { + ResultBuilder::ResultBuilder(assertType::Enum at, const char* file, int line, const char* expr, + const char* exception_type, const String& exception_string) + : AssertData(at, file, line, expr, exception_type, exception_string) { } + + ResultBuilder::ResultBuilder(assertType::Enum at, const char* file, int line, const char* expr, + const char* exception_type, const Contains& exception_string) + : AssertData(at, file, line, expr, exception_type, exception_string) { } + + void ResultBuilder::setResult(const Result& res) { + m_decomp = res.m_decomp; + m_failed = !res.m_passed; + } + + void ResultBuilder::translateException() { + m_threw = true; + m_exception = translateActiveException(); + } + + bool ResultBuilder::log() { + if(m_at & assertType::is_throws) { //!OCLINT bitwise operator in conditional + m_failed = !m_threw; + } else if((m_at & assertType::is_throws_as) && (m_at & assertType::is_throws_with)) { //!OCLINT + m_failed = !m_threw_as || !m_exception_string.check(m_exception); + } else if(m_at & assertType::is_throws_as) { //!OCLINT bitwise operator in conditional + m_failed = !m_threw_as; + } else if(m_at & assertType::is_throws_with) { //!OCLINT bitwise operator in conditional + m_failed = !m_exception_string.check(m_exception); + } else if(m_at & assertType::is_nothrow) { //!OCLINT bitwise operator in conditional + m_failed = m_threw; + } + + if(m_exception.size()) + m_exception = "\"" + m_exception + "\""; + + if(is_running_in_test) { + addAssert(m_at); + DOCTEST_ITERATE_THROUGH_REPORTERS(log_assert, *this); + + if(m_failed) + addFailedAssert(m_at); + } else if(m_failed) { + failed_out_of_a_testing_context(*this); + } + + return m_failed && isDebuggerActive() && !getContextOptions()->no_breaks && + (g_cs->currentTest == nullptr || !g_cs->currentTest->m_no_breaks); // break into debugger + } + + void ResultBuilder::react() const { + if(m_failed && checkIfShouldThrow(m_at)) + throwException(); + } + + void failed_out_of_a_testing_context(const AssertData& ad) { + if(g_cs->ah) + g_cs->ah(ad); + else + std::abort(); + } + + bool decomp_assert(assertType::Enum at, const char* file, int line, const char* expr, + const Result& result) { + bool failed = !result.m_passed; + + // ################################################################################### + // IF THE DEBUGGER BREAKS HERE - GO 1 LEVEL UP IN THE CALLSTACK FOR THE FAILING ASSERT + // THIS IS THE EFFECT OF HAVING 'DOCTEST_CONFIG_SUPER_FAST_ASSERTS' DEFINED + // ################################################################################### + DOCTEST_ASSERT_OUT_OF_TESTS(result.m_decomp); + DOCTEST_ASSERT_IN_TESTS(result.m_decomp); + return !failed; + } + + MessageBuilder::MessageBuilder(const char* file, int line, assertType::Enum severity) { + m_stream = tlssPush(); + m_file = file; + m_line = line; + m_severity = severity; + } + + MessageBuilder::~MessageBuilder() { + if (!logged) + tlssPop(); + } + + DOCTEST_DEFINE_INTERFACE(IExceptionTranslator) + + bool MessageBuilder::log() { + if (!logged) { + m_string = tlssPop(); + logged = true; + } + + DOCTEST_ITERATE_THROUGH_REPORTERS(log_message, *this); + + const bool isWarn = m_severity & assertType::is_warn; + + // warn is just a message in this context so we don't treat it as an assert + if(!isWarn) { + addAssert(m_severity); + addFailedAssert(m_severity); + } + + return isDebuggerActive() && !getContextOptions()->no_breaks && !isWarn && + (g_cs->currentTest == nullptr || !g_cs->currentTest->m_no_breaks); // break into debugger + } + + void MessageBuilder::react() { + if(m_severity & assertType::is_require) //!OCLINT bitwise operator in conditional + throwException(); + } +} // namespace detail +namespace { + using namespace detail; + + // clang-format off + +// ================================================================================================= +// The following code has been taken verbatim from Catch2/include/internal/catch_xmlwriter.h/cpp +// This is done so cherry-picking bug fixes is trivial - even the style/formatting is untouched. +// ================================================================================================= + + class XmlEncode { + public: + enum ForWhat { ForTextNodes, ForAttributes }; + + XmlEncode( std::string const& str, ForWhat forWhat = ForTextNodes ); + + void encodeTo( std::ostream& os ) const; + + friend std::ostream& operator << ( std::ostream& os, XmlEncode const& xmlEncode ); + + private: + std::string m_str; + ForWhat m_forWhat; + }; + + class XmlWriter { + public: + + class ScopedElement { + public: + ScopedElement( XmlWriter* writer ); + + ScopedElement( ScopedElement&& other ) DOCTEST_NOEXCEPT; + ScopedElement& operator=( ScopedElement&& other ) DOCTEST_NOEXCEPT; + + ~ScopedElement(); + + ScopedElement& writeText( std::string const& text, bool indent = true ); + + template + ScopedElement& writeAttribute( std::string const& name, T const& attribute ) { + m_writer->writeAttribute( name, attribute ); + return *this; + } + + private: + mutable XmlWriter* m_writer = nullptr; + }; + +#ifndef DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM + XmlWriter( std::ostream& os = std::cout ); +#else // DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM + XmlWriter( std::ostream& os ); +#endif // DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM + ~XmlWriter(); + + XmlWriter( XmlWriter const& ) = delete; + XmlWriter& operator=( XmlWriter const& ) = delete; + + XmlWriter& startElement( std::string const& name ); + + ScopedElement scopedElement( std::string const& name ); + + XmlWriter& endElement(); + + XmlWriter& writeAttribute( std::string const& name, std::string const& attribute ); + + XmlWriter& writeAttribute( std::string const& name, const char* attribute ); + + XmlWriter& writeAttribute( std::string const& name, bool attribute ); + + template + XmlWriter& writeAttribute( std::string const& name, T const& attribute ) { + std::stringstream rss; + rss << attribute; + return writeAttribute( name, rss.str() ); + } + + XmlWriter& writeText( std::string const& text, bool indent = true ); + + //XmlWriter& writeComment( std::string const& text ); + + //void writeStylesheetRef( std::string const& url ); + + //XmlWriter& writeBlankLine(); + + void ensureTagClosed(); + + void writeDeclaration(); + + private: + + void newlineIfNecessary(); + + bool m_tagIsOpen = false; + bool m_needsNewline = false; + std::vector m_tags; + std::string m_indent; + std::ostream& m_os; + }; + +// ================================================================================================= +// The following code has been taken verbatim from Catch2/include/internal/catch_xmlwriter.h/cpp +// This is done so cherry-picking bug fixes is trivial - even the style/formatting is untouched. +// ================================================================================================= + +using uchar = unsigned char; + +namespace { + + size_t trailingBytes(unsigned char c) { + if ((c & 0xE0) == 0xC0) { + return 2; + } + if ((c & 0xF0) == 0xE0) { + return 3; + } + if ((c & 0xF8) == 0xF0) { + return 4; + } + DOCTEST_INTERNAL_ERROR("Invalid multibyte utf-8 start byte encountered"); + } + + uint32_t headerValue(unsigned char c) { + if ((c & 0xE0) == 0xC0) { + return c & 0x1F; + } + if ((c & 0xF0) == 0xE0) { + return c & 0x0F; + } + if ((c & 0xF8) == 0xF0) { + return c & 0x07; + } + DOCTEST_INTERNAL_ERROR("Invalid multibyte utf-8 start byte encountered"); + } + + void hexEscapeChar(std::ostream& os, unsigned char c) { + std::ios_base::fmtflags f(os.flags()); + os << "\\x" + << std::uppercase << std::hex << std::setfill('0') << std::setw(2) + << static_cast(c); + os.flags(f); + } + +} // anonymous namespace + + XmlEncode::XmlEncode( std::string const& str, ForWhat forWhat ) + : m_str( str ), + m_forWhat( forWhat ) + {} + + void XmlEncode::encodeTo( std::ostream& os ) const { + // Apostrophe escaping not necessary if we always use " to write attributes + // (see: https://www.w3.org/TR/xml/#syntax) + + for( std::size_t idx = 0; idx < m_str.size(); ++ idx ) { + uchar c = m_str[idx]; + switch (c) { + case '<': os << "<"; break; + case '&': os << "&"; break; + + case '>': + // See: https://www.w3.org/TR/xml/#syntax + if (idx > 2 && m_str[idx - 1] == ']' && m_str[idx - 2] == ']') + os << ">"; + else + os << c; + break; + + case '\"': + if (m_forWhat == ForAttributes) + os << """; + else + os << c; + break; + + default: + // Check for control characters and invalid utf-8 + + // Escape control characters in standard ascii + // see https://stackoverflow.com/questions/404107/why-are-control-characters-illegal-in-xml-1-0 + if (c < 0x09 || (c > 0x0D && c < 0x20) || c == 0x7F) { + hexEscapeChar(os, c); + break; + } + + // Plain ASCII: Write it to stream + if (c < 0x7F) { + os << c; + break; + } + + // UTF-8 territory + // Check if the encoding is valid and if it is not, hex escape bytes. + // Important: We do not check the exact decoded values for validity, only the encoding format + // First check that this bytes is a valid lead byte: + // This means that it is not encoded as 1111 1XXX + // Or as 10XX XXXX + if (c < 0xC0 || + c >= 0xF8) { + hexEscapeChar(os, c); + break; + } + + auto encBytes = trailingBytes(c); + // Are there enough bytes left to avoid accessing out-of-bounds memory? + if (idx + encBytes - 1 >= m_str.size()) { + hexEscapeChar(os, c); + break; + } + // The header is valid, check data + // The next encBytes bytes must together be a valid utf-8 + // This means: bitpattern 10XX XXXX and the extracted value is sane (ish) + bool valid = true; + uint32_t value = headerValue(c); + for (std::size_t n = 1; n < encBytes; ++n) { + uchar nc = m_str[idx + n]; + valid &= ((nc & 0xC0) == 0x80); + value = (value << 6) | (nc & 0x3F); + } + + if ( + // Wrong bit pattern of following bytes + (!valid) || + // Overlong encodings + (value < 0x80) || + ( value < 0x800 && encBytes > 2) || // removed "0x80 <= value &&" because redundant + (0x800 < value && value < 0x10000 && encBytes > 3) || + // Encoded value out of range + (value >= 0x110000) + ) { + hexEscapeChar(os, c); + break; + } + + // If we got here, this is in fact a valid(ish) utf-8 sequence + for (std::size_t n = 0; n < encBytes; ++n) { + os << m_str[idx + n]; + } + idx += encBytes - 1; + break; + } + } + } + + std::ostream& operator << ( std::ostream& os, XmlEncode const& xmlEncode ) { + xmlEncode.encodeTo( os ); + return os; + } + + XmlWriter::ScopedElement::ScopedElement( XmlWriter* writer ) + : m_writer( writer ) + {} + + XmlWriter::ScopedElement::ScopedElement( ScopedElement&& other ) DOCTEST_NOEXCEPT + : m_writer( other.m_writer ){ + other.m_writer = nullptr; + } + XmlWriter::ScopedElement& XmlWriter::ScopedElement::operator=( ScopedElement&& other ) DOCTEST_NOEXCEPT { + if ( m_writer ) { + m_writer->endElement(); + } + m_writer = other.m_writer; + other.m_writer = nullptr; + return *this; + } + + + XmlWriter::ScopedElement::~ScopedElement() { + if( m_writer ) + m_writer->endElement(); + } + + XmlWriter::ScopedElement& XmlWriter::ScopedElement::writeText( std::string const& text, bool indent ) { + m_writer->writeText( text, indent ); + return *this; + } + + XmlWriter::XmlWriter( std::ostream& os ) : m_os( os ) + { + // writeDeclaration(); // called explicitly by the reporters that use the writer class - see issue #627 + } + + XmlWriter::~XmlWriter() { + while( !m_tags.empty() ) + endElement(); + } + + XmlWriter& XmlWriter::startElement( std::string const& name ) { + ensureTagClosed(); + newlineIfNecessary(); + m_os << m_indent << '<' << name; + m_tags.push_back( name ); + m_indent += " "; + m_tagIsOpen = true; + return *this; + } + + XmlWriter::ScopedElement XmlWriter::scopedElement( std::string const& name ) { + ScopedElement scoped( this ); + startElement( name ); + return scoped; + } + + XmlWriter& XmlWriter::endElement() { + newlineIfNecessary(); + m_indent = m_indent.substr( 0, m_indent.size()-2 ); + if( m_tagIsOpen ) { + m_os << "/>"; + m_tagIsOpen = false; + } + else { + m_os << m_indent << ""; + } + m_os << std::endl; + m_tags.pop_back(); + return *this; + } + + XmlWriter& XmlWriter::writeAttribute( std::string const& name, std::string const& attribute ) { + if( !name.empty() && !attribute.empty() ) + m_os << ' ' << name << "=\"" << XmlEncode( attribute, XmlEncode::ForAttributes ) << '"'; + return *this; + } + + XmlWriter& XmlWriter::writeAttribute( std::string const& name, const char* attribute ) { + if( !name.empty() && attribute && attribute[0] != '\0' ) + m_os << ' ' << name << "=\"" << XmlEncode( attribute, XmlEncode::ForAttributes ) << '"'; + return *this; + } + + XmlWriter& XmlWriter::writeAttribute( std::string const& name, bool attribute ) { + m_os << ' ' << name << "=\"" << ( attribute ? "true" : "false" ) << '"'; + return *this; + } + + XmlWriter& XmlWriter::writeText( std::string const& text, bool indent ) { + if( !text.empty() ){ + bool tagWasOpen = m_tagIsOpen; + ensureTagClosed(); + if( tagWasOpen && indent ) + m_os << m_indent; + m_os << XmlEncode( text ); + m_needsNewline = true; + } + return *this; + } + + //XmlWriter& XmlWriter::writeComment( std::string const& text ) { + // ensureTagClosed(); + // m_os << m_indent << ""; + // m_needsNewline = true; + // return *this; + //} + + //void XmlWriter::writeStylesheetRef( std::string const& url ) { + // m_os << "\n"; + //} + + //XmlWriter& XmlWriter::writeBlankLine() { + // ensureTagClosed(); + // m_os << '\n'; + // return *this; + //} + + void XmlWriter::ensureTagClosed() { + if( m_tagIsOpen ) { + m_os << ">" << std::endl; + m_tagIsOpen = false; + } + } + + void XmlWriter::writeDeclaration() { + m_os << "\n"; + } + + void XmlWriter::newlineIfNecessary() { + if( m_needsNewline ) { + m_os << std::endl; + m_needsNewline = false; + } + } + +// ================================================================================================= +// End of copy-pasted code from Catch +// ================================================================================================= + + // clang-format on + + struct XmlReporter : public IReporter + { + XmlWriter xml; + DOCTEST_DECLARE_MUTEX(mutex) + + // caching pointers/references to objects of these types - safe to do + const ContextOptions& opt; + const TestCaseData* tc = nullptr; + + XmlReporter(const ContextOptions& co) + : xml(*co.cout) + , opt(co) {} + + void log_contexts() { + int num_contexts = get_num_active_contexts(); + if(num_contexts) { + auto contexts = get_active_contexts(); + std::stringstream ss; + for(int i = 0; i < num_contexts; ++i) { + contexts[i]->stringify(&ss); + xml.scopedElement("Info").writeText(ss.str()); + ss.str(""); + } + } + } + + unsigned line(unsigned l) const { return opt.no_line_numbers ? 0 : l; } + + void test_case_start_impl(const TestCaseData& in) { + bool open_ts_tag = false; + if(tc != nullptr) { // we have already opened a test suite + if(std::strcmp(tc->m_test_suite, in.m_test_suite) != 0) { + xml.endElement(); + open_ts_tag = true; + } + } + else { + open_ts_tag = true; // first test case ==> first test suite + } + + if(open_ts_tag) { + xml.startElement("TestSuite"); + xml.writeAttribute("name", in.m_test_suite); + } + + tc = ∈ + xml.startElement("TestCase") + .writeAttribute("name", in.m_name) + .writeAttribute("filename", skipPathFromFilename(in.m_file.c_str())) + .writeAttribute("line", line(in.m_line)) + .writeAttribute("description", in.m_description); + + if(Approx(in.m_timeout) != 0) + xml.writeAttribute("timeout", in.m_timeout); + if(in.m_may_fail) + xml.writeAttribute("may_fail", true); + if(in.m_should_fail) + xml.writeAttribute("should_fail", true); + } + + // ========================================================================================= + // WHAT FOLLOWS ARE OVERRIDES OF THE VIRTUAL METHODS OF THE REPORTER INTERFACE + // ========================================================================================= + + void report_query(const QueryData& in) override { + test_run_start(); + if(opt.list_reporters) { + for(auto& curr : getListeners()) + xml.scopedElement("Listener") + .writeAttribute("priority", curr.first.first) + .writeAttribute("name", curr.first.second); + for(auto& curr : getReporters()) + xml.scopedElement("Reporter") + .writeAttribute("priority", curr.first.first) + .writeAttribute("name", curr.first.second); + } else if(opt.count || opt.list_test_cases) { + for(unsigned i = 0; i < in.num_data; ++i) { + xml.scopedElement("TestCase").writeAttribute("name", in.data[i]->m_name) + .writeAttribute("testsuite", in.data[i]->m_test_suite) + .writeAttribute("filename", skipPathFromFilename(in.data[i]->m_file.c_str())) + .writeAttribute("line", line(in.data[i]->m_line)) + .writeAttribute("skipped", in.data[i]->m_skip); + } + xml.scopedElement("OverallResultsTestCases") + .writeAttribute("unskipped", in.run_stats->numTestCasesPassingFilters); + } else if(opt.list_test_suites) { + for(unsigned i = 0; i < in.num_data; ++i) + xml.scopedElement("TestSuite").writeAttribute("name", in.data[i]->m_test_suite); + xml.scopedElement("OverallResultsTestCases") + .writeAttribute("unskipped", in.run_stats->numTestCasesPassingFilters); + xml.scopedElement("OverallResultsTestSuites") + .writeAttribute("unskipped", in.run_stats->numTestSuitesPassingFilters); + } + xml.endElement(); + } + + void test_run_start() override { + xml.writeDeclaration(); + + // remove .exe extension - mainly to have the same output on UNIX and Windows + std::string binary_name = skipPathFromFilename(opt.binary_name.c_str()); +#ifdef DOCTEST_PLATFORM_WINDOWS + if(binary_name.rfind(".exe") != std::string::npos) + binary_name = binary_name.substr(0, binary_name.length() - 4); +#endif // DOCTEST_PLATFORM_WINDOWS + + xml.startElement("doctest").writeAttribute("binary", binary_name); + if(opt.no_version == false) + xml.writeAttribute("version", DOCTEST_VERSION_STR); + + // only the consequential ones (TODO: filters) + xml.scopedElement("Options") + .writeAttribute("order_by", opt.order_by.c_str()) + .writeAttribute("rand_seed", opt.rand_seed) + .writeAttribute("first", opt.first) + .writeAttribute("last", opt.last) + .writeAttribute("abort_after", opt.abort_after) + .writeAttribute("subcase_filter_levels", opt.subcase_filter_levels) + .writeAttribute("case_sensitive", opt.case_sensitive) + .writeAttribute("no_throw", opt.no_throw) + .writeAttribute("no_skip", opt.no_skip); + } + + void test_run_end(const TestRunStats& p) override { + if(tc) // the TestSuite tag - only if there has been at least 1 test case + xml.endElement(); + + xml.scopedElement("OverallResultsAsserts") + .writeAttribute("successes", p.numAsserts - p.numAssertsFailed) + .writeAttribute("failures", p.numAssertsFailed); + + xml.startElement("OverallResultsTestCases") + .writeAttribute("successes", + p.numTestCasesPassingFilters - p.numTestCasesFailed) + .writeAttribute("failures", p.numTestCasesFailed); + if(opt.no_skipped_summary == false) + xml.writeAttribute("skipped", p.numTestCases - p.numTestCasesPassingFilters); + xml.endElement(); + + xml.endElement(); + } + + void test_case_start(const TestCaseData& in) override { + test_case_start_impl(in); + xml.ensureTagClosed(); + } + + void test_case_reenter(const TestCaseData&) override {} + + void test_case_end(const CurrentTestCaseStats& st) override { + xml.startElement("OverallResultsAsserts") + .writeAttribute("successes", + st.numAssertsCurrentTest - st.numAssertsFailedCurrentTest) + .writeAttribute("failures", st.numAssertsFailedCurrentTest) + .writeAttribute("test_case_success", st.testCaseSuccess); + if(opt.duration) + xml.writeAttribute("duration", st.seconds); + if(tc->m_expected_failures) + xml.writeAttribute("expected_failures", tc->m_expected_failures); + xml.endElement(); + + xml.endElement(); + } + + void test_case_exception(const TestCaseException& e) override { + DOCTEST_LOCK_MUTEX(mutex) + + xml.scopedElement("Exception") + .writeAttribute("crash", e.is_crash) + .writeText(e.error_string.c_str()); + } + + void subcase_start(const SubcaseSignature& in) override { + xml.startElement("SubCase") + .writeAttribute("name", in.m_name) + .writeAttribute("filename", skipPathFromFilename(in.m_file)) + .writeAttribute("line", line(in.m_line)); + xml.ensureTagClosed(); + } + + void subcase_end() override { xml.endElement(); } + + void log_assert(const AssertData& rb) override { + if(!rb.m_failed && !opt.success) + return; + + DOCTEST_LOCK_MUTEX(mutex) + + xml.startElement("Expression") + .writeAttribute("success", !rb.m_failed) + .writeAttribute("type", assertString(rb.m_at)) + .writeAttribute("filename", skipPathFromFilename(rb.m_file)) + .writeAttribute("line", line(rb.m_line)); + + xml.scopedElement("Original").writeText(rb.m_expr); + + if(rb.m_threw) + xml.scopedElement("Exception").writeText(rb.m_exception.c_str()); + + if(rb.m_at & assertType::is_throws_as) + xml.scopedElement("ExpectedException").writeText(rb.m_exception_type); + if(rb.m_at & assertType::is_throws_with) + xml.scopedElement("ExpectedExceptionString").writeText(rb.m_exception_string.c_str()); + if((rb.m_at & assertType::is_normal) && !rb.m_threw) + xml.scopedElement("Expanded").writeText(rb.m_decomp.c_str()); + + log_contexts(); + + xml.endElement(); + } + + void log_message(const MessageData& mb) override { + DOCTEST_LOCK_MUTEX(mutex) + + xml.startElement("Message") + .writeAttribute("type", failureString(mb.m_severity)) + .writeAttribute("filename", skipPathFromFilename(mb.m_file)) + .writeAttribute("line", line(mb.m_line)); + + xml.scopedElement("Text").writeText(mb.m_string.c_str()); + + log_contexts(); + + xml.endElement(); + } + + void test_case_skipped(const TestCaseData& in) override { + if(opt.no_skipped_summary == false) { + test_case_start_impl(in); + xml.writeAttribute("skipped", "true"); + xml.endElement(); + } + } + }; + + DOCTEST_REGISTER_REPORTER("xml", 0, XmlReporter); + + void fulltext_log_assert_to_stream(std::ostream& s, const AssertData& rb) { + if((rb.m_at & (assertType::is_throws_as | assertType::is_throws_with)) == + 0) //!OCLINT bitwise operator in conditional + s << Color::Cyan << assertString(rb.m_at) << "( " << rb.m_expr << " ) " + << Color::None; + + if(rb.m_at & assertType::is_throws) { //!OCLINT bitwise operator in conditional + s << (rb.m_threw ? "threw as expected!" : "did NOT throw at all!") << "\n"; + } else if((rb.m_at & assertType::is_throws_as) && + (rb.m_at & assertType::is_throws_with)) { //!OCLINT + s << Color::Cyan << assertString(rb.m_at) << "( " << rb.m_expr << ", \"" + << rb.m_exception_string.c_str() + << "\", " << rb.m_exception_type << " ) " << Color::None; + if(rb.m_threw) { + if(!rb.m_failed) { + s << "threw as expected!\n"; + } else { + s << "threw a DIFFERENT exception! (contents: " << rb.m_exception << ")\n"; + } + } else { + s << "did NOT throw at all!\n"; + } + } else if(rb.m_at & + assertType::is_throws_as) { //!OCLINT bitwise operator in conditional + s << Color::Cyan << assertString(rb.m_at) << "( " << rb.m_expr << ", " + << rb.m_exception_type << " ) " << Color::None + << (rb.m_threw ? (rb.m_threw_as ? "threw as expected!" : + "threw a DIFFERENT exception: ") : + "did NOT throw at all!") + << Color::Cyan << rb.m_exception << "\n"; + } else if(rb.m_at & + assertType::is_throws_with) { //!OCLINT bitwise operator in conditional + s << Color::Cyan << assertString(rb.m_at) << "( " << rb.m_expr << ", \"" + << rb.m_exception_string.c_str() + << "\" ) " << Color::None + << (rb.m_threw ? (!rb.m_failed ? "threw as expected!" : + "threw a DIFFERENT exception: ") : + "did NOT throw at all!") + << Color::Cyan << rb.m_exception << "\n"; + } else if(rb.m_at & assertType::is_nothrow) { //!OCLINT bitwise operator in conditional + s << (rb.m_threw ? "THREW exception: " : "didn't throw!") << Color::Cyan + << rb.m_exception << "\n"; + } else { + s << (rb.m_threw ? "THREW exception: " : + (!rb.m_failed ? "is correct!\n" : "is NOT correct!\n")); + if(rb.m_threw) + s << rb.m_exception << "\n"; + else + s << " values: " << assertString(rb.m_at) << "( " << rb.m_decomp << " )\n"; + } + } + + // TODO: + // - log_message() + // - respond to queries + // - honor remaining options + // - more attributes in tags + struct JUnitReporter : public IReporter + { + XmlWriter xml; + DOCTEST_DECLARE_MUTEX(mutex) + Timer timer; + std::vector deepestSubcaseStackNames; + + struct JUnitTestCaseData + { + static std::string getCurrentTimestamp() { + // Beware, this is not reentrant because of backward compatibility issues + // Also, UTC only, again because of backward compatibility (%z is C++11) + time_t rawtime; + std::time(&rawtime); + auto const timeStampSize = sizeof("2017-01-16T17:06:45Z"); + + std::tm timeInfo; +#ifdef DOCTEST_PLATFORM_WINDOWS + gmtime_s(&timeInfo, &rawtime); +#else // DOCTEST_PLATFORM_WINDOWS + gmtime_r(&rawtime, &timeInfo); +#endif // DOCTEST_PLATFORM_WINDOWS + + char timeStamp[timeStampSize]; + const char* const fmt = "%Y-%m-%dT%H:%M:%SZ"; + + std::strftime(timeStamp, timeStampSize, fmt, &timeInfo); + return std::string(timeStamp); + } + + struct JUnitTestMessage + { + JUnitTestMessage(const std::string& _message, const std::string& _type, const std::string& _details) + : message(_message), type(_type), details(_details) {} + + JUnitTestMessage(const std::string& _message, const std::string& _details) + : message(_message), type(), details(_details) {} + + std::string message, type, details; + }; + + struct JUnitTestCase + { + JUnitTestCase(const std::string& _classname, const std::string& _name) + : classname(_classname), name(_name), time(0), failures() {} + + std::string classname, name; + double time; + std::vector failures, errors; + }; + + void add(const std::string& classname, const std::string& name) { + testcases.emplace_back(classname, name); + } + + void appendSubcaseNamesToLastTestcase(std::vector nameStack) { + for(auto& curr: nameStack) + if(curr.size()) + testcases.back().name += std::string("/") + curr.c_str(); + } + + void addTime(double time) { + if(time < 1e-4) + time = 0; + testcases.back().time = time; + totalSeconds += time; + } + + void addFailure(const std::string& message, const std::string& type, const std::string& details) { + testcases.back().failures.emplace_back(message, type, details); + ++totalFailures; + } + + void addError(const std::string& message, const std::string& details) { + testcases.back().errors.emplace_back(message, details); + ++totalErrors; + } + + std::vector testcases; + double totalSeconds = 0; + int totalErrors = 0, totalFailures = 0; + }; + + JUnitTestCaseData testCaseData; + + // caching pointers/references to objects of these types - safe to do + const ContextOptions& opt; + const TestCaseData* tc = nullptr; + + JUnitReporter(const ContextOptions& co) + : xml(*co.cout) + , opt(co) {} + + unsigned line(unsigned l) const { return opt.no_line_numbers ? 0 : l; } + + // ========================================================================================= + // WHAT FOLLOWS ARE OVERRIDES OF THE VIRTUAL METHODS OF THE REPORTER INTERFACE + // ========================================================================================= + + void report_query(const QueryData&) override { + xml.writeDeclaration(); + } + + void test_run_start() override { + xml.writeDeclaration(); + } + + void test_run_end(const TestRunStats& p) override { + // remove .exe extension - mainly to have the same output on UNIX and Windows + std::string binary_name = skipPathFromFilename(opt.binary_name.c_str()); +#ifdef DOCTEST_PLATFORM_WINDOWS + if(binary_name.rfind(".exe") != std::string::npos) + binary_name = binary_name.substr(0, binary_name.length() - 4); +#endif // DOCTEST_PLATFORM_WINDOWS + xml.startElement("testsuites"); + xml.startElement("testsuite").writeAttribute("name", binary_name) + .writeAttribute("errors", testCaseData.totalErrors) + .writeAttribute("failures", testCaseData.totalFailures) + .writeAttribute("tests", p.numAsserts); + if(opt.no_time_in_output == false) { + xml.writeAttribute("time", testCaseData.totalSeconds); + xml.writeAttribute("timestamp", JUnitTestCaseData::getCurrentTimestamp()); + } + if(opt.no_version == false) + xml.writeAttribute("doctest_version", DOCTEST_VERSION_STR); + + for(const auto& testCase : testCaseData.testcases) { + xml.startElement("testcase") + .writeAttribute("classname", testCase.classname) + .writeAttribute("name", testCase.name); + if(opt.no_time_in_output == false) + xml.writeAttribute("time", testCase.time); + // This is not ideal, but it should be enough to mimic gtest's junit output. + xml.writeAttribute("status", "run"); + + for(const auto& failure : testCase.failures) { + xml.scopedElement("failure") + .writeAttribute("message", failure.message) + .writeAttribute("type", failure.type) + .writeText(failure.details, false); + } + + for(const auto& error : testCase.errors) { + xml.scopedElement("error") + .writeAttribute("message", error.message) + .writeText(error.details); + } + + xml.endElement(); + } + xml.endElement(); + xml.endElement(); + } + + void test_case_start(const TestCaseData& in) override { + testCaseData.add(skipPathFromFilename(in.m_file.c_str()), in.m_name); + timer.start(); + } + + void test_case_reenter(const TestCaseData& in) override { + testCaseData.addTime(timer.getElapsedSeconds()); + testCaseData.appendSubcaseNamesToLastTestcase(deepestSubcaseStackNames); + deepestSubcaseStackNames.clear(); + + timer.start(); + testCaseData.add(skipPathFromFilename(in.m_file.c_str()), in.m_name); + } + + void test_case_end(const CurrentTestCaseStats&) override { + testCaseData.addTime(timer.getElapsedSeconds()); + testCaseData.appendSubcaseNamesToLastTestcase(deepestSubcaseStackNames); + deepestSubcaseStackNames.clear(); + } + + void test_case_exception(const TestCaseException& e) override { + DOCTEST_LOCK_MUTEX(mutex) + testCaseData.addError("exception", e.error_string.c_str()); + } + + void subcase_start(const SubcaseSignature& in) override { + deepestSubcaseStackNames.push_back(in.m_name); + } + + void subcase_end() override {} + + void log_assert(const AssertData& rb) override { + if(!rb.m_failed) // report only failures & ignore the `success` option + return; + + DOCTEST_LOCK_MUTEX(mutex) + + std::ostringstream os; + os << skipPathFromFilename(rb.m_file) << (opt.gnu_file_line ? ":" : "(") + << line(rb.m_line) << (opt.gnu_file_line ? ":" : "):") << std::endl; + + fulltext_log_assert_to_stream(os, rb); + log_contexts(os); + testCaseData.addFailure(rb.m_decomp.c_str(), assertString(rb.m_at), os.str()); + } + + void log_message(const MessageData& mb) override { + if(mb.m_severity & assertType::is_warn) // report only failures + return; + + DOCTEST_LOCK_MUTEX(mutex) + + std::ostringstream os; + os << skipPathFromFilename(mb.m_file) << (opt.gnu_file_line ? ":" : "(") + << line(mb.m_line) << (opt.gnu_file_line ? ":" : "):") << std::endl; + + os << mb.m_string.c_str() << "\n"; + log_contexts(os); + + testCaseData.addFailure(mb.m_string.c_str(), + mb.m_severity & assertType::is_check ? "FAIL_CHECK" : "FAIL", os.str()); + } + + void test_case_skipped(const TestCaseData&) override {} + + void log_contexts(std::ostringstream& s) { + int num_contexts = get_num_active_contexts(); + if(num_contexts) { + auto contexts = get_active_contexts(); + + s << " logged: "; + for(int i = 0; i < num_contexts; ++i) { + s << (i == 0 ? "" : " "); + contexts[i]->stringify(&s); + s << std::endl; + } + } + } + }; + + DOCTEST_REGISTER_REPORTER("junit", 0, JUnitReporter); + + struct Whitespace + { + int nrSpaces; + explicit Whitespace(int nr) + : nrSpaces(nr) {} + }; + + std::ostream& operator<<(std::ostream& out, const Whitespace& ws) { + if(ws.nrSpaces != 0) + out << std::setw(ws.nrSpaces) << ' '; + return out; + } + + struct ConsoleReporter : public IReporter + { + std::ostream& s; + bool hasLoggedCurrentTestStart; + std::vector subcasesStack; + size_t currentSubcaseLevel; + DOCTEST_DECLARE_MUTEX(mutex) + + // caching pointers/references to objects of these types - safe to do + const ContextOptions& opt; + const TestCaseData* tc; + + ConsoleReporter(const ContextOptions& co) + : s(*co.cout) + , opt(co) {} + + ConsoleReporter(const ContextOptions& co, std::ostream& ostr) + : s(ostr) + , opt(co) {} + + // ========================================================================================= + // WHAT FOLLOWS ARE HELPERS USED BY THE OVERRIDES OF THE VIRTUAL METHODS OF THE INTERFACE + // ========================================================================================= + + void separator_to_stream() { + s << Color::Yellow + << "===============================================================================" + "\n"; + } + + const char* getSuccessOrFailString(bool success, assertType::Enum at, + const char* success_str) { + if(success) + return success_str; + return failureString(at); + } + + Color::Enum getSuccessOrFailColor(bool success, assertType::Enum at) { + return success ? Color::BrightGreen : + (at & assertType::is_warn) ? Color::Yellow : Color::Red; + } + + void successOrFailColoredStringToStream(bool success, assertType::Enum at, + const char* success_str = "SUCCESS") { + s << getSuccessOrFailColor(success, at) + << getSuccessOrFailString(success, at, success_str) << ": "; + } + + void log_contexts() { + int num_contexts = get_num_active_contexts(); + if(num_contexts) { + auto contexts = get_active_contexts(); + + s << Color::None << " logged: "; + for(int i = 0; i < num_contexts; ++i) { + s << (i == 0 ? "" : " "); + contexts[i]->stringify(&s); + s << "\n"; + } + } + + s << "\n"; + } + + // this was requested to be made virtual so users could override it + virtual void file_line_to_stream(const char* file, int line, + const char* tail = "") { + s << Color::LightGrey << skipPathFromFilename(file) << (opt.gnu_file_line ? ":" : "(") + << (opt.no_line_numbers ? 0 : line) // 0 or the real num depending on the option + << (opt.gnu_file_line ? ":" : "):") << tail; + } + + void logTestStart() { + if(hasLoggedCurrentTestStart) + return; + + separator_to_stream(); + file_line_to_stream(tc->m_file.c_str(), tc->m_line, "\n"); + if(tc->m_description) + s << Color::Yellow << "DESCRIPTION: " << Color::None << tc->m_description << "\n"; + if(tc->m_test_suite && tc->m_test_suite[0] != '\0') + s << Color::Yellow << "TEST SUITE: " << Color::None << tc->m_test_suite << "\n"; + if(strncmp(tc->m_name, " Scenario:", 11) != 0) + s << Color::Yellow << "TEST CASE: "; + s << Color::None << tc->m_name << "\n"; + + for(size_t i = 0; i < currentSubcaseLevel; ++i) { + if(subcasesStack[i].m_name[0] != '\0') + s << " " << subcasesStack[i].m_name << "\n"; + } + + if(currentSubcaseLevel != subcasesStack.size()) { + s << Color::Yellow << "\nDEEPEST SUBCASE STACK REACHED (DIFFERENT FROM THE CURRENT ONE):\n" << Color::None; + for(size_t i = 0; i < subcasesStack.size(); ++i) { + if(subcasesStack[i].m_name[0] != '\0') + s << " " << subcasesStack[i].m_name << "\n"; + } + } + + s << "\n"; + + hasLoggedCurrentTestStart = true; + } + + void printVersion() { + if(opt.no_version == false) + s << Color::Cyan << "[doctest] " << Color::None << "doctest version is \"" + << DOCTEST_VERSION_STR << "\"\n"; + } + + void printIntro() { + if(opt.no_intro == false) { + printVersion(); + s << Color::Cyan << "[doctest] " << Color::None + << "run with \"--" DOCTEST_OPTIONS_PREFIX_DISPLAY "help\" for options\n"; + } + } + + void printHelp() { + int sizePrefixDisplay = static_cast(strlen(DOCTEST_OPTIONS_PREFIX_DISPLAY)); + printVersion(); + // clang-format off + s << Color::Cyan << "[doctest]\n" << Color::None; + s << Color::Cyan << "[doctest] " << Color::None; + s << "boolean values: \"1/on/yes/true\" or \"0/off/no/false\"\n"; + s << Color::Cyan << "[doctest] " << Color::None; + s << "filter values: \"str1,str2,str3\" (comma separated strings)\n"; + s << Color::Cyan << "[doctest]\n" << Color::None; + s << Color::Cyan << "[doctest] " << Color::None; + s << "filters use wildcards for matching strings\n"; + s << Color::Cyan << "[doctest] " << Color::None; + s << "something passes a filter if any of the strings in a filter matches\n"; +#ifndef DOCTEST_CONFIG_NO_UNPREFIXED_OPTIONS + s << Color::Cyan << "[doctest]\n" << Color::None; + s << Color::Cyan << "[doctest] " << Color::None; + s << "ALL FLAGS, OPTIONS AND FILTERS ALSO AVAILABLE WITH A \"" DOCTEST_CONFIG_OPTIONS_PREFIX "\" PREFIX!!!\n"; +#endif + s << Color::Cyan << "[doctest]\n" << Color::None; + s << Color::Cyan << "[doctest] " << Color::None; + s << "Query flags - the program quits after them. Available:\n\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "?, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "help, -" DOCTEST_OPTIONS_PREFIX_DISPLAY "h " + << Whitespace(sizePrefixDisplay*0) << "prints this message\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "v, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "version " + << Whitespace(sizePrefixDisplay*1) << "prints the version\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "c, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "count " + << Whitespace(sizePrefixDisplay*1) << "prints the number of matching tests\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "ltc, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "list-test-cases " + << Whitespace(sizePrefixDisplay*1) << "lists all matching tests by name\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "lts, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "list-test-suites " + << Whitespace(sizePrefixDisplay*1) << "lists all matching test suites\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "lr, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "list-reporters " + << Whitespace(sizePrefixDisplay*1) << "lists all registered reporters\n\n"; + // ================================================================================== << 79 + s << Color::Cyan << "[doctest] " << Color::None; + s << "The available / options/filters are:\n\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "tc, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "test-case= " + << Whitespace(sizePrefixDisplay*1) << "filters tests by their name\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "tce, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "test-case-exclude= " + << Whitespace(sizePrefixDisplay*1) << "filters OUT tests by their name\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "sf, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "source-file= " + << Whitespace(sizePrefixDisplay*1) << "filters tests by their file\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "sfe, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "source-file-exclude= " + << Whitespace(sizePrefixDisplay*1) << "filters OUT tests by their file\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "ts, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "test-suite= " + << Whitespace(sizePrefixDisplay*1) << "filters tests by their test suite\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "tse, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "test-suite-exclude= " + << Whitespace(sizePrefixDisplay*1) << "filters OUT tests by their test suite\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "sc, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "subcase= " + << Whitespace(sizePrefixDisplay*1) << "filters subcases by their name\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "sce, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "subcase-exclude= " + << Whitespace(sizePrefixDisplay*1) << "filters OUT subcases by their name\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "r, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "reporters= " + << Whitespace(sizePrefixDisplay*1) << "reporters to use (console is default)\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "o, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "out= " + << Whitespace(sizePrefixDisplay*1) << "output filename\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "ob, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "order-by= " + << Whitespace(sizePrefixDisplay*1) << "how the tests should be ordered\n"; + s << Whitespace(sizePrefixDisplay*3) << " - [file/suite/name/rand/none]\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "rs, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "rand-seed= " + << Whitespace(sizePrefixDisplay*1) << "seed for random ordering\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "f, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "first= " + << Whitespace(sizePrefixDisplay*1) << "the first test passing the filters to\n"; + s << Whitespace(sizePrefixDisplay*3) << " execute - for range-based execution\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "l, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "last= " + << Whitespace(sizePrefixDisplay*1) << "the last test passing the filters to\n"; + s << Whitespace(sizePrefixDisplay*3) << " execute - for range-based execution\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "aa, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "abort-after= " + << Whitespace(sizePrefixDisplay*1) << "stop after failed assertions\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "scfl,--" DOCTEST_OPTIONS_PREFIX_DISPLAY "subcase-filter-levels= " + << Whitespace(sizePrefixDisplay*1) << "apply filters for the first levels\n"; + s << Color::Cyan << "\n[doctest] " << Color::None; + s << "Bool options - can be used like flags and true is assumed. Available:\n\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "s, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "success= " + << Whitespace(sizePrefixDisplay*1) << "include successful assertions in output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "cs, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "case-sensitive= " + << Whitespace(sizePrefixDisplay*1) << "filters being treated as case sensitive\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "e, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "exit= " + << Whitespace(sizePrefixDisplay*1) << "exits after the tests finish\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "d, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "duration= " + << Whitespace(sizePrefixDisplay*1) << "prints the time duration of each test\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "m, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "minimal= " + << Whitespace(sizePrefixDisplay*1) << "minimal console output (only failures)\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "q, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "quiet= " + << Whitespace(sizePrefixDisplay*1) << "no console output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "nt, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-throw= " + << Whitespace(sizePrefixDisplay*1) << "skips exceptions-related assert checks\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "ne, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-exitcode= " + << Whitespace(sizePrefixDisplay*1) << "returns (or exits) always with success\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "nr, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-run= " + << Whitespace(sizePrefixDisplay*1) << "skips all runtime doctest operations\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "ni, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-intro= " + << Whitespace(sizePrefixDisplay*1) << "omit the framework intro in the output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "nv, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-version= " + << Whitespace(sizePrefixDisplay*1) << "omit the framework version in the output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "nc, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-colors= " + << Whitespace(sizePrefixDisplay*1) << "disables colors in output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "fc, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "force-colors= " + << Whitespace(sizePrefixDisplay*1) << "use colors even when not in a tty\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "nb, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-breaks= " + << Whitespace(sizePrefixDisplay*1) << "disables breakpoints in debuggers\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "ns, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-skip= " + << Whitespace(sizePrefixDisplay*1) << "don't skip test cases marked as skip\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "gfl, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "gnu-file-line= " + << Whitespace(sizePrefixDisplay*1) << ":n: vs (n): for line numbers in output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "npf, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-path-filenames= " + << Whitespace(sizePrefixDisplay*1) << "only filenames and no paths in output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "spp, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "skip-path-prefixes= " + << Whitespace(sizePrefixDisplay*1) << "whenever file paths start with this prefix, remove it from the output\n"; + s << " -" DOCTEST_OPTIONS_PREFIX_DISPLAY "nln, --" DOCTEST_OPTIONS_PREFIX_DISPLAY "no-line-numbers= " + << Whitespace(sizePrefixDisplay*1) << "0 instead of real line numbers in output\n"; + // ================================================================================== << 79 + // clang-format on + + s << Color::Cyan << "\n[doctest] " << Color::None; + s << "for more information visit the project documentation\n\n"; + } + + void printRegisteredReporters() { + printVersion(); + auto printReporters = [this] (const reporterMap& reporters, const char* type) { + if(reporters.size()) { + s << Color::Cyan << "[doctest] " << Color::None << "listing all registered " << type << "\n"; + for(auto& curr : reporters) + s << "priority: " << std::setw(5) << curr.first.first + << " name: " << curr.first.second << "\n"; + } + }; + printReporters(getListeners(), "listeners"); + printReporters(getReporters(), "reporters"); + } + + // ========================================================================================= + // WHAT FOLLOWS ARE OVERRIDES OF THE VIRTUAL METHODS OF THE REPORTER INTERFACE + // ========================================================================================= + + void report_query(const QueryData& in) override { + if(opt.version) { + printVersion(); + } else if(opt.help) { + printHelp(); + } else if(opt.list_reporters) { + printRegisteredReporters(); + } else if(opt.count || opt.list_test_cases) { + if(opt.list_test_cases) { + s << Color::Cyan << "[doctest] " << Color::None + << "listing all test case names\n"; + separator_to_stream(); + } + + for(unsigned i = 0; i < in.num_data; ++i) + s << Color::None << in.data[i]->m_name << "\n"; + + separator_to_stream(); + + s << Color::Cyan << "[doctest] " << Color::None + << "unskipped test cases passing the current filters: " + << g_cs->numTestCasesPassingFilters << "\n"; + + } else if(opt.list_test_suites) { + s << Color::Cyan << "[doctest] " << Color::None << "listing all test suites\n"; + separator_to_stream(); + + for(unsigned i = 0; i < in.num_data; ++i) + s << Color::None << in.data[i]->m_test_suite << "\n"; + + separator_to_stream(); + + s << Color::Cyan << "[doctest] " << Color::None + << "unskipped test cases passing the current filters: " + << g_cs->numTestCasesPassingFilters << "\n"; + s << Color::Cyan << "[doctest] " << Color::None + << "test suites with unskipped test cases passing the current filters: " + << g_cs->numTestSuitesPassingFilters << "\n"; + } + } + + void test_run_start() override { + if(!opt.minimal) + printIntro(); + } + + void test_run_end(const TestRunStats& p) override { + if(opt.minimal && p.numTestCasesFailed == 0) + return; + + separator_to_stream(); + s << std::dec; + + auto totwidth = int(std::ceil(log10(static_cast(std::max(p.numTestCasesPassingFilters, static_cast(p.numAsserts))) + 1))); + auto passwidth = int(std::ceil(log10(static_cast(std::max(p.numTestCasesPassingFilters - p.numTestCasesFailed, static_cast(p.numAsserts - p.numAssertsFailed))) + 1))); + auto failwidth = int(std::ceil(log10(static_cast(std::max(p.numTestCasesFailed, static_cast(p.numAssertsFailed))) + 1))); + const bool anythingFailed = p.numTestCasesFailed > 0 || p.numAssertsFailed > 0; + s << Color::Cyan << "[doctest] " << Color::None << "test cases: " << std::setw(totwidth) + << p.numTestCasesPassingFilters << " | " + << ((p.numTestCasesPassingFilters == 0 || anythingFailed) ? Color::None : + Color::Green) + << std::setw(passwidth) << p.numTestCasesPassingFilters - p.numTestCasesFailed << " passed" + << Color::None << " | " << (p.numTestCasesFailed > 0 ? Color::Red : Color::None) + << std::setw(failwidth) << p.numTestCasesFailed << " failed" << Color::None << " |"; + if(opt.no_skipped_summary == false) { + const int numSkipped = p.numTestCases - p.numTestCasesPassingFilters; + s << " " << (numSkipped == 0 ? Color::None : Color::Yellow) << numSkipped + << " skipped" << Color::None; + } + s << "\n"; + s << Color::Cyan << "[doctest] " << Color::None << "assertions: " << std::setw(totwidth) + << p.numAsserts << " | " + << ((p.numAsserts == 0 || anythingFailed) ? Color::None : Color::Green) + << std::setw(passwidth) << (p.numAsserts - p.numAssertsFailed) << " passed" << Color::None + << " | " << (p.numAssertsFailed > 0 ? Color::Red : Color::None) << std::setw(failwidth) + << p.numAssertsFailed << " failed" << Color::None << " |\n"; + s << Color::Cyan << "[doctest] " << Color::None + << "Status: " << (p.numTestCasesFailed > 0 ? Color::Red : Color::Green) + << ((p.numTestCasesFailed > 0) ? "FAILURE!" : "SUCCESS!") << Color::None << std::endl; + } + + void test_case_start(const TestCaseData& in) override { + hasLoggedCurrentTestStart = false; + tc = ∈ + subcasesStack.clear(); + currentSubcaseLevel = 0; + } + + void test_case_reenter(const TestCaseData&) override { + subcasesStack.clear(); + } + + void test_case_end(const CurrentTestCaseStats& st) override { + if(tc->m_no_output) + return; + + // log the preamble of the test case only if there is something + // else to print - something other than that an assert has failed + if(opt.duration || + (st.failure_flags && st.failure_flags != static_cast(TestCaseFailureReason::AssertFailure))) + logTestStart(); + + if(opt.duration) + s << Color::None << std::setprecision(6) << std::fixed << st.seconds + << " s: " << tc->m_name << "\n"; + + if(st.failure_flags & TestCaseFailureReason::Timeout) + s << Color::Red << "Test case exceeded time limit of " << std::setprecision(6) + << std::fixed << tc->m_timeout << "!\n"; + + if(st.failure_flags & TestCaseFailureReason::ShouldHaveFailedButDidnt) { + s << Color::Red << "Should have failed but didn't! Marking it as failed!\n"; + } else if(st.failure_flags & TestCaseFailureReason::ShouldHaveFailedAndDid) { + s << Color::Yellow << "Failed as expected so marking it as not failed\n"; + } else if(st.failure_flags & TestCaseFailureReason::CouldHaveFailedAndDid) { + s << Color::Yellow << "Allowed to fail so marking it as not failed\n"; + } else if(st.failure_flags & TestCaseFailureReason::DidntFailExactlyNumTimes) { + s << Color::Red << "Didn't fail exactly " << tc->m_expected_failures + << " times so marking it as failed!\n"; + } else if(st.failure_flags & TestCaseFailureReason::FailedExactlyNumTimes) { + s << Color::Yellow << "Failed exactly " << tc->m_expected_failures + << " times as expected so marking it as not failed!\n"; + } + if(st.failure_flags & TestCaseFailureReason::TooManyFailedAsserts) { + s << Color::Red << "Aborting - too many failed asserts!\n"; + } + s << Color::None; // lgtm [cpp/useless-expression] + } + + void test_case_exception(const TestCaseException& e) override { + DOCTEST_LOCK_MUTEX(mutex) + if(tc->m_no_output) + return; + + logTestStart(); + + file_line_to_stream(tc->m_file.c_str(), tc->m_line, " "); + successOrFailColoredStringToStream(false, e.is_crash ? assertType::is_require : + assertType::is_check); + s << Color::Red << (e.is_crash ? "test case CRASHED: " : "test case THREW exception: ") + << Color::Cyan << e.error_string << "\n"; + + int num_stringified_contexts = get_num_stringified_contexts(); + if(num_stringified_contexts) { + auto stringified_contexts = get_stringified_contexts(); + s << Color::None << " logged: "; + for(int i = num_stringified_contexts; i > 0; --i) { + s << (i == num_stringified_contexts ? "" : " ") + << stringified_contexts[i - 1] << "\n"; + } + } + s << "\n" << Color::None; + } + + void subcase_start(const SubcaseSignature& subc) override { + subcasesStack.push_back(subc); + ++currentSubcaseLevel; + hasLoggedCurrentTestStart = false; + } + + void subcase_end() override { + --currentSubcaseLevel; + hasLoggedCurrentTestStart = false; + } + + void log_assert(const AssertData& rb) override { + if((!rb.m_failed && !opt.success) || tc->m_no_output) + return; + + DOCTEST_LOCK_MUTEX(mutex) + + logTestStart(); + + file_line_to_stream(rb.m_file, rb.m_line, " "); + successOrFailColoredStringToStream(!rb.m_failed, rb.m_at); + + fulltext_log_assert_to_stream(s, rb); + + log_contexts(); + } + + void log_message(const MessageData& mb) override { + if(tc->m_no_output) + return; + + DOCTEST_LOCK_MUTEX(mutex) + + logTestStart(); + + file_line_to_stream(mb.m_file, mb.m_line, " "); + s << getSuccessOrFailColor(false, mb.m_severity) + << getSuccessOrFailString(mb.m_severity & assertType::is_warn, mb.m_severity, + "MESSAGE") << ": "; + s << Color::None << mb.m_string << "\n"; + log_contexts(); + } + + void test_case_skipped(const TestCaseData&) override {} + }; + + DOCTEST_REGISTER_REPORTER("console", 0, ConsoleReporter); + +#ifdef DOCTEST_PLATFORM_WINDOWS + struct DebugOutputWindowReporter : public ConsoleReporter + { + DOCTEST_THREAD_LOCAL static std::ostringstream oss; + + DebugOutputWindowReporter(const ContextOptions& co) + : ConsoleReporter(co, oss) {} + +#define DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(func, type, arg) \ + void func(type arg) override { \ + bool with_col = g_no_colors; \ + g_no_colors = false; \ + ConsoleReporter::func(arg); \ + if(oss.tellp() != std::streampos{}) { \ + DOCTEST_OUTPUT_DEBUG_STRING(oss.str().c_str()); \ + oss.str(""); \ + } \ + g_no_colors = with_col; \ + } + + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(test_run_start, DOCTEST_EMPTY, DOCTEST_EMPTY) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(test_run_end, const TestRunStats&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(test_case_start, const TestCaseData&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(test_case_reenter, const TestCaseData&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(test_case_end, const CurrentTestCaseStats&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(test_case_exception, const TestCaseException&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(subcase_start, const SubcaseSignature&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(subcase_end, DOCTEST_EMPTY, DOCTEST_EMPTY) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(log_assert, const AssertData&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(log_message, const MessageData&, in) + DOCTEST_DEBUG_OUTPUT_REPORTER_OVERRIDE(test_case_skipped, const TestCaseData&, in) + }; + + DOCTEST_THREAD_LOCAL std::ostringstream DebugOutputWindowReporter::oss; +#endif // DOCTEST_PLATFORM_WINDOWS + + // the implementation of parseOption() + bool parseOptionImpl(int argc, const char* const* argv, const char* pattern, String* value) { + // going from the end to the beginning and stopping on the first occurrence from the end + for(int i = argc; i > 0; --i) { + auto index = i - 1; + auto temp = std::strstr(argv[index], pattern); + if(temp && (value || strlen(temp) == strlen(pattern))) { //!OCLINT prefer early exits and continue + // eliminate matches in which the chars before the option are not '-' + bool noBadCharsFound = true; + auto curr = argv[index]; + while(curr != temp) { + if(*curr++ != '-') { + noBadCharsFound = false; + break; + } + } + if(noBadCharsFound && argv[index][0] == '-') { + if(value) { + // parsing the value of an option + temp += strlen(pattern); + const unsigned len = strlen(temp); + if(len) { + *value = temp; + return true; + } + } else { + // just a flag - no value + return true; + } + } + } + } + return false; + } + + // parses an option and returns the string after the '=' character + bool parseOption(int argc, const char* const* argv, const char* pattern, String* value = nullptr, + const String& defaultVal = String()) { + if(value) + *value = defaultVal; +#ifndef DOCTEST_CONFIG_NO_UNPREFIXED_OPTIONS + // offset (normally 3 for "dt-") to skip prefix + if(parseOptionImpl(argc, argv, pattern + strlen(DOCTEST_CONFIG_OPTIONS_PREFIX), value)) + return true; +#endif // DOCTEST_CONFIG_NO_UNPREFIXED_OPTIONS + return parseOptionImpl(argc, argv, pattern, value); + } + + // locates a flag on the command line + bool parseFlag(int argc, const char* const* argv, const char* pattern) { + return parseOption(argc, argv, pattern); + } + + // parses a comma separated list of words after a pattern in one of the arguments in argv + bool parseCommaSepArgs(int argc, const char* const* argv, const char* pattern, + std::vector& res) { + String filtersString; + if(parseOption(argc, argv, pattern, &filtersString)) { + // tokenize with "," as a separator, unless escaped with backslash + std::ostringstream s; + auto flush = [&s, &res]() { + auto string = s.str(); + if(string.size() > 0) { + res.push_back(string.c_str()); + } + s.str(""); + }; + + bool seenBackslash = false; + const char* current = filtersString.c_str(); + const char* end = current + strlen(current); + while(current != end) { + char character = *current++; + if(seenBackslash) { + seenBackslash = false; + if(character == ',' || character == '\\') { + s.put(character); + continue; + } + s.put('\\'); + } + if(character == '\\') { + seenBackslash = true; + } else if(character == ',') { + flush(); + } else { + s.put(character); + } + } + + if(seenBackslash) { + s.put('\\'); + } + flush(); + return true; + } + return false; + } + + enum optionType + { + option_bool, + option_int + }; + + // parses an int/bool option from the command line + bool parseIntOption(int argc, const char* const* argv, const char* pattern, optionType type, + int& res) { + String parsedValue; + if(!parseOption(argc, argv, pattern, &parsedValue)) + return false; + + if(type) { + // integer + // TODO: change this to use std::stoi or something else! currently it uses undefined behavior - assumes '0' on failed parse... + int theInt = std::atoi(parsedValue.c_str()); + if (theInt != 0) { + res = theInt; //!OCLINT parameter reassignment + return true; + } + } else { + // boolean + const char positive[][5] = { "1", "true", "on", "yes" }; // 5 - strlen("true") + 1 + const char negative[][6] = { "0", "false", "off", "no" }; // 6 - strlen("false") + 1 + + // if the value matches any of the positive/negative possibilities + for (unsigned i = 0; i < 4; i++) { + if (parsedValue.compare(positive[i], true) == 0) { + res = 1; //!OCLINT parameter reassignment + return true; + } + if (parsedValue.compare(negative[i], true) == 0) { + res = 0; //!OCLINT parameter reassignment + return true; + } + } + } + return false; + } +} // namespace + +Context::Context(int argc, const char* const* argv) + : p(new detail::ContextState) { + parseArgs(argc, argv, true); + if(argc) + p->binary_name = argv[0]; +} + +Context::~Context() { + if(g_cs == p) + g_cs = nullptr; + delete p; +} + +void Context::applyCommandLine(int argc, const char* const* argv) { + parseArgs(argc, argv); + if(argc) + p->binary_name = argv[0]; +} + +// parses args +void Context::parseArgs(int argc, const char* const* argv, bool withDefaults) { + using namespace detail; + + // clang-format off + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "source-file=", p->filters[0]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "sf=", p->filters[0]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "source-file-exclude=",p->filters[1]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "sfe=", p->filters[1]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "test-suite=", p->filters[2]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "ts=", p->filters[2]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "test-suite-exclude=", p->filters[3]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "tse=", p->filters[3]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "test-case=", p->filters[4]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "tc=", p->filters[4]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "test-case-exclude=", p->filters[5]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "tce=", p->filters[5]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "subcase=", p->filters[6]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "sc=", p->filters[6]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "subcase-exclude=", p->filters[7]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "sce=", p->filters[7]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "reporters=", p->filters[8]); + parseCommaSepArgs(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "r=", p->filters[8]); + // clang-format on + + int intRes = 0; + String strRes; + +#define DOCTEST_PARSE_AS_BOOL_OR_FLAG(name, sname, var, default) \ + if(parseIntOption(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX name "=", option_bool, intRes) || \ + parseIntOption(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX sname "=", option_bool, intRes)) \ + p->var = static_cast(intRes); \ + else if(parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX name) || \ + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX sname)) \ + p->var = true; \ + else if(withDefaults) \ + p->var = default + +#define DOCTEST_PARSE_INT_OPTION(name, sname, var, default) \ + if(parseIntOption(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX name "=", option_int, intRes) || \ + parseIntOption(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX sname "=", option_int, intRes)) \ + p->var = intRes; \ + else if(withDefaults) \ + p->var = default + +#define DOCTEST_PARSE_STR_OPTION(name, sname, var, default) \ + if(parseOption(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX name "=", &strRes, default) || \ + parseOption(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX sname "=", &strRes, default) || \ + withDefaults) \ + p->var = strRes + + // clang-format off + DOCTEST_PARSE_STR_OPTION("out", "o", out, ""); + DOCTEST_PARSE_STR_OPTION("order-by", "ob", order_by, "file"); + DOCTEST_PARSE_INT_OPTION("rand-seed", "rs", rand_seed, 0); + + DOCTEST_PARSE_INT_OPTION("first", "f", first, 0); + DOCTEST_PARSE_INT_OPTION("last", "l", last, UINT_MAX); + + DOCTEST_PARSE_INT_OPTION("abort-after", "aa", abort_after, 0); + DOCTEST_PARSE_INT_OPTION("subcase-filter-levels", "scfl", subcase_filter_levels, INT_MAX); + + DOCTEST_PARSE_AS_BOOL_OR_FLAG("success", "s", success, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("case-sensitive", "cs", case_sensitive, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("exit", "e", exit, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("duration", "d", duration, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("minimal", "m", minimal, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("quiet", "q", quiet, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-throw", "nt", no_throw, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-exitcode", "ne", no_exitcode, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-run", "nr", no_run, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-intro", "ni", no_intro, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-version", "nv", no_version, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-colors", "nc", no_colors, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("force-colors", "fc", force_colors, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-breaks", "nb", no_breaks, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-skip", "ns", no_skip, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("gnu-file-line", "gfl", gnu_file_line, !bool(DOCTEST_MSVC)); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-path-filenames", "npf", no_path_in_filenames, false); + DOCTEST_PARSE_STR_OPTION("strip-file-prefixes", "sfp", strip_file_prefixes, ""); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-line-numbers", "nln", no_line_numbers, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-debug-output", "ndo", no_debug_output, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-skipped-summary", "nss", no_skipped_summary, false); + DOCTEST_PARSE_AS_BOOL_OR_FLAG("no-time-in-output", "ntio", no_time_in_output, false); + // clang-format on + + if(withDefaults) { + p->help = false; + p->version = false; + p->count = false; + p->list_test_cases = false; + p->list_test_suites = false; + p->list_reporters = false; + } + if(parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "help") || + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "h") || + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "?")) { + p->help = true; + p->exit = true; + } + if(parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "version") || + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "v")) { + p->version = true; + p->exit = true; + } + if(parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "count") || + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "c")) { + p->count = true; + p->exit = true; + } + if(parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "list-test-cases") || + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "ltc")) { + p->list_test_cases = true; + p->exit = true; + } + if(parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "list-test-suites") || + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "lts")) { + p->list_test_suites = true; + p->exit = true; + } + if(parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "list-reporters") || + parseFlag(argc, argv, DOCTEST_CONFIG_OPTIONS_PREFIX "lr")) { + p->list_reporters = true; + p->exit = true; + } +} + +// allows the user to add procedurally to the filters from the command line +void Context::addFilter(const char* filter, const char* value) { setOption(filter, value); } + +// allows the user to clear all filters from the command line +void Context::clearFilters() { + for(auto& curr : p->filters) + curr.clear(); +} + +// allows the user to override procedurally the bool options from the command line +void Context::setOption(const char* option, bool value) { + setOption(option, value ? "true" : "false"); +} + +// allows the user to override procedurally the int options from the command line +void Context::setOption(const char* option, int value) { + setOption(option, toString(value).c_str()); +} + +// allows the user to override procedurally the string options from the command line +void Context::setOption(const char* option, const char* value) { + auto argv = String("-") + option + "=" + value; + auto lvalue = argv.c_str(); + parseArgs(1, &lvalue); +} + +// users should query this in their main() and exit the program if true +bool Context::shouldExit() { return p->exit; } + +void Context::setAsDefaultForAssertsOutOfTestCases() { g_cs = p; } + +void Context::setAssertHandler(detail::assert_handler ah) { p->ah = ah; } + +void Context::setCout(std::ostream* out) { p->cout = out; } + +static class DiscardOStream : public std::ostream +{ +private: + class : public std::streambuf + { + private: + // allowing some buffering decreases the amount of calls to overflow + char buf[1024]; + + protected: + std::streamsize xsputn(const char_type*, std::streamsize count) override { return count; } + + int_type overflow(int_type ch) override { + setp(std::begin(buf), std::end(buf)); + return traits_type::not_eof(ch); + } + } discardBuf; + +public: + DiscardOStream() + : std::ostream(&discardBuf) {} +} discardOut; + +// the main function that does all the filtering and test running +int Context::run() { + using namespace detail; + + // save the old context state in case such was setup - for using asserts out of a testing context + auto old_cs = g_cs; + // this is the current contest + g_cs = p; + is_running_in_test = true; + + g_no_colors = p->no_colors; + p->resetRunData(); + + std::fstream fstr; + if(p->cout == nullptr) { + if(p->quiet) { + p->cout = &discardOut; + } else if(p->out.size()) { + // to a file if specified + fstr.open(p->out.c_str(), std::fstream::out); + p->cout = &fstr; + } else { +#ifndef DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM + // stdout by default + p->cout = &std::cout; +#else // DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM + return EXIT_FAILURE; +#endif // DOCTEST_CONFIG_NO_INCLUDE_IOSTREAM + } + } + + FatalConditionHandler::allocateAltStackMem(); + + auto cleanup_and_return = [&]() { + FatalConditionHandler::freeAltStackMem(); + + if(fstr.is_open()) + fstr.close(); + + // restore context + g_cs = old_cs; + is_running_in_test = false; + + // we have to free the reporters which were allocated when the run started + for(auto& curr : p->reporters_currently_used) + delete curr; + p->reporters_currently_used.clear(); + + if(p->numTestCasesFailed && !p->no_exitcode) + return EXIT_FAILURE; + return EXIT_SUCCESS; + }; + + // setup default reporter if none is given through the command line + if(p->filters[8].empty()) + p->filters[8].push_back("console"); + + // check to see if any of the registered reporters has been selected + for(auto& curr : getReporters()) { + if(matchesAny(curr.first.second.c_str(), p->filters[8], false, p->case_sensitive)) + p->reporters_currently_used.push_back(curr.second(*g_cs)); + } + + // TODO: check if there is nothing in reporters_currently_used + + // prepend all listeners + for(auto& curr : getListeners()) + p->reporters_currently_used.insert(p->reporters_currently_used.begin(), curr.second(*g_cs)); + +#ifdef DOCTEST_PLATFORM_WINDOWS + if(isDebuggerActive() && p->no_debug_output == false) + p->reporters_currently_used.push_back(new DebugOutputWindowReporter(*g_cs)); +#endif // DOCTEST_PLATFORM_WINDOWS + + // handle version, help and no_run + if(p->no_run || p->version || p->help || p->list_reporters) { + DOCTEST_ITERATE_THROUGH_REPORTERS(report_query, QueryData()); + + return cleanup_and_return(); + } + + std::vector testArray; + for(auto& curr : getRegisteredTests()) + testArray.push_back(&curr); + p->numTestCases = testArray.size(); + + // sort the collected records + if(!testArray.empty()) { + if(p->order_by.compare("file", true) == 0) { + std::sort(testArray.begin(), testArray.end(), fileOrderComparator); + } else if(p->order_by.compare("suite", true) == 0) { + std::sort(testArray.begin(), testArray.end(), suiteOrderComparator); + } else if(p->order_by.compare("name", true) == 0) { + std::sort(testArray.begin(), testArray.end(), nameOrderComparator); + } else if(p->order_by.compare("rand", true) == 0) { + std::srand(p->rand_seed); + + // random_shuffle implementation + const auto first = &testArray[0]; + for(size_t i = testArray.size() - 1; i > 0; --i) { + int idxToSwap = std::rand() % (i + 1); + + const auto temp = first[i]; + + first[i] = first[idxToSwap]; + first[idxToSwap] = temp; + } + } else if(p->order_by.compare("none", true) == 0) { + // means no sorting - beneficial for death tests which call into the executable + // with a specific test case in mind - we don't want to slow down the startup times + } + } + + std::set testSuitesPassingFilt; + + bool query_mode = p->count || p->list_test_cases || p->list_test_suites; + std::vector queryResults; + + if(!query_mode) + DOCTEST_ITERATE_THROUGH_REPORTERS(test_run_start, DOCTEST_EMPTY); + + // invoke the registered functions if they match the filter criteria (or just count them) + for(auto& curr : testArray) { + const auto& tc = *curr; + + bool skip_me = false; + if(tc.m_skip && !p->no_skip) + skip_me = true; + + if(!matchesAny(tc.m_file.c_str(), p->filters[0], true, p->case_sensitive)) + skip_me = true; + if(matchesAny(tc.m_file.c_str(), p->filters[1], false, p->case_sensitive)) + skip_me = true; + if(!matchesAny(tc.m_test_suite, p->filters[2], true, p->case_sensitive)) + skip_me = true; + if(matchesAny(tc.m_test_suite, p->filters[3], false, p->case_sensitive)) + skip_me = true; + if(!matchesAny(tc.m_name, p->filters[4], true, p->case_sensitive)) + skip_me = true; + if(matchesAny(tc.m_name, p->filters[5], false, p->case_sensitive)) + skip_me = true; + + if(!skip_me) + p->numTestCasesPassingFilters++; + + // skip the test if it is not in the execution range + if((p->last < p->numTestCasesPassingFilters && p->first <= p->last) || + (p->first > p->numTestCasesPassingFilters)) + skip_me = true; + + if(skip_me) { + if(!query_mode) + DOCTEST_ITERATE_THROUGH_REPORTERS(test_case_skipped, tc); + continue; + } + + // do not execute the test if we are to only count the number of filter passing tests + if(p->count) + continue; + + // print the name of the test and don't execute it + if(p->list_test_cases) { + queryResults.push_back(&tc); + continue; + } + + // print the name of the test suite if not done already and don't execute it + if(p->list_test_suites) { + if((testSuitesPassingFilt.count(tc.m_test_suite) == 0) && tc.m_test_suite[0] != '\0') { + queryResults.push_back(&tc); + testSuitesPassingFilt.insert(tc.m_test_suite); + p->numTestSuitesPassingFilters++; + } + continue; + } + + // execute the test if it passes all the filtering + { + p->currentTest = &tc; + + p->failure_flags = TestCaseFailureReason::None; + p->seconds = 0; + + // reset atomic counters + p->numAssertsFailedCurrentTest_atomic = 0; + p->numAssertsCurrentTest_atomic = 0; + + p->fullyTraversedSubcases.clear(); + + DOCTEST_ITERATE_THROUGH_REPORTERS(test_case_start, tc); + + p->timer.start(); + + bool run_test = true; + + do { + // reset some of the fields for subcases (except for the set of fully passed ones) + p->reachedLeaf = false; + // May not be empty if previous subcase exited via exception. + p->subcaseStack.clear(); + p->currentSubcaseDepth = 0; + + p->shouldLogCurrentException = true; + + // reset stuff for logging with INFO() + p->stringifiedContexts.clear(); + +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + try { +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS +// MSVC 2015 diagnoses fatalConditionHandler as unused (because reset() is a static method) +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4101) // unreferenced local variable + FatalConditionHandler fatalConditionHandler; // Handle signals + // execute the test + tc.m_test(); + fatalConditionHandler.reset(); +DOCTEST_MSVC_SUPPRESS_WARNING_POP +#ifndef DOCTEST_CONFIG_NO_EXCEPTIONS + } catch(const TestFailureException&) { + p->failure_flags |= TestCaseFailureReason::AssertFailure; + } catch(...) { + DOCTEST_ITERATE_THROUGH_REPORTERS(test_case_exception, + {translateActiveException(), false}); + p->failure_flags |= TestCaseFailureReason::Exception; + } +#endif // DOCTEST_CONFIG_NO_EXCEPTIONS + + // exit this loop if enough assertions have failed - even if there are more subcases + if(p->abort_after > 0 && + p->numAssertsFailed + p->numAssertsFailedCurrentTest_atomic >= p->abort_after) { + run_test = false; + p->failure_flags |= TestCaseFailureReason::TooManyFailedAsserts; + } + + if(!p->nextSubcaseStack.empty() && run_test) + DOCTEST_ITERATE_THROUGH_REPORTERS(test_case_reenter, tc); + if(p->nextSubcaseStack.empty()) + run_test = false; + } while(run_test); + + p->finalizeTestCaseData(); + + DOCTEST_ITERATE_THROUGH_REPORTERS(test_case_end, *g_cs); + + p->currentTest = nullptr; + + // stop executing tests if enough assertions have failed + if(p->abort_after > 0 && p->numAssertsFailed >= p->abort_after) + break; + } + } + + if(!query_mode) { + DOCTEST_ITERATE_THROUGH_REPORTERS(test_run_end, *g_cs); + } else { + QueryData qdata; + qdata.run_stats = g_cs; + qdata.data = queryResults.data(); + qdata.num_data = unsigned(queryResults.size()); + DOCTEST_ITERATE_THROUGH_REPORTERS(report_query, qdata); + } + + return cleanup_and_return(); +} + +DOCTEST_DEFINE_INTERFACE(IReporter) + +int IReporter::get_num_active_contexts() { return detail::g_infoContexts.size(); } +const IContextScope* const* IReporter::get_active_contexts() { + return get_num_active_contexts() ? &detail::g_infoContexts[0] : nullptr; +} + +int IReporter::get_num_stringified_contexts() { return detail::g_cs->stringifiedContexts.size(); } +const String* IReporter::get_stringified_contexts() { + return get_num_stringified_contexts() ? &detail::g_cs->stringifiedContexts[0] : nullptr; +} + +namespace detail { + void registerReporterImpl(const char* name, int priority, reporterCreatorFunc c, bool isReporter) { + if(isReporter) + getReporters().insert(reporterMap::value_type(reporterMap::key_type(priority, name), c)); + else + getListeners().insert(reporterMap::value_type(reporterMap::key_type(priority, name), c)); + } +} // namespace detail + +} // namespace doctest + +#endif // DOCTEST_CONFIG_DISABLE + +#ifdef DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +DOCTEST_MSVC_SUPPRESS_WARNING_WITH_PUSH(4007) // 'function' : must be 'attribute' - see issue #182 +int main(int argc, char** argv) { return doctest::Context(argc, argv).run(); } +DOCTEST_MSVC_SUPPRESS_WARNING_POP +#endif // DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN + +DOCTEST_CLANG_SUPPRESS_WARNING_POP +DOCTEST_MSVC_SUPPRESS_WARNING_POP +DOCTEST_GCC_SUPPRESS_WARNING_POP + +DOCTEST_SUPPRESS_COMMON_WARNINGS_POP + +#endif // DOCTEST_LIBRARY_IMPLEMENTATION +#endif // DOCTEST_CONFIG_IMPLEMENT + +#ifdef DOCTEST_UNDEF_WIN32_LEAN_AND_MEAN +#undef WIN32_LEAN_AND_MEAN +#undef DOCTEST_UNDEF_WIN32_LEAN_AND_MEAN +#endif // DOCTEST_UNDEF_WIN32_LEAN_AND_MEAN + +#ifdef DOCTEST_UNDEF_NOMINMAX +#undef NOMINMAX +#undef DOCTEST_UNDEF_NOMINMAX +#endif // DOCTEST_UNDEF_NOMINMAX From 8c2edf949ea62a4c38223871e29ae04f9d1464af Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:06 -0400 Subject: [PATCH 2/8] Add BH.Tests project with fakes for the game tests/BH.Tests.vcxproj builds a Win32 console exe that compiles BH sources unmodified, plus: - fakes/EnginePtrs.cpp: defines the D2Ptrs.h engine pointers like BH.cpp does, with Patch::GetDllOffset routing the engine functions tests need to fakes and everything else to zeroed memory, and D2Version fixed to 1.13c. - fakes/FakeEngine: a small model of the game state those functions read. - fakes/BHGlobals.cpp: globals owned by BH.cpp/Item.cpp/Module.cpp, which are not compiled. - support/LootFilter: helpers to build items and load a filter file through Config::Parse and ItemDisplay::InitializeItemRules. Game and BH state are reset before every test case. The project is not part of BH.sln. --- .gitignore | 5 + tests/BH.Tests.vcxproj | 120 ++++++++++ tests/fakes/BHGlobals.cpp | 95 ++++++++ tests/fakes/BHGlobals.h | 15 ++ tests/fakes/EnginePtrs.cpp | 93 ++++++++ tests/fakes/FakeEngine.cpp | 423 +++++++++++++++++++++++++++++++++++ tests/fakes/FakeEngine.h | 133 +++++++++++ tests/fakes/ResetEachTest.h | 22 ++ tests/main.cpp | 3 + tests/support/LootFilter.cpp | 104 +++++++++ tests/support/LootFilter.h | 57 +++++ 11 files changed, 1070 insertions(+) create mode 100644 tests/BH.Tests.vcxproj create mode 100644 tests/fakes/BHGlobals.cpp create mode 100644 tests/fakes/BHGlobals.h create mode 100644 tests/fakes/EnginePtrs.cpp create mode 100644 tests/fakes/FakeEngine.cpp create mode 100644 tests/fakes/FakeEngine.h create mode 100644 tests/fakes/ResetEachTest.h create mode 100644 tests/main.cpp create mode 100644 tests/support/LootFilter.cpp create mode 100644 tests/support/LootFilter.h diff --git a/.gitignore b/.gitignore index 3345e568..9b502a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ Release/* /BH.VC.db /.vs out/ + +# Unit test build output +tests/bin/ +tests/obj/ +tests/junit.xml diff --git a/tests/BH.Tests.vcxproj b/tests/BH.Tests.vcxproj new file mode 100644 index 00000000..5e60325b --- /dev/null +++ b/tests/BH.Tests.vcxproj @@ -0,0 +1,120 @@ + + + + + + Debug + Win32 + + + Release + Win32 + + + + {7D3F1C2A-5B8E-4C61-9A0D-2E4B6F8A1C35} + BHTests + + + + Application + MultiByte + v141 + v142 + v143 + v143 + + + true + + + false + + + + $(ProjectDir)bin\$(Configuration)\ + $(ProjectDir)obj\$(Configuration)\ + BH.Tests + $(ProjectDir) + + + + Level3 + + ..\BH;..\ThirdParty;..\ThirdParty\cpp-lru-cache\include;fakes;support;%(AdditionalIncludeDirectories) + /utf-8 /bigobj %(AdditionalOptions) + + + Console + true + shlwapi.lib;version.lib;%(AdditionalDependencies) + + + + + Disabled + MultiThreadedDebug + + + + + MaxSpeed + MultiThreaded + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fakes/BHGlobals.cpp b/tests/fakes/BHGlobals.cpp new file mode 100644 index 00000000..5a9eee46 --- /dev/null +++ b/tests/fakes/BHGlobals.cpp @@ -0,0 +1,95 @@ +// Definitions that BH keeps in files the test build does not compile (BH.cpp, Item.cpp, Module.cpp, +// ModuleManager.cpp). Those files install game hooks and start BH up, so they are left out; the +// globals they own are plain data here, with the defaults the tests rely on noted. +#include "BH.h" +#include "Modules/Item/Item.h" +#include "Modules/Item/ItemDisplay.h" +#include "Modules/Module.h" +#include "Modules/ModuleManager.h" + +#include "BHGlobals.h" +#include "ResetEachTest.h" + +// BH.cpp +string BH::path; +HINSTANCE BH::instance; +ModuleManager* BH::moduleManager = &fake::Modules(); +Config* BH::lootFilter; +BHApp App; +bool BH::cGuardLoaded; // false: no cGuard in the test process + +// Item.cpp. The real values come from the game's data tables at game join; tests set what they need. +unsigned int STAT_MAX; +unsigned int SKILL_MAX; +unsigned int PREFIX_OFFSET; +unsigned int AUTOMOD_OFFSET; +std::vector AllStatList; +std::vector CharList; +std::map ItemAttributeMap; // item code -> attributes; empty + +// Item.cpp: true once the data tables above are loaded, which the tests treat as always. +bool IsInitialized() { + return true; +} + +// Item.cpp (same body). +void ResetCaches() { + item_desc_cache.ResetCache(); + item_name_cache.ResetCache(); + map_action_cache.ResetCache(); +} + +// Module.cpp / ModuleManager.cpp / Item.cpp: just enough of the module system for +// ItemDisplay::InitializeItemRules, which publishes the filter level names on the "item" module. +Module::Module(string name) : name(name), active(false) {} +Module::~Module() {} + +ModuleManager::ModuleManager() {} +ModuleManager::~ModuleManager() {} + +Module* ModuleManager::Get(string name) { + return name == "item" ? &fake::ItemModule() : nullptr; +} + +void Item::OnLoad() {} +void Item::OnUnload() {} +void Item::LoadConfig() {} +void Item::OnGameJoin() {} +void Item::OnLoop() {} +void Item::OnKey(bool up, BYTE key, LPARAM lParam, bool* block) {} +void Item::OnLeftClick(bool up, int x, int y, bool* block) {} + +namespace fake { +namespace { + +// Every test case starts with BH's settings at their defaults, no loot filter loaded, and the +// data-table sizes of the 1.13c game (ItemStatCost.txt and Skills.txt row counts). +void ResetBHState() { + ItemDisplay::UninitializeItemRules(); + UnknownItemCodes.clear(); + ItemAttributeMap.clear(); + ItemModule().ItemFilterNames.clear(); + App = BHApp(); + BH::path.clear(); + BH::lootFilter = nullptr; + STAT_MAX = 359; + SKILL_MAX = 357; + PREFIX_OFFSET = 0; + AUTOMOD_OFFSET = 0; +} + +} // namespace + +REGISTER_LISTENER("bh-state-reset", 1, ResetEachTest<&ResetBHState>); + +ModuleManager& Modules() { + static ModuleManager manager; + return manager; +} + +Item& ItemModule() { + static Item item; + return item; +} + +} // namespace fake diff --git a/tests/fakes/BHGlobals.h b/tests/fakes/BHGlobals.h new file mode 100644 index 00000000..c77d3721 --- /dev/null +++ b/tests/fakes/BHGlobals.h @@ -0,0 +1,15 @@ +// BH's own globals for the test build (see BHGlobals.cpp). +#pragma once + +class Item; +class ModuleManager; + +namespace fake { + +// BH::moduleManager points here. Get("item") returns ItemModule(). +ModuleManager& Modules(); + +// The "item" module, whose ItemFilterNames ItemDisplay::InitializeItemRules fills in. +Item& ItemModule(); + +} // namespace fake diff --git a/tests/fakes/EnginePtrs.cpp b/tests/fakes/EnginePtrs.cpp new file mode 100644 index 00000000..4d54fedb --- /dev/null +++ b/tests/fakes/EnginePtrs.cpp @@ -0,0 +1,93 @@ +// Defines the engine pointers declared in D2Ptrs.h for the test executable, the way BH.cpp does +// for the real DLL (_DEFINE_PTRS). There is no game in the test process, so Patch::GetDllOffset +// and D2Version are replaced here: an engine function the tests need resolves to its fake in +// FakeEngine.cpp, and anything else (game variables, asm addresses) resolves to a zeroed block +// of memory (fake::Slot). Calling an engine function that has no fake therefore crashes the test +// that does it, and doctest reports which test that was. +#define _DEFINE_PTRS +#include "D2Ptrs.h" + +#include "FakeEngine.h" + +namespace { + +struct Route { + Dll dll; + int offset; + void* target; +}; + +// `real` is only there for its type: a fake must have exactly the engine function's signature +// and calling convention, or this does not compile. +template +Route MakeRoute(Dll dll, const Offsets& offsets, F* /*real*/, F* fake) { + return Route{ dll, offsets._113c, reinterpret_cast(fake) }; +} + +#define ROUTE(dll, name, fake) MakeRoute(dll, f##dll##_##name##_offsets, &dll##_##name, fake) + +const Route* Routes(size_t& count) { + static const Route routes[] = { + ROUTE(D2LANG, GetLocaleText, &fake::GetLocaleText), + ROUTE(D2CLIENT, PrintGameString, &fake::PrintGameString), + ROUTE(D2CLIENT, GetPlayerUnit, &fake::GetPlayerUnit), + ROUTE(D2CLIENT, GetDifficulty, &fake::GetDifficulty), + ROUTE(D2CLIENT, GetCurrentInteractingNPC, &fake::GetCurrentInteractingNPC), + ROUTE(D2CLIENT, GetQuestInfo, &fake::GetQuestInfo), + ROUTE(D2COMMON, GetUnitStat, &fake::GetUnitStat), + ROUTE(D2COMMON, GetStatList, &fake::GetStatList), + ROUTE(D2COMMON, CopyStatList, &fake::CopyStatList), + ROUTE(D2COMMON, GetStatValueFromStatList, &fake::GetStatValueFromStatList), + ROUTE(D2COMMON, GetStateStatList, &fake::GetStateStatList), + ROUTE(D2COMMON, GetItemText, &fake::GetItemText), + ROUTE(D2COMMON, GetItemTextFromItemCode, &fake::GetItemTextFromItemCode), + ROUTE(D2COMMON, GetItemLevelRequirement, &fake::GetItemLevelRequirement), + ROUTE(D2COMMON, GetMaxSockets, &fake::GetMaxSockets), + ROUTE(D2COMMON, GetItemPrice, &fake::GetItemPrice), + ROUTE(D2COMMON, GetRoomFromUnit, &fake::GetRoomFromUnit), + ROUTE(D2COMMON, GetLevelIdFromRoom, &fake::GetLevelIdFromRoom), + ROUTE(D2WIN, DrawText, &fake::WinDrawText), + ROUTE(D2WIN, SetTextSize, &fake::SetTextSize), + ROUTE(D2WIN, GetTextWidthFileNo, &fake::GetTextWidthFileNo), + ROUTE(D2CLIENT, FindServerSideUnit, &fake::FindServerSideUnit), + ROUTE(D2CLIENT, GetItemName, &fake::ClientGetItemName), + ROUTE(D2CLIENT, LeaveParty, &fake::LeaveParty), + ROUTE(D2NET, SendPacket, &fake::SendPacket), + }; + count = sizeof(routes) / sizeof(routes[0]); + return routes; +} + +} // namespace + +int Patch::GetDllOffset(Dll dll, int offset) { + size_t count = 0; + const Route* routes = Routes(count); + for (size_t i = 0; i < count; i++) { + if (routes[i].dll == dll && routes[i].offset == offset) { + return reinterpret_cast(routes[i].target); + } + } + return reinterpret_cast(fake::Slot(dll, offset)); +} + +VersionID D2Version::versionID = VERSION_113c; + +VersionID D2Version::GetGameVersionID() { + return VERSION_113c; +} + +// Modules under test (e.g. Gamefilter.cpp) create their code patches as globals. There is no game +// code to patch, so a patch only records whether it is installed and never writes memory. +Patch::Patch(PatchType type, Dll dll, Offsets offsets, int function, int length) + : dll(dll), type(type), offsets(offsets), length(length), function(function), oldCode(NULL), injected(false) {} + +bool Patch::Install() { + injected = true; + return true; +} + +bool Patch::Remove() { + injected = false; + return true; +} diff --git a/tests/fakes/FakeEngine.cpp b/tests/fakes/FakeEngine.cpp new file mode 100644 index 00000000..993fd049 --- /dev/null +++ b/tests/fakes/FakeEngine.cpp @@ -0,0 +1,423 @@ +#include "FakeEngine.h" + +#include +#include +#include +#include +#include + +#include "Constants.h" +#include "ResetEachTest.h" + +namespace fake { +namespace { + +// A unit's stats, kept as the game keeps them: one list per unit, plus the runeword state list. +// The StatList objects are only handles; the values live in the maps. +struct Stats { + StatList list{}; + StatList runewordList{}; + std::map, int> values; // (stat, param) -> value + std::map, int> runewordValues; // same, runeword state list +}; + +struct World { + std::deque items; + UnitAny player{}; + PlayerData playerData{}; + std::map> stats; + std::map> itemTxt; + std::map localeText; + std::map, DWORD> levelRequirement; // (item, class or -1) + std::map maxSockets; + std::map, DWORD> price; // (item, transaction type) + std::map uiVars; + std::vector> printed; + UnitAny* interactingNpc = nullptr; + BYTE difficulty = 0; + int areaId = 0; + std::vector drawn; + DWORD textSize = 0; + std::map, UnitAny*> serverUnits; // (type, id) -> unit + std::map levelNames; + std::map itemNames; + void* questInfo = nullptr; + std::vector> sentPackets; + std::vector> partyClicks; + int partyLeaves = 0; +}; + +World& W() { + static World world; + return world; +} + +// Game variables and asm addresses: one zeroed block per (dll, offset), handed out the first +// time BH resolves it (at static initialisation, for most of them) and cleared on Reset(). +struct SlotStore { + static const size_t kSlotSize = 256; + std::map, std::unique_ptr> slots; +}; + +SlotStore& Slots() { + static SlotStore store; + return store; +} + +Stats& StatsOf(const UnitAny* unit) { + auto& entry = W().stats[unit]; + if (!entry) { + entry.reset(new Stats()); + } + return *entry; +} + +const UnitAny* OwnerOf(const StatList* list, bool& runeword) { + for (auto& entry : W().stats) { + if (&entry.second->list == list) { + runeword = false; + return entry.first; + } + if (&entry.second->runewordList == list) { + runeword = true; + return entry.first; + } + } + return nullptr; +} + +} // namespace + +REGISTER_LISTENER("fake-engine-reset", 1, ResetEachTest<&Reset>); + +void Reset() { + W() = World(); + for (auto& slot : Slots().slots) { + std::memset(slot.second.get(), 0, SlotStore::kSlotSize); + } + W().player.dwType = UNIT_PLAYER; + W().player.dwUnitId = 1; + W().player.pPlayerData = &W().playerData; + Var(Var_D2CLIENT_PlayerUnit) = &W().player; +} + +void* Slot(int dll, int offset) { + auto& slot = Slots().slots[std::make_pair(dll, offset)]; + if (!slot) { + slot.reset(new char[SlotStore::kSlotSize]()); + } + return slot.get(); +} + +ItemUnit& NewItem(DWORD txtFileNo, DWORD quality) { + W().items.emplace_back(); + ItemUnit& item = W().items.back(); + std::memset(&item, 0, sizeof(item)); + item.unit.dwType = UNIT_ITEM; + item.unit.dwTxtFileNo = txtFileNo; + item.unit.dwUnitId = 1000 + static_cast(W().items.size()); + item.unit.pItemData = &item.data; + item.data.dwQuality = quality; + item.data.dwFlags = ITEM_IDENTIFIED; + item.data.dwItemLevel = 1; + return item; +} + +UnitAny& Player() { + return W().player; +} + +void SetStat(UnitAny* unit, int stat, int value, int param) { + StatsOf(unit).values[std::make_pair(stat, param)] = value; +} + +void SetRunewordStat(UnitAny* unit, int stat, int value, int param) { + StatsOf(unit).runewordValues[std::make_pair(stat, param)] = value; +} + +ItemsTxt& AddItemTxt(DWORD txtFileNo, const char* code) { + auto& txt = W().itemTxt[txtFileNo]; + txt.reset(new ItemsTxt()); + std::memset(txt.get(), 0, sizeof(ItemsTxt)); + std::memset(txt->szCode, ' ', sizeof(txt->szCode)); + std::memcpy(txt->szCode, code, (std::min)(std::strlen(code), sizeof(txt->szCode))); + return *txt; +} + +void SetLocaleText(WORD id, const std::wstring& text) { + W().localeText[id] = text; +} + +void SetDifficulty(BYTE difficulty) { + W().difficulty = difficulty; +} + +void SetAreaId(int levelId) { + W().areaId = levelId; +} + +void SetLevelRequirement(UnitAny* item, DWORD level, int classId) { + W().levelRequirement[std::make_pair(static_cast(item), classId)] = level; +} + +void SetMaxSockets(UnitAny* item, BYTE sockets) { + W().maxSockets[item] = sockets; +} + +void SetPrice(UnitAny* item, int transactionType, DWORD price) { + W().price[std::make_pair(static_cast(item), transactionType)] = price; +} + +void SetInteractingNpc(UnitAny* npc) { + W().interactingNpc = npc; +} + +void SetUIVar(DWORD varno, DWORD value) { + W().uiVars[varno] = value; +} + +const std::vector>& Printed() { + return W().printed; +} + +// ---- Engine function fakes ----------------------------------------------------------------- + +wchar_t* __fastcall GetLocaleText(WORD nLocaleTxtNo) { + static wchar_t empty[1] = { 0 }; + auto it = W().localeText.find(nLocaleTxtNo); + return it == W().localeText.end() ? empty : &it->second[0]; +} + +void __stdcall PrintGameString(wchar_t* wMessage, int nColor) { + W().printed.emplace_back(wMessage, nColor); +} + +UnitAny* __stdcall GetPlayerUnit() { + return &W().player; +} + +BYTE __stdcall GetDifficulty() { + return W().difficulty; +} + +UnitAny* __fastcall GetCurrentInteractingNPC() { + return W().interactingNpc; +} + +void* __stdcall GetQuestInfo() { + return W().questInfo; +} + +DWORD __stdcall GetUnitStat(UnitAny* pUnit, DWORD dwStat, DWORD dwStat2) { + auto it = W().stats.find(pUnit); + if (it == W().stats.end()) { + return 0; + } + auto value = it->second->values.find(std::make_pair(static_cast(dwStat), static_cast(dwStat2))); + return value == it->second->values.end() ? 0 : static_cast(value->second); +} + +StatList* __stdcall GetStatList(UnitAny* pUnit, DWORD dwUnk, DWORD dwMaxEntries) { + return pUnit ? &StatsOf(pUnit).list : nullptr; +} + +DWORD __stdcall CopyStatList(StatList* pStatList, Stat* pStatArray, DWORD dwMaxEntries) { + bool runeword = false; + const UnitAny* owner = OwnerOf(pStatList, runeword); + if (!owner) { + return 0; + } + const auto& values = runeword ? W().stats[owner]->runewordValues : W().stats[owner]->values; + DWORD count = 0; + for (const auto& entry : values) { + if (count == dwMaxEntries) { + break; + } + pStatArray[count].wStatIndex = static_cast(entry.first.first); + pStatArray[count].wSubIndex = static_cast(entry.first.second); + pStatArray[count].dwStatValue = static_cast(entry.second); + count++; + } + return count; +} + +int __stdcall GetStatValueFromStatList(StatList* pStatList, int statId, WORD nLayer) { + bool runeword = false; + const UnitAny* owner = OwnerOf(pStatList, runeword); + if (!owner) { + return 0; + } + const auto& values = runeword ? W().stats[owner]->runewordValues : W().stats[owner]->values; + auto it = values.find(std::make_pair(statId, static_cast(nLayer))); + return it == values.end() ? 0 : it->second; +} + +StatList* __stdcall GetStateStatList(UnitAny* pUnit, DWORD dwStateNo) { + if (!pUnit || dwStateNo != STATE_RUNEWORD) { + return nullptr; + } + auto it = W().stats.find(pUnit); + if (it == W().stats.end() || it->second->runewordValues.empty()) { + return nullptr; + } + return &it->second->runewordList; +} + +ItemsTxt* __stdcall GetItemText(DWORD dwItemNo) { + auto it = W().itemTxt.find(dwItemNo); + return it == W().itemTxt.end() ? nullptr : it->second.get(); +} + +ItemsTxt* __stdcall GetItemTextFromItemCode(DWORD dwCode, int* pItemId) { + for (auto& entry : W().itemTxt) { + if (entry.second->dwcode == dwCode) { + if (pItemId) { + *pItemId = static_cast(entry.first); + } + return entry.second.get(); + } + } + return nullptr; +} + +DWORD __stdcall GetItemLevelRequirement(UnitAny* pItem, UnitAny* pPlayer) { + const auto& reqs = W().levelRequirement; + auto it = pPlayer ? reqs.find(std::make_pair(static_cast(pItem), static_cast(pPlayer->dwTxtFileNo))) + : reqs.end(); + if (it == reqs.end()) { + it = reqs.find(std::make_pair(static_cast(pItem), -1)); + } + return it == reqs.end() ? 0 : it->second; +} + +BYTE __stdcall GetMaxSockets(UnitAny* pItem) { + auto it = W().maxSockets.find(pItem); + return it == W().maxSockets.end() ? 0 : it->second; +} + +DWORD __stdcall GetItemPrice(UnitAny* pPlayer, UnitAny* pItem, DWORD nDifficulty, DWORD pQuestInfo, DWORD nVendorId, + DWORD nTransactionType) { + auto it = W().price.find(std::make_pair(static_cast(pItem), static_cast(nTransactionType))); + return it == W().price.end() ? 0 : it->second; +} + +Room1* __stdcall GetRoomFromUnit(UnitAny* ptUnit) { + // Any non-null room; GetLevelIdFromRoom ignores it. + static char room[64]; + return ptUnit ? reinterpret_cast(room) : nullptr; +} + +int __stdcall GetLevelIdFromRoom(Room1* pRoom) { + return W().areaId; +} + + +const std::vector& Drawn() { + return W().drawn; +} + +void AddServerUnit(UnitAny* unit) { + W().serverUnits[std::make_pair(unit->dwType, unit->dwUnitId)] = unit; +} + +void SetLevelName(DWORD levelId, const std::wstring& name) { + W().levelNames[levelId] = name; +} + +void SetItemName(UnitAny* item, const std::wstring& name) { + W().itemNames[item] = name; +} + +void SetQuestInfo(void* quests) { + W().questInfo = quests; +} + +const std::vector>& SentPackets() { + return W().sentPackets; +} + +const std::vector>& PartyClicks() { + return W().partyClicks; +} + +int PartyLeaves() { + return W().partyLeaves; +} + +void __fastcall WinDrawText(const wchar_t* wStr, int xPos, int yPos, DWORD dwColor, DWORD dwUnk) { + W().drawn.push_back(DrawnText{ wStr, xPos, yPos, dwColor, W().textSize }); +} + +DWORD __fastcall SetTextSize(DWORD dwSize) { + DWORD old = W().textSize; + W().textSize = dwSize; + return old; +} + +DWORD __fastcall GetTextWidthFileNo(wchar_t* wStr, DWORD* dwWidth, DWORD* dwFileNo) { + *dwWidth = static_cast(wcslen(wStr)) * kCharWidth; + *dwFileNo = 0; + return *dwWidth; +} + +UnitAny* __fastcall FindServerSideUnit(DWORD dwId, DWORD dwType) { + auto it = W().serverUnits.find(std::make_pair(dwType, dwId)); + return it == W().serverUnits.end() ? nullptr : it->second; +} + +BOOL __stdcall ClientGetItemName(UnitAny* pItem, wchar_t* wBuffer, DWORD dwSize) { + auto it = W().itemNames.find(pItem); + std::wstring name = it == W().itemNames.end() ? L"" : it->second; + wcsncpy_s(wBuffer, dwSize, name.c_str(), _TRUNCATE); + return TRUE; +} + +void __fastcall LeaveParty(void) { + W().partyLeaves++; +} + +void __stdcall SendPacket(size_t aLen, DWORD arg1, BYTE* aPacket) { + W().sentPackets.emplace_back(aPacket, aPacket + aLen); +} + +} // namespace fake + +// D2Stubs.cpp wraps a few engine functions with register-convention asm thunks; the test build +// does not compile D2Stubs.cpp, so these are the fakes for the ones the code under test calls. +DWORD __fastcall D2CLIENT_GetUIVar_STUB(DWORD varno) { + auto it = fake::W().uiVars.find(varno); + return it == fake::W().uiVars.end() ? 0 : it->second; +} + +DWORD __fastcall D2CLIENT_GetUnitName_STUB(DWORD unit) { + static wchar_t empty[1] = { 0 }; + return reinterpret_cast(empty); +} + +DWORD __fastcall TestPvpFlag_STUB(DWORD planum1, DWORD planum2, DWORD flagmask) { + return 0; +} + +int __stdcall D2COMMON_GetSequenceIndex_STUB(UnitAny* pUnit) { + return 0; +} + +int __stdcall D2COMMON_GetFrameMinAccr_STUB(int nIndex, UnitAny* pUnit) { + return 0; +} + +// Gamefilter.cpp loads the join-game screen's button/box images; tests have no UI images. +CellFile* __fastcall D2CLIENT_LoadUiImage(CHAR* szPath) { + return NULL; +} + +DWORD __fastcall D2CLIENT_GetLevelName_STUB(DWORD levelId) { + static wchar_t empty[1] = { 0 }; + auto it = fake::W().levelNames.find(levelId); + return reinterpret_cast(it == fake::W().levelNames.end() ? empty : &it->second[0]); +} + +DWORD __fastcall D2CLIENT_ClickParty_ASM(RosterUnit* RosterUnit, DWORD Mode) { + fake::W().partyClicks.emplace_back(RosterUnit, Mode); + return 0; +} diff --git a/tests/fakes/FakeEngine.h b/tests/fakes/FakeEngine.h new file mode 100644 index 00000000..9525eefb --- /dev/null +++ b/tests/fakes/FakeEngine.h @@ -0,0 +1,133 @@ +// A small model of the game functions BH's logic calls, with state the tests set up. +// EnginePtrs.cpp routes the matching D2Ptrs.h entries here. Everything is reset before each +// test case, so tests never see each other's units, strings or settings. +#pragma once +#include + +#include +#include +#include + +#include "D2Ptrs.h" + +namespace fake { + +void Reset(); + +// ---- Units ------------------------------------------------------------------------------- + +// An item unit. `unit.pItemData` points at `data`. Lives until the next Reset(). +struct ItemUnit { + UnitAny unit; + ItemData data; +}; + +// A new item: dwType UNIT_ITEM, dwTxtFileNo = txtFileNo (see AddItemTxt), the given quality, +// identified, item level 1. +ItemUnit& NewItem(DWORD txtFileNo, DWORD quality); + +// The player unit D2CLIENT_GetPlayerUnit returns (and *p_D2CLIENT_PlayerUnit points at). +// Class is dwTxtFileNo (0 Amazon .. 6 Assassin, charstats.txt order); default Amazon. +UnitAny& Player(); + +// A unit stat as D2COMMON_GetUnitStat reports it, and as the unit's stat list +// (D2COMMON_GetStatList + CopyStatList / GetStatValueFromStatList) contains it. +void SetStat(UnitAny* unit, int stat, int value, int param = 0); + +// A stat on the unit's runeword state list (D2COMMON_GetStateStatList(unit, STATE_RUNEWORD)). +void SetRunewordStat(UnitAny* unit, int stat, int value, int param = 0); + +// ---- Data tables ------------------------------------------------------------------------- + +// Registers a Weapons/Armor/Misc.txt record for D2COMMON_GetItemText(txtFileNo) and +// D2COMMON_GetItemTextFromItemCode. `code` is the 3-4 letter item code. Everything else is zero. +ItemsTxt& AddItemTxt(DWORD txtFileNo, const char* code); + +// D2LANG_GetLocaleText: the string table. Unknown ids return an empty string. +void SetLocaleText(WORD id, const std::wstring& text); + +// ---- Game state -------------------------------------------------------------------------- + +void SetDifficulty(BYTE difficulty); // 0 normal, 1 nightmare, 2 hell +void SetAreaId(int levelId); // level the player is in (Levels.txt id) +// D2COMMON_GetItemLevelRequirement: the item's level requirement for a character of class +// `classId` (charstats.txt order), or for every class when classId is -1. A class-specific value +// wins over the every-class one (charged skills of another class raise the requirement). +void SetLevelRequirement(UnitAny* item, DWORD level, int classId = -1); +void SetMaxSockets(UnitAny* item, BYTE sockets); // D2COMMON_GetMaxSockets +// D2COMMON_GetItemPrice for a transaction type (TRANSACTIONTYPE_BUY/SELL/...), any vendor. +void SetPrice(UnitAny* item, int transactionType, DWORD price); +void SetInteractingNpc(UnitAny* npc); // D2CLIENT_GetCurrentInteractingNPC +void SetUIVar(DWORD varno, DWORD value); // D2CLIENT_GetUIState + +// Lines printed to the in-game chat with D2CLIENT_PrintGameString (text, colour). +const std::vector>& Printed(); + +// A game variable (VARPTR in D2Ptrs.h), e.g. Var(Var_D2CLIENT_ScreenSizeX) = 800. +// Every variable starts zeroed in each test. +template +T& Var(T** (*accessor)(void)) { + return **accessor(); +} + +// Storage for a game variable or asm address no test has set; used by EnginePtrs.cpp. +void* Slot(int dll, int offset); + +// ---- Engine function fakes (signatures match D2Ptrs.h) ------------------------------------ + +wchar_t* __fastcall GetLocaleText(WORD nLocaleTxtNo); +void __stdcall PrintGameString(wchar_t* wMessage, int nColor); +UnitAny* __stdcall GetPlayerUnit(); +BYTE __stdcall GetDifficulty(); +UnitAny* __fastcall GetCurrentInteractingNPC(); +void* __stdcall GetQuestInfo(); +DWORD __stdcall GetUnitStat(UnitAny* pUnit, DWORD dwStat, DWORD dwStat2); +StatList* __stdcall GetStatList(UnitAny* pUnit, DWORD dwUnk, DWORD dwMaxEntries); +DWORD __stdcall CopyStatList(StatList* pStatList, Stat* pStatArray, DWORD dwMaxEntries); +int __stdcall GetStatValueFromStatList(StatList* pStatList, int statId, WORD nLayer); +StatList* __stdcall GetStateStatList(UnitAny* pUnit, DWORD dwStateNo); +ItemsTxt* __stdcall GetItemText(DWORD dwItemNo); +ItemsTxt* __stdcall GetItemTextFromItemCode(DWORD dwCode, int* pItemId); +DWORD __stdcall GetItemLevelRequirement(UnitAny* pItem, UnitAny* pPlayer); +BYTE __stdcall GetMaxSockets(UnitAny* pItem); +DWORD __stdcall GetItemPrice(UnitAny* pPlayer, UnitAny* pItem, DWORD nDifficulty, DWORD pQuestInfo, DWORD nVendorId, + DWORD nTransactionType); +Room1* __stdcall GetRoomFromUnit(UnitAny* ptUnit); +int __stdcall GetLevelIdFromRoom(Room1* pRoom); + +// ---- Text drawing, server units, names, party and network -------------------------------- + +// A string drawn with D2WIN_DrawText (Texthook::Draw, hooks): text, position, colour, and the +// D2WIN_SetTextSize font in effect. +struct DrawnText { + std::wstring text; + int x; + int y; + DWORD color; + DWORD font; +}; +const std::vector& Drawn(); + +// D2WIN_GetTextWidthFileNo measures every character as kCharWidth pixels. +const DWORD kCharWidth = 8; + +void AddServerUnit(UnitAny* unit); // D2CLIENT_FindServerSideUnit(id, type) +void SetLevelName(DWORD levelId, const std::wstring& name); // D2CLIENT_GetLevelName (default "") +void SetItemName(UnitAny* item, const std::wstring& name); // D2CLIENT_GetItemName (default "") +void SetQuestInfo(void* quests); // D2CLIENT_GetQuestInfo (default null) + +// Packets sent with D2NET_SendPacket, in order. +const std::vector>& SentPackets(); +// D2CLIENT_ClickParty calls (roster entry, mode), in order, and the D2CLIENT_LeaveParty count. +const std::vector>& PartyClicks(); +int PartyLeaves(); + +void __fastcall WinDrawText(const wchar_t* wStr, int xPos, int yPos, DWORD dwColor, DWORD dwUnk); +DWORD __fastcall SetTextSize(DWORD dwSize); +DWORD __fastcall GetTextWidthFileNo(wchar_t* wStr, DWORD* dwWidth, DWORD* dwFileNo); +UnitAny* __fastcall FindServerSideUnit(DWORD dwId, DWORD dwType); +BOOL __stdcall ClientGetItemName(UnitAny* pItem, wchar_t* wBuffer, DWORD dwSize); +void __fastcall LeaveParty(void); +void __stdcall SendPacket(size_t aLen, DWORD arg1, BYTE* aPacket); + +} // namespace fake diff --git a/tests/fakes/ResetEachTest.h b/tests/fakes/ResetEachTest.h new file mode 100644 index 00000000..66618065 --- /dev/null +++ b/tests/fakes/ResetEachTest.h @@ -0,0 +1,22 @@ +// A doctest listener that calls `reset` before every test case (and before each subcase re-run), +// so no test sees state left behind by another. Register with +// REGISTER_LISTENER("name", 1, ResetEachTest<&MyReset>); +#pragma once +#include "doctest/doctest.h" + +template +struct ResetEachTest : doctest::IReporter { + ResetEachTest(const doctest::ContextOptions&) {} + void report_query(const doctest::QueryData&) override {} + void test_run_start() override {} + void test_run_end(const doctest::TestRunStats&) override {} + void test_case_start(const doctest::TestCaseData&) override { reset(); } + void test_case_reenter(const doctest::TestCaseData&) override { reset(); } + void test_case_end(const doctest::CurrentTestCaseStats&) override {} + void test_case_exception(const doctest::TestCaseException&) override {} + void subcase_start(const doctest::SubcaseSignature&) override {} + void subcase_end() override {} + void log_assert(const doctest::AssertData&) override {} + void log_message(const doctest::MessageData&) override {} + void test_case_skipped(const doctest::TestCaseData&) override {} +}; diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 00000000..7262a7d1 --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,3 @@ +// Test runner entry point. Run BH.Tests.exe --help for doctest's options (filters, reporters). +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest/doctest.h" diff --git a/tests/support/LootFilter.cpp b/tests/support/LootFilter.cpp new file mode 100644 index 00000000..499a9fb2 --- /dev/null +++ b/tests/support/LootFilter.cpp @@ -0,0 +1,104 @@ +#include "LootFilter.h" + +#include + +#include +#include +#include +#include + +#include "BH.h" +#include "Config.h" +#include "doctest/doctest.h" + +extern BYTE LastConditionType; // ItemDisplay.cpp + +namespace support { +namespace { + +DWORD nextTxtFileNo = 1; + +std::string TempDir() { + char path[MAX_PATH] = {}; + GetTempPathA(MAX_PATH, path); + return std::string(path) + "bh-tests-" + std::to_string(GetCurrentProcessId()) + "\\"; +} + +} // namespace + +TestItem::TestItem(const char* code, DWORD quality) + : item_(fake::NewItem(nextTxtFileNo, quality)), txt_(fake::AddItemTxt(nextTxtFileNo, code)), attrs_(), info_() { + nextTxtFileNo++; + attrs_.name = std::wstring(code, code + std::strlen(code)); + info_.item = &item_.unit; + strncpy_s(info_.itemCode, code, _TRUNCATE); + info_.attrs = &attrs_; + attrs_.staffmodClass = 255; // no staff mods (Item.cpp's value when ItemTypes.txt has none) +} + +TestItem& TestItem::Stat(int stat, int value, int param) { + fake::SetStat(&item_.unit, stat, value, param); + return *this; +} + +TestItem& TestItem::Flags(DWORD flags) { + item_.data.dwFlags = flags; + return *this; +} + +TestItem& TestItem::ItemLevel(DWORD ilvl) { + item_.data.dwItemLevel = ilvl; + return *this; +} + +Rule* ParseRule(const std::wstring& conditions, const std::wstring& action) { + std::wstring buf; + std::wstringstream ss(conditions); + std::vector tokens; + while (ss >> buf) { + tokens.push_back(buf); + } + LastConditionType = CT_None; + std::vector raw; + for (auto& token : tokens) { + Condition::BuildConditions(raw, token); + } + std::wstring text = action; + return new Rule(raw, &text); +} + +bool Matches(const std::wstring& conditions, TestItem& item) { + // Not freed: Rule's destructor needs ReplacementValue, which only ItemDisplay.cpp defines. + return ParseRule(conditions)->Evaluate(item.info()); +} + +void LoadFilter(const std::string& filterText) { + static Config filter("test.filter"); + BH::path = TempDir(); + CreateDirectoryA(BH::path.c_str(), nullptr); + const std::string file = BH::path + "test.filter"; + { + std::ofstream out(file, std::ios::binary | std::ios::trunc); + out << filterText; + } + const bool parsed = filter.Parse(); + // Config keeps the parsed lines in memory, so the file is not needed past this point. + DeleteFileA(file.c_str()); + RemoveDirectoryA(BH::path.c_str()); + REQUIRE_MESSAGE(parsed, "could not read the filter file back"); + BH::lootFilter = &filter; + ItemDisplay::UninitializeItemRules(); + ItemDisplay::InitializeItemRules(); +} + +std::wstring NameOf(TestItem& item, const std::wstring& baseName) { + std::wstring name = baseName; + GetItemName(item.info(), name); + return name; +} + +std::wstring DescriptionOf(TestItem& item) { + return item_desc_cache.Get(item.info()); +} + +} // namespace support diff --git a/tests/support/LootFilter.h b/tests/support/LootFilter.h new file mode 100644 index 00000000..0e5b75e6 --- /dev/null +++ b/tests/support/LootFilter.h @@ -0,0 +1,57 @@ +// Helpers for loot filter tests: build an item the rule engine can look at, load a filter, and +// read back what BH would show for the item. +#pragma once +#include + +#include "FakeEngine.h" +#include "Modules/Item/Item.h" +#include "Modules/Item/ItemDisplay.h" + +namespace support { + +// An item as BH's loot filter sees it: the unit, its Weapons/Armor/Misc.txt record, the +// ItemTypes-derived attributes Item.cpp would have built, and the UnitItemInfo tying them together. +// Defaults: identified, item level 1, no stats, ItemAttributes zeroed except name = code and +// staffmodClass = 255 (no staff mods). +class TestItem { +public: + TestItem(const char* code, DWORD quality); + TestItem(const TestItem&) = delete; + TestItem& operator=(const TestItem&) = delete; + + UnitAny* unit() { return &item_.unit; } + ItemData& data() { return item_.data; } + ItemsTxt& txt() { return txt_; } + ItemAttributes& attrs() { return attrs_; } + UnitItemInfo* info() { return &info_; } + + TestItem& Stat(int stat, int value, int param = 0); + TestItem& Flags(DWORD flags); // replaces ItemData::dwFlags + TestItem& ItemLevel(DWORD ilvl); + +private: + fake::ItemUnit& item_; + ItemsTxt& txt_; + ItemAttributes attrs_; + UnitItemInfo info_; +}; + +// Parses one rule the way InitializeItemRules does for an `ItemDisplay[conditions]: action` line. +// The rule is not added to RuleList, and is never freed (Rule's destructor needs +// ReplacementValue, which only ItemDisplay.cpp defines; a test process can afford the leak). +Rule* ParseRule(const std::wstring& conditions, const std::wstring& action = L"%NAME%"); + +// True when `conditions` match the item. +bool Matches(const std::wstring& conditions, TestItem& item); + +// Writes `filterText` as the loot filter file, parses it with BH's Config and runs +// ItemDisplay::InitializeItemRules, exactly as BH does when the filter is (re)loaded. +void LoadFilter(const std::string& filterText); + +// The name BH shows for the item, starting from the game's name `baseName` (GetItemName). +std::wstring NameOf(TestItem& item, const std::wstring& baseName); + +// The description (the text added above the item's stats). +std::wstring DescriptionOf(TestItem& item); + +} // namespace support From 62745a0ee0636e85842537370e60b448681f1cee Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:06 -0400 Subject: [PATCH 3/8] Test Common, Config, JSONObject and Mustache --- tests/BH.Tests.vcxproj | 4 + tests/CommonTests.cpp | 382 ++++++++++++++++++++ tests/ConfigTests.cpp | 739 ++++++++++++++++++++++++++++++++++++++ tests/JSONObjectTests.cpp | 434 ++++++++++++++++++++++ tests/MustacheTests.cpp | 574 +++++++++++++++++++++++++++++ 5 files changed, 2133 insertions(+) create mode 100644 tests/CommonTests.cpp create mode 100644 tests/ConfigTests.cpp create mode 100644 tests/JSONObjectTests.cpp create mode 100644 tests/MustacheTests.cpp diff --git a/tests/BH.Tests.vcxproj b/tests/BH.Tests.vcxproj index 5e60325b..fd611555 100644 --- a/tests/BH.Tests.vcxproj +++ b/tests/BH.Tests.vcxproj @@ -115,6 +115,10 @@ + + + + diff --git a/tests/CommonTests.cpp b/tests/CommonTests.cpp new file mode 100644 index 00000000..c3ded843 --- /dev/null +++ b/tests/CommonTests.cpp @@ -0,0 +1,382 @@ +#include "doctest/doctest.h" + +#include + +#include +#include +#include + +#include "Common.h" +#include "FakeEngine.h" + +namespace { + +std::vector Tokens(const std::string& str, const std::string& delimiters) { + std::vector out; + Tokenize(str, out, delimiters); + return out; +} + +std::vector Strs(const char* a = nullptr, const char* b = nullptr, const char* c = nullptr) { + std::vector out; + if (a) out.push_back(a); + if (b) out.push_back(b); + if (c) out.push_back(c); + return out; +} + +// A temp file with the given bytes, deleted when the helper goes out of scope. +struct TempFile { + std::string path; + explicit TempFile(const std::string& contents) { + char dir[MAX_PATH]; + char name[MAX_PATH]; + GetTempPathA(MAX_PATH, dir); + GetTempFileNameA(dir, "bht", 0, name); + path = name; + HANDLE h = CreateFileA(name, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + DWORD written = 0; + if (!contents.empty()) ::WriteFile(h, contents.data(), (DWORD)contents.size(), &written, NULL); + CloseHandle(h); + } + ~TempFile() { DeleteFileA(path.c_str()); } + char* c_path() { return &path[0]; } +}; + +const std::wstring kColorPrefix = L"\xFF" L"c"; + +} // namespace + +TEST_SUITE("Common") { + +// ---- Tokenize ------------------------------------------------------------------------------ + +TEST_CASE("Tokenize splits on any delimiter character and drops empty tokens") { + CHECK(Tokens("a b c", " ") == Strs("a", "b", "c")); + CHECK(Tokens(" a b ", " ") == Strs("a", "b")); + CHECK(Tokens("key=value;x", "=;") == Strs("key", "value", "x")); + CHECK(Tokens("a,,b", ",") == Strs("a", "b")); +} + +TEST_CASE("Tokenize yields nothing for empty or delimiter-only input and appends to the vector") { + CHECK(Tokens("", " ").empty()); + CHECK(Tokens(" ", " ").empty()); + CHECK(Tokens("single", " ") == Strs("single")); + + std::vector out; + out.push_back("existing"); + Tokenize("x y", out, " "); + CHECK(out == Strs("existing", "x", "y")); +} + +// ---- Encoding conversions (CODE_PAGE is UTF-8) --------------------------------------------- + +TEST_CASE("AnsiToWide and WideToAnsi convert UTF-8 and round-trip") { + CHECK(AnsiToWide("") == L""); + CHECK(WideToAnsi(L"") == ""); + CHECK(AnsiToWide("Stone of Jordan") == L"Stone of Jordan"); + // "ÿc" in UTF-8 is C3 BF 63; the wide form is U+00FF 'c' (the game's colour prefix). + CHECK(AnsiToWide("\xC3\xBF" "c1") == L"\xFF" L"c1"); + CHECK(WideToAnsi(L"\xFF" L"c1") == "\xC3\xBF" "c1"); + CHECK(WideToAnsi(L"\xFF" L"c1").size() == 4u); + + // A non-BMP character (U+1F5E1 dagger) is a surrogate pair in UTF-16 and 4 bytes in UTF-8. + const std::string dagger = "\xF0\x9F\x97\xA1"; + std::wstring wide = AnsiToWide(dagger); + REQUIRE(wide.size() == 2u); + CHECK(wide[0] == (wchar_t)0xD83D); + CHECK(wide[1] == (wchar_t)0xDDE1); + CHECK(WideToAnsi(wide) == dagger); + + const std::string mixed = "Gr\xC3\xBC\xC3\x9F" "e \xE6\x97\xA5\xE6\x9C\xAC"; // "Grüße 日本" + CHECK(AnsiToWide(mixed).size() == 8u); + CHECK(WideToAnsi(AnsiToWide(mixed)) == mixed); +} + +TEST_CASE("AnsiToUnicode and UnicodeToAnsi return null-terminated UTF-8 conversions") { + wchar_t* w = AnsiToUnicode("\xC3\xBF" "c4Hello"); + CHECK(std::wstring(w) == L"\xFF" L"c4Hello"); + delete[] w; + + char* a = UnicodeToAnsi(L"\xFF" L"c4Hello"); + CHECK(std::string(a) == "\xC3\xBF" "c4Hello"); + delete[] a; + + wchar_t* emptyW = AnsiToUnicode(""); + CHECK(emptyW[0] == L'\0'); + delete[] emptyW; + + char* emptyA = UnicodeToAnsi(L""); + CHECK(emptyA[0] == '\0'); + delete[] emptyA; +} + +// ---- Colour codes -------------------------------------------------------------------------- + +TEST_CASE("GetColorCode appends the colour character to the game's colour prefix") { + fake::SetLocaleText(3994, kColorPrefix); + CHECK(GetColorCode(0) == L"\xFF" L"c0"); // white + CHECK(GetColorCode(1) == L"\xFF" L"c1"); // red + CHECK(GetColorCode(9) == L"\xFF" L"c9"); // yellow + // The game's colours past 9 continue in ASCII order: 10 ':' (dark green), 11 ';' (purple). + CHECK(GetColorCode(10) == L"\xFF" L"c:"); + CHECK(GetColorCode(11) == L"\xFF" L"c;"); +} + +TEST_CASE("MaybeStripColorPrefixW removes one leading colour code only") { + CHECK(MaybeStripColorPrefixW(L"\xFF" L"c4Shako") == L"Shako"); + CHECK(MaybeStripColorPrefixW(L"\xFF" L"c;X") == L"X"); + CHECK(MaybeStripColorPrefixW(L"\xFF" L"c1\xFF" L"c2Two") == L"\xFF" L"c2Two"); + CHECK(MaybeStripColorPrefixW(L"Shako") == L"Shako"); + CHECK(MaybeStripColorPrefixW(L"Sh\xFF" L"c4ako") == L"Sh\xFF" L"c4ako"); + CHECK(MaybeStripColorPrefixW(L"c4Shako") == L"c4Shako"); + CHECK(MaybeStripColorPrefixW(L"\xFF" L"x4Shako") == L"\xFF" L"x4Shako"); + CHECK(MaybeStripColorPrefixW(L"") == L""); +} + +TEST_CASE("MaybeStripColorPrefix removes one leading UTF-8 colour code only") { + CHECK(MaybeStripColorPrefix("\xC3\xBF" "c4Shako") == "Shako"); + CHECK(MaybeStripColorPrefix("\xC3\xBF" "c1\xC3\xBF" "c2Two") == "\xC3\xBF" "c2Two"); + CHECK(MaybeStripColorPrefix("Shako") == "Shako"); + CHECK(MaybeStripColorPrefix("Sh\xC3\xBF" "c4ako") == "Sh\xC3\xBF" "c4ako"); + CHECK(MaybeStripColorPrefix("\xC3\xBF" "x4Shako") == "\xC3\xBF" "x4Shako"); + CHECK(MaybeStripColorPrefix("") == ""); +} + +// ---- Trim ---------------------------------------------------------------------------------- + +TEST_CASE("Trim and TrimW remove surrounding spaces and tabs but keep inner whitespace") { + CHECK(Trim(" value ") == "value"); + CHECK(Trim("\tvalue\t\t") == "value"); + CHECK(Trim(" \tvalue\t ") == "value"); + CHECK(Trim("a b\tc") == "a b\tc"); + CHECK(Trim("") == ""); + CHECK(Trim(" ") == ""); + CHECK(Trim("\t\t") == ""); + + CHECK(TrimW(L" value ") == L"value"); + CHECK(TrimW(L"\tvalue\t") == L"value"); + CHECK(TrimW(L" \tvalue\t ") == L"value"); + CHECK(TrimW(L"a b\tc") == L"a b\tc"); + CHECK(TrimW(L"") == L""); + CHECK(TrimW(L" ") == L""); +} + +// The PD2 Item Filtering wiki documents this order: "Whitespace surrounding the Output of each rule +// gets removed prior to evaluation (spaces first, followed by tabs)". Filters rely on it to pad +// names with spaces, e.g. "ItemDisplay[RUNE>9]: %NAME% ". +TEST_CASE("Trim and TrimW strip spaces before tabs, so tab-wrapped space padding survives") { + CHECK(Trim("\t value \t") == " value "); + CHECK(Trim(" \t value \t ") == " value "); + CHECK(Trim("\t\t value \t") == " value "); + CHECK(TrimW(L"\t value \t") == L" value "); + CHECK(TrimW(L" \t value \t ") == L" value "); +} + +// ---- Booleans and numbers ------------------------------------------------------------------ + +TEST_CASE("IsTrue and StringToBool accept 1/y/yes/true case-insensitively and nothing else") { + CHECK(IsTrue("1")); + CHECK(IsTrue("y")); + CHECK(IsTrue("Y")); + CHECK(IsTrue("yes")); + CHECK(IsTrue("YeS")); + CHECK(IsTrue("true")); + CHECK(IsTrue("TRUE")); + + CHECK_FALSE(IsTrue("0")); + CHECK_FALSE(IsTrue("n")); + CHECK_FALSE(IsTrue("no")); + CHECK_FALSE(IsTrue("false")); + CHECK_FALSE(IsTrue("")); + CHECK_FALSE(IsTrue("2")); + CHECK_FALSE(IsTrue("yess")); + CHECK_FALSE(IsTrue(" true")); + CHECK_FALSE(IsTrue("on")); + + CHECK(StringToBool("True")); + CHECK(StringToBool("1")); + CHECK_FALSE(StringToBool("False")); + CHECK_FALSE(StringToBool("")); +} + +// ---- PrintText ----------------------------------------------------------------------------- + +TEST_CASE("PrintText formats its arguments and prints one line in the given colour") { + PrintText(4, "Found %s x%d", "Ber", 2); + REQUIRE(fake::Printed().size() == 1u); + CHECK(fake::Printed()[0].first == L"Found Ber x2"); + CHECK(fake::Printed()[0].second == 4); + + PrintText(0, "\xC3\xBF" "c1red"); + REQUIRE(fake::Printed().size() == 2u); + CHECK(fake::Printed()[1].first == L"\xFF" L"c1red"); + CHECK(fake::Printed()[1].second == 0); +} + +// Common.cpp documents a 151-character limit with a TODO to lift it, so only require that long +// text keeps its start intact; the exact cut-off is not pinned. +TEST_CASE("PrintText prints 151 characters intact and keeps the start of longer text") { + std::string exact(151, 'a'); + PrintText(1, "%s", exact.c_str()); + std::string longer(151, 'b'); + longer += "OVERFLOW"; + PrintText(1, "%s", longer.c_str()); + + REQUIRE(fake::Printed().size() == 2u); + CHECK(fake::Printed()[0].first == std::wstring(151, L'a')); + const std::wstring& printed = fake::Printed()[1].first; + const std::wstring wideLonger(longer.begin(), longer.end()); + CHECK(printed.size() >= 151u); + CHECK(printed.size() <= wideLonger.size()); + CHECK(wideLonger.compare(0, printed.size(), printed) == 0); +} + +// ---- Key codes ----------------------------------------------------------------------------- + +TEST_CASE("GetKeyCode(name) maps names to Windows virtual-key codes case-insensitively") { + CHECK(GetKeyCode("VK_A").value == (unsigned)'A'); + CHECK(GetKeyCode("vk_a").value == (unsigned)'A'); + CHECK(GetKeyCode("VK_Z").value == (unsigned)'Z'); + CHECK(GetKeyCode("VK_0").value == (unsigned)'0'); + CHECK(GetKeyCode("VK_9").value == (unsigned)'9'); + CHECK(GetKeyCode("VK_BACK").value == (unsigned)VK_BACK); + CHECK(GetKeyCode("VK_RETURN").value == (unsigned)VK_RETURN); + CHECK(GetKeyCode("VK_ALT").value == (unsigned)VK_MENU); + CHECK(GetKeyCode("VK_CAPSLOCK").value == (unsigned)VK_CAPITAL); + CHECK(GetKeyCode("VK_PAGEUP").value == (unsigned)VK_PRIOR); + CHECK(GetKeyCode("VK_PAGEDN").value == (unsigned)VK_NEXT); + CHECK(GetKeyCode("VK_SNAPSHOT").value == (unsigned)VK_SNAPSHOT); + CHECK(GetKeyCode("VK_NUMPAD0").value == (unsigned)VK_NUMPAD0); + CHECK(GetKeyCode("VK_NUMPAD9").value == (unsigned)VK_NUMPAD9); + CHECK(GetKeyCode("VK_NUMPADMULTIPLY").value == (unsigned)VK_MULTIPLY); + CHECK(GetKeyCode("VK_NUMPADADD").value == (unsigned)VK_ADD); + CHECK(GetKeyCode("VK_NUMPADSUBTRACT").value == (unsigned)VK_SUBTRACT); + CHECK(GetKeyCode("VK_NUMPADDECIMAL").value == (unsigned)VK_DECIMAL); + CHECK(GetKeyCode("VK_NUMPADDIVIDE").value == (unsigned)VK_DIVIDE); + CHECK(GetKeyCode("VK_F1").value == (unsigned)VK_F1); + CHECK(GetKeyCode("VK_F12").value == (unsigned)VK_F12); + CHECK(GetKeyCode("VK_F16").value == (unsigned)VK_F16); + CHECK(GetKeyCode("VK_NUMLOCK").value == (unsigned)VK_NUMLOCK); + CHECK(GetKeyCode("VK_SCROLL").value == (unsigned)VK_SCROLL); + CHECK(GetKeyCode("VK_LCTRL").value == (unsigned)VK_LCONTROL); + CHECK(GetKeyCode("VK_RMENU").value == (unsigned)VK_RMENU); + CHECK(GetKeyCode("VK_SEMICOLON").value == (unsigned)VK_OEM_1); + CHECK(GetKeyCode("VK_PLUS").value == (unsigned)VK_OEM_PLUS); + CHECK(GetKeyCode("VK_COMMA").value == (unsigned)VK_OEM_COMMA); + CHECK(GetKeyCode("VK_MINUS").value == (unsigned)VK_OEM_MINUS); + CHECK(GetKeyCode("VK_PERIOD").value == (unsigned)VK_OEM_PERIOD); + CHECK(GetKeyCode("VK_LEFTBRACKET").value == (unsigned)VK_OEM_4); + CHECK(GetKeyCode("VK_BACKSLASH").value == (unsigned)VK_OEM_5); + CHECK(GetKeyCode("VK_RIGHTBRACKET").value == (unsigned)VK_OEM_6); + CHECK(GetKeyCode("VK_QUOTE").value == (unsigned)VK_OEM_7); + + KeyCode f5 = GetKeyCode("Vk_F5"); + CHECK(f5.name == "VK_F5"); + CHECK(f5.literalName == "F5"); +} + +TEST_CASE("GetKeyCode(name) falls back to 'None' for unknown names") { + KeyCode unknown = GetKeyCode("VK_NOPE"); + CHECK(unknown.name == "None"); + CHECK(unknown.value == 0u); + CHECK(unknown.literalName == "Not Set"); + CHECK(GetKeyCode("").name == "None"); + CHECK(GetKeyCode("VK_A ").name == "None"); +} + +TEST_CASE("GetKeyCode(value) reports the key's name and display name") { + KeyCode esc = GetKeyCode((unsigned)VK_ESCAPE); + CHECK(esc.name == "VK_ESCAPE"); + CHECK(esc.literalName == "Esc"); + CHECK(GetKeyCode((unsigned)'Q').literalName == "Q"); + CHECK(GetKeyCode((unsigned)VK_NUMPAD5).literalName == "Numpad 5"); + CHECK(GetKeyCode((unsigned)VK_OEM_1).literalName == ";"); + CHECK(GetKeyCode((unsigned)VK_OEM_PLUS).literalName == "+"); + CHECK(GetKeyCode((unsigned)VK_OEM_MINUS).literalName == "-"); + CHECK(GetKeyCode((unsigned)VK_OEM_5).literalName == "\\"); + CHECK(GetKeyCode((unsigned)VK_OEM_7).literalName == "'"); + + CHECK(GetKeyCode(0u).name == "None"); + CHECK(GetKeyCode(0u).literalName == "Not Set"); + CHECK(GetKeyCode((unsigned)VK_LBUTTON).name == "None"); + CHECK(GetKeyCode(0x3Au).name == "None"); // gap between '9' and 'A' + CHECK(GetKeyCode(0xFFFFu).name == "None"); +} + +TEST_CASE("GetKeyCode name and value lookups round-trip") { + const char* names[] = {"VK_TAB", "VK_SPACE", "VK_LEFT", "VK_DELETE", "VK_M", "VK_F9", "VK_NUMPAD7", + "VK_LSHIFT", "VK_COMMA", "VK_QUOTE"}; + for (const char* name : names) { + CAPTURE(name); + CHECK(GetKeyCode(GetKeyCode(name).value).name == name); + } +} + +// BUG: the table maps VK_FORWARDSLASH to 0xBD (VK_OEM_MINUS, already used by VK_MINUS) and +// VK_TILDE to 0xBF, which is VK_OEM_2, the '/?' key. On a US keyboard '`~' is VK_OEM_3 (0xC0). +// So binding "VK_FORWARDSLASH" fires on '-', binding "VK_TILDE" fires on '/', the '/' key is +// displayed as "~", and the '~' key cannot be bound or displayed at all. +TEST_CASE("GetKeyCode maps '/' and '~' to VK_OEM_2 and VK_OEM_3" * doctest::should_fail()) { + CHECK(GetKeyCode("VK_FORWARDSLASH").value == (unsigned)VK_OEM_2); + CHECK(GetKeyCode("VK_TILDE").value == (unsigned)VK_OEM_3); + CHECK(GetKeyCode((unsigned)VK_OEM_2).literalName == "/"); + CHECK(GetKeyCode((unsigned)VK_OEM_3).literalName == "~"); + CHECK(GetKeyCode(GetKeyCode("VK_FORWARDSLASH").value).name == "VK_FORWARDSLASH"); +} + +// ---- string_format ------------------------------------------------------------------------- + +TEST_CASE("string_format behaves like sprintf without a length limit") { + CHECK(string_format("%d/%d", 3, 7) == "3/7"); + CHECK(string_format("%s-%s", "a", "b") == "a-b"); + CHECK(string_format("%05.1f", 2.26) == "002.3"); + CHECK(string_format("100%%") == "100%"); + CHECK(string_format("plain") == "plain"); + CHECK(string_format("%x", 255) == "ff"); + + // Output far longer than the initial 2x-format-length buffer. + std::string big(5000, 'q'); + std::string out = string_format("<%s>", big.c_str()); + CHECK(out.size() == 5002u); + CHECK(out == "<" + big + ">"); + + // Output exactly one char longer than the initial buffer (2 * 2 = 4 chars + null). + CHECK(string_format("%s", "abcd") == "abcd"); + CHECK(string_format("%s", "abc") == "abc"); +} + +// ---- Memory and files ---------------------------------------------------------------------- + +TEST_CASE("memcpy2 copies the bytes and returns the end of the destination") { + char src[] = "abcdef"; + char dst[8]; + memset(dst, 'x', sizeof(dst)); + void* end = memcpy2(dst, src, 3); + CHECK(end == (void*)(dst + 3)); + CHECK(memcmp(dst, "abcxxxxx", 8) == 0); + + // Chained writes append. + end = memcpy2(end, src + 3, 3); + CHECK(end == (void*)(dst + 6)); + CHECK(memcmp(dst, "abcdefxx", 8) == 0); + + CHECK(memcpy2(dst, src, 0) == (void*)dst); +} + +TEST_CASE("AllocReadFile returns the whole file contents") { + const std::string contents("D2\0data\xFF", 8); + TempFile file(contents); + BYTE* buf = AllocReadFile(file.c_path()); + REQUIRE(buf != nullptr); + CHECK(memcmp(buf, contents.data(), contents.size()) == 0); + delete[] buf; +} + +TEST_CASE("AllocReadFile returns null for a missing file") { + TempFile file("x"); + std::string missing = file.path + ".missing"; + CHECK(AllocReadFile(&missing[0]) == nullptr); +} + +} // TEST_SUITE diff --git a/tests/ConfigTests.cpp b/tests/ConfigTests.cpp new file mode 100644 index 00000000..a2a2c60b --- /dev/null +++ b/tests/ConfigTests.cpp @@ -0,0 +1,739 @@ +#include "doctest/doctest.h" + +#include +#include +#include +#include +#include +#include + +#include "BH.h" +#include "Config.h" + +// Config.cpp: set once LoadConfig had to create BH.json, never cleared by BH itself. Tests that +// load settings start from a fresh process state, so they clear it (see JsonFiles). +extern bool bCreateFile; + +namespace { + +typedef std::vector> KeyVals; + +std::string TestDir() { + char path[MAX_PATH] = {}; + GetTempPathA(MAX_PATH, path); + std::string dir = std::string(path) + "bh-config-tests-" + std::to_string(GetCurrentProcessId()) + "\\"; + CreateDirectoryA(dir.c_str(), nullptr); + return dir; +} + +void WriteText(const std::string& path, const std::string& text) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << text; +} + +std::string ReadText(const std::string& path) { + std::ifstream in(path, std::ios::binary); + std::stringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +bool Exists(const std::string& path) { + return GetFileAttributesA(path.c_str()) != INVALID_FILE_ATTRIBUTES; +} + +// Loot filter files under a temp BH::path; every file written is deleted afterwards. +struct FilterDir { + std::string dir; + std::vector written; + + FilterDir() : dir(TestDir()) { BH::path = dir; } + ~FilterDir() { + for (size_t i = 0; i < written.size(); i++) DeleteFileA((dir + written[i]).c_str()); + RemoveDirectoryA(dir.c_str()); + } + void Write(const std::string& name, const std::string& text) { + WriteText(dir + name, text); + written.push_back(name); + } +}; + +// "key=value" lines, so mismatches print readably. +std::string Dump(const KeyVals& kv) { + std::string out; + for (size_t i = 0; i < kv.size(); i++) out += "[" + kv[i].first + "]=[" + kv[i].second + "]\n"; + return out; +} + +std::string Rules(Config& config, const std::string& key = "ItemDisplay") { + KeyVals values; + config.ReadMapList(key, values); + return Dump(values); +} + +// Parses `text` as loot.filter and returns the ItemDisplay rules read back from it. +std::string ParseRules(const std::string& text, const std::string& key = "ItemDisplay") { + FilterDir files; + files.Write("loot.filter", text); + Config config("loot.filter"); + REQUIRE(config.Parse()); + return Rules(config, key); +} + +// BH.json / BH.json.bak in a temp dir, deleted afterwards. +struct JsonFiles { + std::string dir, main, bak; + + JsonFiles() : dir(TestDir()), main(dir + "BH.json"), bak(dir + "BH.json.bak") { + DeleteFileA(main.c_str()); + DeleteFileA(bak.c_str()); + App.jsonFile = main; + App.jsonBackup = bak; + bCreateFile = false; + } + ~JsonFiles() { + DeleteFileA(main.c_str()); + DeleteFileA(bak.c_str()); + RemoveDirectoryA(dir.c_str()); + bCreateFile = false; + } + json Main() { return json::parse(ReadText(main)); } + json Bak() { return json::parse(ReadText(bak)); } +}; + +json::json_pointer Ptr(const char* p) { + return json::json_pointer(p); +} + +} // namespace + +TEST_SUITE("Config") { + +// --------------------------------------------------------------------------------------------- +// Loot filter file reader: Config::Parse + ReadMapList +// --------------------------------------------------------------------------------------------- + +TEST_CASE("Parse reads key: value lines in file order and trims spaces and tabs around both") { + CHECK(ParseRules( + "ItemDisplay[hax]: Hand Axe \n" + " ItemDisplay[UNI]\t:\tUnique\t\n" + "ItemDisplay[SET]:Set\n") == + "[hax]=[Hand Axe]\n" + "[UNI]=[Unique]\n" + "[SET]=[Set]\n"); +} + +TEST_CASE("Parse keeps every colon after the first as part of the value") { + CHECK(ParseRules("ItemDisplay[r33]: Zod: 33\n") == "[r33]=[Zod: 33]\n"); +} + +TEST_CASE("Parse treats // as the start of a comment anywhere on the line") { + CHECK(ParseRules( + "// whole-line comment: ItemDisplay[hax]: Hidden\n" + "//ItemDisplay[axe]: Hidden too\n" + "ItemDisplay[UNI]: Unique // trailing note\n" + "ItemDisplay[SET]: Set//no space before the comment\n") == + "[UNI]=[Unique]\n" + "[SET]=[Set]\n"); +} + +TEST_CASE("Parse skips blank lines, whitespace-only lines and an empty file") { + CHECK(ParseRules( + "\n" + " \n" + "\t\n" + "ItemDisplay[hax]: A\n" + "\n" + "ItemDisplay[axe]: B\n") == + "[hax]=[A]\n" + "[axe]=[B]\n"); + CHECK(ParseRules("") == ""); +} + +TEST_CASE("Parse reads a last line that has no trailing newline") { + CHECK(ParseRules("ItemDisplay[hax]: A\nItemDisplay[axe]: B") == "[hax]=[A]\n[axe]=[B]\n"); +} + +TEST_CASE("Parse does not leave carriage returns of CRLF files in keys or values") { + CHECK(ParseRules("ItemDisplay[hax]: A\r\nItemDisplay[axe]: B\r\n") == "[hax]=[A]\n[axe]=[B]\n"); +} + +// PD2 wiki, Item Filtering: "Whitespace surrounding the Output of each rule gets removed prior to +// evaluation (spaces first, followed by tabs), so tabs are often the best non-space character to +// use". Filters rely on this to pad names with spaces, e.g. `ItemDisplay[RUNE>9]: %NAME% `. +TEST_CASE("Parse strips spaces first and then tabs, so tabs protect padding spaces in the output") { + CHECK(ParseRules("ItemDisplay[RUNE>9]:\t %NAME% \t\n") == "[RUNE>9]=[ %NAME% ]\n"); + CHECK(ParseRules("ItemDisplay[RUNE>9]: \t %NAME% \t \n") == "[RUNE>9]=[ %NAME% ]\n"); +} + +// BUG: a line without ':' is not a rule. PD2 wiki, Item Filtering: "If a line doesn't follow this +// format, it won't be considered a rule, which means it won't affect how items will be displayed". +// Parse computes the value from find(':') + 1, which is npos + 1 == 0, so the whole line becomes the +// value and "ItemDisplay[hax]" (colon missing) renames hand axes to the literal "ItemDisplay[hax]". +TEST_CASE("Parse does not turn a line without ':' into a rule" * doctest::should_fail()) { + CHECK(ParseRules("ItemDisplay[hax]\n") == ""); +} + +TEST_CASE("Parse keeps duplicate keys as separate entries in file order") { + CHECK(ParseRules( + "ItemDisplay[hax]: first\n" + "ItemDisplay[UNI]: middle\n" + "ItemDisplay[hax]: second\n") == + "[hax]=[first]\n" + "[UNI]=[middle]\n" + "[hax]=[second]\n"); +} + +TEST_CASE("ReadMapList only returns keys that start with exactly key[") { + const std::string filter = + "ItemDisplayFilterName[]: Strict\n" + "ItemDisplay[hax]: Axe\n" + "ItemDisplayFilter[x]: not a rule\n" + "ItemDisplay [axe]: space before bracket\n" + "Something ItemDisplay[2ax]: not at the start\n" + "ItemDisplay: no brackets\n" + "ItemDisplay[UNI]: Unique\n"; + CHECK(ParseRules(filter) == "[hax]=[Axe]\n[UNI]=[Unique]\n"); + CHECK(ParseRules(filter, "ItemDisplayFilterName") == "[]=[Strict]\n"); + CHECK(ParseRules(filter, "ItemDisplayFilter") == "[x]=[not a rule]\n"); +} + +TEST_CASE("ReadMapList returns the text inside ItemDisplay[...]") { + CHECK(ParseRules("ItemDisplay[]: empty\n") == "[]=[empty]\n"); + CHECK(ParseRules("ItemDisplay[UNI (hax OR axe)]: A\n") == "[UNI (hax OR axe)]=[A]\n"); +} + +TEST_CASE("ReadMapList appends to the caller's list and returns the combined list") { + FilterDir files; + files.Write("loot.filter", "ItemDisplay[hax]: A\n"); + Config config("loot.filter"); + REQUIRE(config.Parse()); + + KeyVals values; + values.push_back(std::make_pair(std::string("existing"), std::string("X"))); + KeyVals returned = config.ReadMapList("ItemDisplay", values); + CHECK(Dump(values) == "[existing]=[X]\n[hax]=[A]\n"); + CHECK(Dump(returned) == "[existing]=[X]\n[hax]=[A]\n"); +} + +TEST_CASE("Parse reloads replace the previous contents") { + FilterDir files; + files.Write("loot.filter", "ItemDisplay[hax]: A\nItemDisplay[axe]: B\n"); + Config config("loot.filter"); + REQUIRE(config.Parse()); + REQUIRE(Rules(config) == "[hax]=[A]\n[axe]=[B]\n"); + + files.Write("loot.filter", "ItemDisplay[2ax]: C\n"); + REQUIRE(config.Parse()); + CHECK(Rules(config) == "[2ax]=[C]\n"); + + files.Write("loot.filter", ""); + REQUIRE(config.Parse()); + CHECK(Rules(config) == ""); +} + +TEST_CASE("Parse returns false for a missing file or an empty name and keeps the loaded rules") { + FilterDir files; + Config missing("does-not-exist.filter"); + CHECK_FALSE(missing.Parse()); + CHECK(Rules(missing) == ""); + + Config unnamed(""); + CHECK_FALSE(unnamed.Parse()); + + files.Write("loot.filter", "ItemDisplay[hax]: A\n"); + Config config("loot.filter"); + REQUIRE(config.Parse()); + DeleteFileA((files.dir + "loot.filter").c_str()); + CHECK_FALSE(config.Parse()); + CHECK(Rules(config) == "[hax]=[A]\n"); +} + +TEST_CASE("Config files are resolved relative to BH::path") { + FilterDir files; + Config config("loot.filter"); + CHECK(config.GetConfigName() == files.dir + "loot.filter"); + + files.Write("loot.filter", "ItemDisplay[hax]: default\n"); + files.Write("other.filter", "ItemDisplay[axe]: other\n"); + config.SetConfigName("other.filter"); + CHECK(config.GetConfigName() == files.dir + "other.filter"); + REQUIRE(config.Parse()); + CHECK(Rules(config) == "[axe]=[other]\n"); +} + +// --------------------------------------------------------------------------------------------- +// BH.json accessors +// --------------------------------------------------------------------------------------------- + +TEST_CASE("GetInt reads the value and falls back to the default for a missing or malformed entry") { + Config config(""); + SettingsInt setting = { 0, 7, 0, 0 }; // no bounds + CHECK(config.GetInt(Ptr("/s"), "n", setting) == 7); // no section + + App.jsonConfig = json::parse(R"({"s": {"n": -25, "str": "12", "arr": [1], "obj": {"n": 3}}, "notobj": 5})"); + CHECK(config.GetInt(Ptr("/s"), "n", setting) == -25); + CHECK(config.GetInt(Ptr("/s"), "missing", setting) == 7); + CHECK(config.GetInt(Ptr("/s"), "str", setting) == 7); + CHECK(config.GetInt(Ptr("/s"), "arr", setting) == 7); + CHECK(config.GetInt(Ptr("/s"), "obj", setting) == 7); + CHECK(config.GetInt(Ptr("/notobj"), "n", setting) == 7); + CHECK(config.GetInt(Ptr("/s/obj"), "n", setting) == 3); +} + +TEST_CASE("GetInt clamps to the setting's bounds, keeping values exactly on them") { + Config config(""); + const SettingsInt& join = App.bnet.failToJoin; + App.jsonConfig = json::object(); + App.jsonConfig["bnet"]["lo"] = join.minValue - 1; + App.jsonConfig["bnet"]["min"] = join.minValue; + App.jsonConfig["bnet"]["max"] = join.maxValue; + App.jsonConfig["bnet"]["hi"] = join.maxValue + 1; + App.jsonConfig["bnet"]["neg"] = -1; + CHECK(config.GetInt(Ptr("/bnet"), "lo", join) == join.minValue); + CHECK(config.GetInt(Ptr("/bnet"), "min", join) == join.minValue); + CHECK(config.GetInt(Ptr("/bnet"), "max", join) == join.maxValue); + CHECK(config.GetInt(Ptr("/bnet"), "hi", join) == join.maxValue); + CHECK(config.GetInt(Ptr("/bnet"), "neg", join) == join.minValue); + + const SettingsInt& level = App.lootfilter.filterLevel; + App.jsonConfig["lootfilter"]["a"] = level.maxValue; + App.jsonConfig["lootfilter"]["b"] = level.maxValue + 1; + App.jsonConfig["lootfilter"]["c"] = 0; + CHECK(config.GetInt(Ptr("/lootfilter"), "a", level) == level.maxValue); + CHECK(config.GetInt(Ptr("/lootfilter"), "b", level) == level.maxValue); + CHECK(config.GetInt(Ptr("/lootfilter"), "c", level) == 0); +} + +TEST_CASE("GetBool reads JSON booleans only and otherwise keeps the default") { + Config config(""); + SettingsBool on = { true, true }; + SettingsBool off = { false, false }; + App.jsonConfig = json::parse(R"({"s": {"t": true, "f": false, "one": 1, "str": "true", "nul": null}})"); + CHECK(config.GetBool(Ptr("/s"), "t", off) == true); + CHECK(config.GetBool(Ptr("/s"), "f", on) == false); + CHECK(config.GetBool(Ptr("/s"), "one", off) == false); + CHECK(config.GetBool(Ptr("/s"), "str", off) == false); + CHECK(config.GetBool(Ptr("/s"), "nul", on) == true); + CHECK(config.GetBool(Ptr("/s"), "missing", on) == true); + CHECK(config.GetBool(Ptr("/other"), "t", off) == false); +} + +TEST_CASE("GetString reads strings and keeps the default for other types") { + Config config(""); + SettingsString setting = { "", "json" }; + App.jsonConfig = json::parse(R"({"s": {"str": "stash", "empty": "", "num": 5, "arr": ["x"]}})"); + CHECK(config.GetString(Ptr("/s"), "str", setting) == "stash"); + CHECK(config.GetString(Ptr("/s"), "empty", setting) == ""); + CHECK(config.GetString(Ptr("/s"), "num", setting) == "json"); + CHECK(config.GetString(Ptr("/s"), "arr", setting) == "json"); + CHECK(config.GetString(Ptr("/s"), "missing", setting) == "json"); +} + +TEST_CASE("GetToggle reads enabled and hotkey independently, each defaulting when absent") { + Config config(""); + SettingsToggle setting = { {}, { 0x41, true } }; // VK_A, enabled + App.jsonConfig = json::parse(R"({"party": { + "both": {"enabled": false, "hotkey": "VK_F5"}, + "enabledOnly": {"enabled": false}, + "hotkeyOnly": {"hotkey": "VK_NUMPAD0"}, + "none": {} + }})"); + + Toggle t = config.GetToggle(Ptr("/party"), "both", setting); + CHECK(t.isEnabled == false); + CHECK(t.hotkey == 0x74u); // VK_F5 + + t = config.GetToggle(Ptr("/party"), "enabledOnly", setting); + CHECK(t.isEnabled == false); + CHECK(t.hotkey == 0x41u); + + t = config.GetToggle(Ptr("/party"), "hotkeyOnly", setting); + CHECK(t.isEnabled == true); + CHECK(t.hotkey == 0x60u); // VK_NUMPAD0 + + t = config.GetToggle(Ptr("/party"), "none", setting); + CHECK(t.isEnabled == true); + CHECK(t.hotkey == 0x41u); + + t = config.GetToggle(Ptr("/party"), "missing", setting); + CHECK(t.isEnabled == true); + CHECK(t.hotkey == 0x41u); +} + +TEST_CASE("GetToggle resolves hotkey names case-insensitively and unknown names to no key") { + Config config(""); + SettingsToggle setting = { {}, { 0x41, false } }; + App.jsonConfig = json::parse(R"({"lootfilter": {"legacy_settings": { + "lower": {"hotkey": "vk_f12"}, + "none": {"hotkey": "None"}, + "bogus": {"hotkey": "VK_NOPE"} + }}})"); + CHECK(config.GetToggle(Ptr("/lootfilter/legacy_settings"), "lower", setting).hotkey == 0x7Bu); + CHECK(config.GetToggle(Ptr("/lootfilter/legacy_settings"), "none", setting).hotkey == 0u); + CHECK(config.GetToggle(Ptr("/lootfilter/legacy_settings"), "bogus", setting).hotkey == 0u); +} + +TEST_CASE("GetKey resolves a key name and keeps the default when the entry is missing") { + Config config(""); + SettingsKey setting = { 0, 0x74 }; + App.jsonConfig = json::parse(R"({"lootfilter": {"inc": "VK_ADD_NOT_A_KEY", "dec": "VK_PAGEDN", "prev": "vk_back"}})"); + CHECK(config.GetKey(Ptr("/lootfilter"), "dec", setting) == 0x22u); + CHECK(config.GetKey(Ptr("/lootfilter"), "prev", setting) == 0x08u); + CHECK(config.GetKey(Ptr("/lootfilter"), "inc", setting) == 0u); + CHECK(config.GetKey(Ptr("/lootfilter"), "missing", setting) == 0x74u); + CHECK(config.GetKey(Ptr("/stash_export"), "dec", setting) == 0x74u); +} + +TEST_CASE("GetArray and GetAssoc replace the defaults with the configured values") { + Config config(""); + App.jsonConfig = json::parse(R"({"screen_info": { + "automap_info": ["%GAMENAME%", "%PING%"], + "empty": [], + "additional_stats": {"b": "2", "a": "1"} + }})"); + + std::vector arr = config.GetArray(Ptr("/screen_info"), "automap_info", App.screen.automapInfo); + REQUIRE(arr.size() == 2); + CHECK(arr[0] == "%GAMENAME%"); + CHECK(arr[1] == "%PING%"); + CHECK(config.GetArray(Ptr("/screen_info"), "empty", App.screen.automapInfo).empty()); + CHECK(config.GetArray(Ptr("/screen_info"), "missing", App.screen.automapInfo) == App.screen.automapInfo.defValues); + + std::map assoc = config.GetAssoc(Ptr("/screen_info"), "additional_stats", App.bnet.whisperColor); + REQUIRE(assoc.size() == 2); + CHECK(assoc["a"] == "1"); + CHECK(assoc["b"] == "2"); + CHECK(config.GetAssoc(Ptr("/bnet"), "whisper_color", App.bnet.whisperColor) == App.bnet.whisperColor.defValues); +} + +// BUG (this test and the next three): GetInt/GetFloat/GetBool/GetString catch conversion errors and +// keep the default, but GetToggle, GetKey, GetArray and GetAssoc do not. A hand-edited BH.json with +// a wrong type in one of these entries makes LoadConfig throw nlohmann::type_error out of BH startup +// (or a config reload) instead of falling back to the default like every other setting. +TEST_CASE("GetToggle falls back to the default for wrongly typed entries" * doctest::should_fail()) { + Config config(""); + SettingsToggle setting = { {}, { 0x41, true } }; + App.jsonConfig = json::parse(R"({"party": {"auto_party": {"enabled": "no", "hotkey": 116}}})"); + Toggle t = { 0, false }; + REQUIRE_NOTHROW(t = config.GetToggle(Ptr("/party"), "auto_party", setting)); + CHECK(t.isEnabled == true); + CHECK(t.hotkey == 0x41u); +} + +TEST_CASE("GetKey falls back to the default for a non-string entry" * doctest::should_fail()) { + Config config(""); + SettingsKey setting = { 0, 0x74 }; + App.jsonConfig = json::parse(R"({"stash_export": {"export_gear": 116}})"); + unsigned int key = 0; + REQUIRE_NOTHROW(key = config.GetKey(Ptr("/stash_export"), "export_gear", setting)); + CHECK(key == 0x74u); +} + +TEST_CASE("GetArray falls back to the default for a non-array entry" * doctest::should_fail()) { + Config config(""); + App.jsonConfig = json::parse(R"({"screen_info": {"automap_info": "Name: %GAMENAME%"}})"); + std::vector arr; + REQUIRE_NOTHROW(arr = config.GetArray(Ptr("/screen_info"), "automap_info", App.screen.automapInfo)); + CHECK(arr == App.screen.automapInfo.defValues); +} + +TEST_CASE("GetAssoc falls back to the default for a non-object entry" * doctest::should_fail()) { + Config config(""); + App.jsonConfig = json::parse(R"({"bnet": {"whisper_color": {"*chat": 9}}})"); + std::map assoc; + REQUIRE_NOTHROW(assoc = config.GetAssoc(Ptr("/bnet"), "whisper_color", App.bnet.whisperColor)); + CHECK(assoc == App.bnet.whisperColor.defValues); +} + +// --------------------------------------------------------------------------------------------- +// LoadConfig / SaveConfig +// --------------------------------------------------------------------------------------------- + +TEST_CASE("LoadConfig puts each BH.json key into its App setting") { + JsonFiles files; + WriteText(files.main, R"({ + "bnet": {"fail_to_join": 6000, "game_list_refresh": 2500, "save_last_game": "baal-01", + "autofill_next_game": false, "show_hell_difficulty": false, + "whisper_color": {"*friend": "2"}}, + "general": {"stats_on_right": true}, + "lootfilter": {"filter_level": 5, "last_filter_level": 3, "filter_level_increase": "VK_F5", + "filter_level_decrease": "VK_F6", "filter_level_previous": "VK_F7", + "advanced_item_display": false, "show_iLvl": true, "detailed_notifications": 2, + "allow_unknown_items": {"enabled": true, "hotkey": "VK_U"}, + "always_show_stat_ranges": true, "drop_sounds": false, + "legacy_settings": {"show_ethereal": {"enabled": true, "hotkey": "VK_E"}, + "drop_notifications": {"enabled": false}}}, + "game": {"experience_meter": true, "always_show_items": true}, + "party": {"auto_party": {"enabled": false, "hotkey": "VK_P"}}, + "screen_info": {"automap_info": ["%LEVEL%"], "hide_game_password": true}, + "stash_export": {"export_gear": "VK_G", "mustache_default": "stash", + "export_on_menu": {"enabled": true}}, + "bh_ui": {"is_minimized": false, "opened_x": 300, "opened_y": 250, "size_x": 5000} + })"); + Config config(App.jsonFile); + config.LoadConfig(); + + CHECK(App.bnet.failToJoin.value == 6000); + CHECK(App.bnet.refreshTime.value == 2500); + CHECK(App.bnet.saveLastGame.value == "baal-01"); + CHECK(App.bnet.autofillNextGame.value == false); + CHECK(App.bnet.autofillLastGame.value == App.bnet.autofillLastGame.defValue); // absent + CHECK(App.bnet.showHellDiff.value == false); + CHECK(App.bnet.showNormalDiff.value == App.bnet.showNormalDiff.defValue); // absent + CHECK(App.bnet.whisperColor.values.size() == 1); + CHECK(App.bnet.whisperColor.values["*friend"] == "2"); + CHECK(App.general.statsOnRight.value == true); + + CHECK(App.lootfilter.filterLevel.uValue == 5u); + CHECK(App.lootfilter.lastFilterLevel.uValue == 3u); + CHECK(App.lootfilter.filterLevelIncrease.hotkey == 0x74u); + CHECK(App.lootfilter.filterLevelDecrease.hotkey == 0x75u); + CHECK(App.lootfilter.filterLevelPrevious.hotkey == 0x76u); + CHECK(App.lootfilter.enableFilter.value == false); + CHECK(App.lootfilter.showIlvl.value == true); + CHECK(App.lootfilter.detailedNotifications.value == 2); + CHECK(App.lootfilter.allowUnknownItems.toggle.isEnabled == true); + CHECK(App.lootfilter.allowUnknownItems.toggle.hotkey == 0x55u); + CHECK(App.lootfilter.alwaysShowStatRanges.value == true); + CHECK(App.lootfilter.dropSounds.value == false); + CHECK(App.legacy.showEthereal.toggle.isEnabled == true); + CHECK(App.legacy.showEthereal.toggle.hotkey == 0x45u); + CHECK(App.legacy.dropNotifications.toggle.isEnabled == false); + CHECK(App.legacy.closeNotifications.toggle.isEnabled == App.legacy.closeNotifications.defToggle.isEnabled); // absent + + CHECK(App.game.experienceMeter.value == true); + CHECK(App.game.alwaysShowItems.value == true); + CHECK(App.party.autoParty.toggle.isEnabled == false); + CHECK(App.party.autoParty.toggle.hotkey == 0x50u); + CHECK(App.party.autoCorpseLoot.toggle.isEnabled == App.party.autoCorpseLoot.defToggle.isEnabled); // absent + REQUIRE(App.screen.automapInfo.values.size() == 1); + CHECK(App.screen.automapInfo.values[0] == "%LEVEL%"); + CHECK(App.screen.hideGamePassword.value == true); + CHECK(App.stash.exportGear.hotkey == 0x47u); + CHECK(App.stash.mustacheDefault.value == "stash"); + CHECK(App.stash.exportOnMenu.toggle.isEnabled == true); + CHECK(App.stash.includeEquipment.toggle.isEnabled == App.stash.includeEquipment.defToggle.isEnabled); // absent + CHECK(App.stash.mustacheOptions.values == App.stash.mustacheOptions.defValues); + + CHECK(App.bhui.isMinimized.value == false); + CHECK(App.bhui.openedX.value == 300); + CHECK(App.bhui.openedY.value == 250); + CHECK(App.bhui.sizeX.value == App.bhui.sizeX.maxValue); // 5000 clamped to the maximum + CHECK(App.bhui.minimizedX.value == App.bhui.minimizedX.defValue); // absent +} + +TEST_CASE("LoadConfig hands every hotkey toggle in BH.json to hotkey handling") { + JsonFiles files; + // Each toggle with a hotkey in BH.json (the sections SaveConfig writes), given its own key. + struct { const char* path; const char* key; unsigned int code; } toggles[] = { + { "/lootfilter/allow_unknown_items", "VK_A", 0x41 }, + { "/lootfilter/legacy_settings/show_ethereal", "VK_B", 0x42 }, + { "/lootfilter/legacy_settings/show_sockets", "VK_C", 0x43 }, + { "/lootfilter/legacy_settings/show_rune_numbers", "VK_D", 0x44 }, + { "/lootfilter/legacy_settings/alt_item_style", "VK_E", 0x45 }, + { "/lootfilter/legacy_settings/color_mod", "VK_F", 0x46 }, + { "/lootfilter/legacy_settings/shorten_item_names", "VK_G", 0x47 }, + { "/lootfilter/legacy_settings/drop_notifications", "VK_H", 0x48 }, + { "/lootfilter/legacy_settings/close_notifications", "VK_I", 0x49 }, + { "/lootfilter/legacy_settings/verbose_notifications", "VK_J", 0x4A }, + { "/party/auto_party", "VK_K", 0x4B }, + { "/party/auto_corpse_loot", "VK_L", 0x4C }, + { "/stash_export/include_equipment", "VK_M", 0x4D }, + { "/stash_export/export_on_menu", "VK_N", 0x4E }, + }; + json config = json::object(); + for (size_t i = 0; i < sizeof(toggles) / sizeof(toggles[0]); i++) + config[Ptr(toggles[i].path) / "hotkey"] = toggles[i].key; + WriteText(files.main, config.dump()); + Config loader(App.jsonFile); + loader.LoadConfig(); + + for (size_t i = 0; i < sizeof(toggles) / sizeof(toggles[0]); i++) { + CAPTURE(toggles[i].path); + int registered = 0; + for (size_t t = 0; t < App.hotkeyToggles.size(); t++) + if (App.hotkeyToggles[t]->hotkey == toggles[i].code) registered++; + CHECK(registered == 1); + } +} + +TEST_CASE("LoadConfig creates BH.json and its backup with the default settings when missing") { + JsonFiles files; + Config config(App.jsonFile); + config.LoadConfig(); + + REQUIRE(Exists(files.main)); + REQUIRE(Exists(files.bak)); + json main = files.Main(); + CHECK(main.at(Ptr("/lootfilter/filter_level")) == App.lootfilter.filterLevel.defValue); + CHECK(main.at(Ptr("/bnet/fail_to_join")) == App.bnet.failToJoin.defValue); + CHECK(main.at(Ptr("/bnet/game_list_refresh")) == App.bnet.refreshTime.defValue); + CHECK(main.at(Ptr("/party/auto_party/enabled")) == App.party.autoParty.defToggle.isEnabled); + CHECK(main.at(Ptr("/stash_export/mustache_default")) == App.stash.mustacheDefault.defValue); + CHECK(main.at(Ptr("/bnet/whisper_color")).get>() == App.bnet.whisperColor.defValues); + CHECK(files.Bak() == main); + + CHECK(App.lootfilter.filterLevel.value == App.lootfilter.filterLevel.defValue); + CHECK(App.bnet.failToJoin.value == App.bnet.failToJoin.defValue); +} + +TEST_CASE("LoadConfig refreshes the backup from a valid BH.json") { + JsonFiles files; + WriteText(files.main, R"({"lootfilter": {"filter_level": 4}, "custom": {"kept": 1}})"); + WriteText(files.bak, R"({"lootfilter": {"filter_level": 9}})"); + Config config(App.jsonFile); + config.LoadConfig(); + + CHECK(App.lootfilter.filterLevel.uValue == 4u); + json bak = files.Bak(); + CHECK(bak.at(Ptr("/lootfilter/filter_level")) == 4); + CHECK(bak.at(Ptr("/custom/kept")) == 1); + // A normal load does not rewrite the user's file. + CHECK(files.Main() == json::parse(R"({"lootfilter": {"filter_level": 4}, "custom": {"kept": 1}})")); +} + +TEST_CASE("LoadConfig restores a corrupt or empty BH.json from the backup") { + const char* corrupt[] = { "{\"lootfilter\": {\"filter_level\": 4", "", "not json" }; + for (size_t i = 0; i < sizeof(corrupt) / sizeof(corrupt[0]); i++) { + CAPTURE(corrupt[i]); + App = BHApp(); + JsonFiles files; + WriteText(files.main, corrupt[i]); + WriteText(files.bak, R"({"lootfilter": {"filter_level": 9}, "bnet": {"fail_to_join": 7000}})"); + Config config(App.jsonFile); + config.LoadConfig(); + + CHECK(App.lootfilter.filterLevel.uValue == 9u); + CHECK(App.bnet.failToJoin.value == 7000); + json main = files.Main(); // repaired: valid JSON again + CHECK(main.at(Ptr("/lootfilter/filter_level")) == 9); + CHECK(main.at(Ptr("/bnet/fail_to_join")) == 7000); + } +} + +TEST_CASE("LoadConfig falls back to defaults and rewrites both files when both are corrupt") { + JsonFiles files; + WriteText(files.main, "{ broken"); + WriteText(files.bak, "also broken"); + App.lootfilter.filterLevel.uValue = 11; // stale in-memory value must not survive + Config config(App.jsonFile); + config.LoadConfig(); + + CHECK(App.lootfilter.filterLevel.value == App.lootfilter.filterLevel.defValue); + CHECK(App.bnet.failToJoin.value == App.bnet.failToJoin.defValue); + json main = files.Main(); + CHECK(main.at(Ptr("/lootfilter/filter_level")) == App.lootfilter.filterLevel.defValue); + CHECK(main.at(Ptr("/bnet/fail_to_join")) == App.bnet.failToJoin.defValue); + CHECK(files.Bak() == main); +} + +TEST_CASE("SaveConfig writes settings under their documented keys with hotkeys as key names") { + JsonFiles files; + App.lootfilter.filterLevel.uValue = 7; + App.lootfilter.filterLevelIncrease.hotkey = 0x74; // F5 + App.lootfilter.filterLevelDecrease.hotkey = 0; // unset + App.lootfilter.showIlvl.value = true; + App.bnet.failToJoin.value = 5500; + App.party.autoParty.toggle.isEnabled = false; + App.party.autoParty.toggle.hotkey = 0x7B; // F12 + App.legacy.colorMod.toggle.isEnabled = true; + App.legacy.colorMod.toggle.hotkey = 0x43; // C + App.bhui.openedX.value = 321; + App.stash.exportGear.hotkey = 0x60; // numpad 0 + Config config(App.jsonFile); + config.SaveConfig(); + + json main = files.Main(); + CHECK(main.at(Ptr("/lootfilter/filter_level")) == 7); + CHECK(main.at(Ptr("/lootfilter/filter_level_increase")) == "VK_F5"); + CHECK(main.at(Ptr("/lootfilter/filter_level_decrease")) == "None"); + CHECK(main.at(Ptr("/lootfilter/show_iLvl")) == true); + CHECK(main.at(Ptr("/bnet/fail_to_join")) == 5500); + CHECK(main.at(Ptr("/party/auto_party/enabled")) == false); + CHECK(main.at(Ptr("/party/auto_party/hotkey")) == "VK_F12"); + CHECK(main.at(Ptr("/lootfilter/legacy_settings/color_mod/enabled")) == true); + CHECK(main.at(Ptr("/lootfilter/legacy_settings/color_mod/hotkey")) == "VK_C"); + CHECK(main.at(Ptr("/bh_ui/opened_x")) == 321); + CHECK(main.at(Ptr("/stash_export/export_gear")) == "VK_NUMPAD0"); +} + +TEST_CASE("SaveConfig then LoadConfig restores every changed setting") { + JsonFiles files; + App.bnet.autofillLastGame.value = false; + App.bnet.saveLastGame.value = "cows-12"; + App.bnet.saveLastPass.value = "pw"; + App.bnet.saveLastDesc.value = "desc"; + App.bnet.failToJoin.value = 9000; + App.bnet.refreshTime.value = 3000; + App.bnet.whisperColor.values = { { "*chat", "3" } }; + App.bnet.showNightmareDiff.value = false; + App.general.statsOnRight.value = true; + App.lootfilter.filterLevel.uValue = 12; + App.lootfilter.lastFilterLevel.uValue = 6; + App.lootfilter.filterLevelPrevious.hotkey = 0x76; // F7 + App.lootfilter.enableFilter.value = false; + App.lootfilter.detailedNotifications.value = 0; + App.lootfilter.allowUnknownItems.toggle = { 0x55, true }; + App.lootfilter.dropSounds.value = false; + App.lootfilter.classSkillsList.values = { { "amazon", "1" } }; + App.legacy.verboseNotifications.toggle = { 0x56, true }; + App.game.alwaysShowItems.value = true; + App.party.autoCorpseLoot.toggle = { 0x4C, false }; + App.screen.automapInfo.values = { "%GAMETIME%" }; + App.screen.additionalStats.values = { { "Faster Cast Rate", "105" } }; + App.screen.hideGamePassword.value = true; + App.stash.includeEquipment.toggle = { 0x49, false }; + App.stash.mustacheDefault.value = "stash"; + App.stash.mustacheOptions.values = { "stash" }; + App.stash.mustacheFormat.values = { { "item", "{{name}}" } }; + App.bhui.isMinimized.value = false; + App.bhui.minimizedY.value = 400; + App.bhui.sizeY.value = 500; + Config saver(App.jsonFile); + saver.SaveConfig(); + + std::string main = files.main, bak = files.bak; + App = BHApp(); + App.jsonFile = main; + App.jsonBackup = bak; + Config loader(App.jsonFile); + loader.LoadConfig(); + + CHECK(App.bnet.autofillLastGame.value == false); + CHECK(App.bnet.saveLastGame.value == "cows-12"); + CHECK(App.bnet.saveLastPass.value == "pw"); + CHECK(App.bnet.saveLastDesc.value == "desc"); + CHECK(App.bnet.failToJoin.value == 9000); + CHECK(App.bnet.refreshTime.value == 3000); + CHECK(App.bnet.whisperColor.values == std::map{ { "*chat", "3" } }); + CHECK(App.bnet.showNightmareDiff.value == false); + CHECK(App.general.statsOnRight.value == true); + CHECK(App.lootfilter.filterLevel.uValue == 12u); + CHECK(App.lootfilter.lastFilterLevel.uValue == 6u); + CHECK(App.lootfilter.filterLevelPrevious.hotkey == 0x76u); + CHECK(App.lootfilter.enableFilter.value == false); + CHECK(App.lootfilter.detailedNotifications.value == 0); + CHECK(App.lootfilter.allowUnknownItems.toggle.hotkey == 0x55u); + CHECK(App.lootfilter.allowUnknownItems.toggle.isEnabled == true); + CHECK(App.lootfilter.dropSounds.value == false); + CHECK(App.lootfilter.classSkillsList.values == std::map{ { "amazon", "1" } }); + CHECK(App.legacy.verboseNotifications.toggle.hotkey == 0x56u); + CHECK(App.legacy.verboseNotifications.toggle.isEnabled == true); + CHECK(App.game.alwaysShowItems.value == true); + CHECK(App.party.autoCorpseLoot.toggle.hotkey == 0x4Cu); + CHECK(App.party.autoCorpseLoot.toggle.isEnabled == false); + CHECK(App.screen.automapInfo.values == std::vector{ "%GAMETIME%" }); + CHECK(App.screen.additionalStats.values == std::map{ { "Faster Cast Rate", "105" } }); + CHECK(App.screen.hideGamePassword.value == true); + CHECK(App.stash.includeEquipment.toggle.hotkey == 0x49u); + CHECK(App.stash.includeEquipment.toggle.isEnabled == false); + CHECK(App.stash.mustacheDefault.value == "stash"); + CHECK(App.stash.mustacheOptions.values == std::vector{ "stash" }); + CHECK(App.stash.mustacheFormat.values == std::map{ { "item", "{{name}}" } }); + CHECK(App.bhui.isMinimized.value == false); + CHECK(App.bhui.minimizedY.value == 400); + CHECK(App.bhui.sizeY.value == 500); +} + +} // TEST_SUITE diff --git a/tests/JSONObjectTests.cpp b/tests/JSONObjectTests.cpp new file mode 100644 index 00000000..9d76be03 --- /dev/null +++ b/tests/JSONObjectTests.cpp @@ -0,0 +1,434 @@ +#include "doctest/doctest.h" + +#include "JSONObject.h" +#include + +#include +#include + +// BH's JSON object model (BH/JSONObject.h) backs the stash export: the "json" export type is +// JSONObject/JSONArray serialized with SER_OPT_FORMATTED, and the Mustache export types read the +// same tree through JSONElement::find paths. + +namespace { + std::string Compact(JSONElement &e) { + std::string buf; + JSONWriter w(buf, SER_OPT_NONE); + e.serialize(w); + return buf; + } + + std::string Formatted(JSONElement &e) { + std::string buf; + JSONWriter w(buf, SER_OPT_FORMATTED); + e.serialize(w); + return buf; + } + + JSONObject *Obj(const std::string &key, const std::string &value) { + JSONObject *o = new JSONObject(); + o->set(key, value); + return o; + } +} + +TEST_SUITE("JSONObject") { + +TEST_CASE("Json_Escape escapes quotes, backslashes, solidus and the short control escapes") { + CHECK(Json_Escape("plain text 123") == "plain text 123"); + CHECK(Json_Escape("Tal Rasha's \"Horadric\" Crest") == "Tal Rasha's \\\"Horadric\\\" Crest"); + CHECK(Json_Escape("C:\\d2\\save") == "C:\\\\d2\\\\save"); + CHECK(Json_Escape("a/b") == "a\\/b"); + CHECK(Json_Escape("l1\nl2\r\tx\by\fz") == "l1\\nl2\\r\\tx\\by\\fz"); + CHECK(Json_Escape("") == ""); + // Non-ASCII bytes (D2's color code byte, UTF-8 text) are valid in JSON strings and pass through. + CHECK(Json_Escape("\xFF" "c4Shako") == "\xFF" "c4Shako"); +} + +TEST_CASE("Json_Escape output of C0 control characters is a valid JSON string" * doctest::should_fail()) { + // BUG: RFC 8259 section 7 requires every character U+0000..U+001F to be escaped inside a JSON + // string; Json_Escape only handles \b \f \n \r \t and copies e.g. 0x01 / 0x1F raw, producing + // invalid JSON. Checked by parsing with a conforming parser, so any valid escape spelling passes. + const std::string inputs[] = { std::string("a\x01" "b"), std::string("\x1F") }; + for (int i = 0; i < 2; i++) { + nlohmann::json parsed = nlohmann::json::parse("\"" + Json_Escape(inputs[i]) + "\"", nullptr, false); + REQUIRE_FALSE(parsed.is_discarded()); + CHECK(parsed.get() == inputs[i]); + } +} + +TEST_CASE("JSONObject stores typed values and the typed getters convert between them") { + JSONObject o; + o.set("name", std::string("Harlequin Crest")); + o.set("iLevel", 87); + o.set("speed", 1.5f); + o.set("isEthereal", true); + + CHECK(o.length() == 4); + CHECK(o.get("name")->getType() == JSON_STRING); + CHECK(o.get("iLevel")->getType() == JSON_NUMBER); + CHECK(o.get("speed")->getType() == JSON_NUMBER); + CHECK(o.get("isEthereal")->getType() == JSON_BOOL); + + CHECK(o.getString("name") == "Harlequin Crest"); + CHECK(o.getString("iLevel") == "87"); + CHECK(o.getString("isEthereal") == "true"); + CHECK(o.getNumber("iLevel") == 87.0f); + CHECK(o.getNumber("speed") == 1.5f); + CHECK(o.getNumber("isEthereal") == 1.0f); + CHECK(o.getBool("isEthereal")); + CHECK(o.get("iLevel")->toInt() == 87); +} + +TEST_CASE("JSONObject getters return neutral values for missing keys and wrong container types") { + JSONObject o; + o.set("name", std::string("Shako")); + o.set("child", Obj("k", "v")); + JSONArray *arr = new JSONArray(); + arr->add(1); + o.set("list", arr); + + CHECK(o.getString("missing") == ""); + CHECK(o.getNumber("missing") == 0.0f); + CHECK_FALSE(o.getBool("missing")); + CHECK(o.getObject("missing") == nullptr); + CHECK(o.getArray("missing") == nullptr); + + // get() of a missing key is the shared Null element, never a null pointer. + JSONElement *missing = o.get("missing"); + REQUIRE(missing != nullptr); + CHECK(missing->getType() == JSON_NULL); + CHECK_FALSE(missing->hasValue()); + + CHECK(o.getObject("list") == nullptr); + CHECK(o.getArray("child") == nullptr); + CHECK(o.getObject("name") == nullptr); + CHECK(o.getObject("child") != nullptr); + CHECK(o.getArray("list") == arr); +} + +TEST_CASE("JSONObject::set replaces an existing key and ignores empty keys and null children") { + JSONObject o; + o.set("a", 1); + o.set("a", std::string("replaced")); + CHECK(o.length() == 1); + CHECK(o.get("a")->getType() == JSON_STRING); + CHECK(o.getString("a") == "replaced"); + + o.set("", std::string("ignored")); + o.set("nullObj", (JSONObject*)nullptr); + o.set("nullArr", (JSONArray*)nullptr); + CHECK(o.length() == 1); + CHECK(o.get("nullObj")->getType() == JSON_NULL); +} + +TEST_CASE("JSONObject::get(\"this\") refers to the object itself") { + JSONObject o; + o.set("x", 1); + CHECK(o.get("this") == &o); + CHECK(o.find("this") == &o); + CHECK(o.find("") == &o); +} + +TEST_CASE("Truthiness: zero, false, empty strings and empty containers have no value") { + CHECK_FALSE(JSONNumber(0).hasValue()); + CHECK_FALSE(JSONNumber(0.0f).hasValue()); + CHECK(JSONNumber(-1).hasValue()); + CHECK(JSONNumber(0.25f).hasValue()); + CHECK_FALSE(JSONBool(false).hasValue()); + CHECK(JSONBool(true).hasValue()); + CHECK_FALSE(JSONString("").hasValue()); + CHECK(JSONString("false").hasValue()); + CHECK_FALSE(JSONObject().hasValue()); + CHECK_FALSE(JSONArray().hasValue()); + + JSONArray a; + a.add(0); + CHECK(a.hasValue()); + CHECK(static_cast(JSONString("x"))); + // (JSONElement::Null() is defined inline in JSONObject.cpp, so the Null element is reached via get()) + JSONObject empty; + CHECK_FALSE(static_cast(*empty.get("missing"))); +} + +TEST_CASE("JSONNumber conversions: ints and floats convert to each other with C semantics") { + JSONNumber i(42); + CHECK(i.toInt() == 42); + CHECK(i.toFloat() == 42.0f); + CHECK(i.getValue() == 42.0f); + CHECK(i.toString() == "42"); + CHECK(JSONNumber(-7).toString() == "-7"); + + JSONNumber f(2.75f); + CHECK(f.toInt() == 2); // truncation toward zero like a C cast + CHECK(JSONNumber(-2.75f).toInt() == -2); + CHECK(f.toFloat() == 2.75f); + CHECK(f.getValue() == 2.75f); + // float text must round-trip to the same value + CHECK(atof(f.toString().c_str()) == doctest::Approx(2.75)); + CHECK(atof(JSONNumber(-0.125f).toString().c_str()) == doctest::Approx(-0.125)); +} + +TEST_CASE("JSONString converts to numbers like atoi/atof and to bool only for \"true\"") { + CHECK(JSONString("42").toInt() == 42); + CHECK(JSONString("42abc").toInt() == 42); + CHECK(JSONString("abc").toInt() == 0); + CHECK(JSONString("-3").toInt() == -3); + CHECK(JSONString("12.5").toFloat() == 12.5f); + CHECK(JSONString("true").toBool()); + CHECK_FALSE(JSONString("false").toBool()); + CHECK_FALSE(JSONString("1").toBool()); + CHECK_FALSE(JSONString("TRUE").toBool()); + CHECK(JSONBool(true).toString() == "true"); + CHECK(JSONBool(false).toString() == "false"); + CHECK(JSONBool(true).toInt() == 1); + CHECK(JSONBool(false).toFloat() == 0.0f); +} + +TEST_CASE("JSONArray stores elements in insertion order with bounds-checked access") { + JSONArray a; + a.add(std::string("Tal")); + a.add(7); + a.add(true); + a.add(0.5f); + a.add(Obj("name", "Ral")); + JSONArray *inner = new JSONArray(); + inner->add(1); + a.add(inner); + a.add((JSONObject*)nullptr); + a.add((JSONArray*)nullptr); + + CHECK(a.length() == 6); + CHECK(a.getString(0) == "Tal"); + CHECK(a.getNumber(1) == 7.0f); + CHECK(a.getBool(2)); + CHECK(a.getNumber(3) == 0.5f); + REQUIRE(a.getObject(4) != nullptr); + CHECK(a.getObject(4)->getString("name") == "Ral"); + CHECK(a.getArray(5) == inner); + CHECK(a.getObject(5) == nullptr); + CHECK(a.getArray(4) == nullptr); + + // index == length is the first out-of-range index + CHECK(a.get(6)->getType() == JSON_NULL); + CHECK(a.getString(6) == ""); + CHECK(a.getNumber(6) == 0.0f); + CHECK_FALSE(a.getBool(6)); + CHECK(a.getObject(6) == nullptr); + CHECK(a.getArray(6) == nullptr); +} + +TEST_CASE("JSONArray::removeWhere and remove drop exactly the selected elements") { + JSONArray a; + for (int i = 1; i <= 6; i++) { + a.add(i); + } + a.removeWhere([](JSONElement *e) { return e->toInt() % 2 == 0; }); + REQUIRE(a.length() == 3); + CHECK(a.getNumber(0) == 1.0f); + CHECK(a.getNumber(1) == 3.0f); + CHECK(a.getNumber(2) == 5.0f); + + a.remove(a.begin()); + REQUIRE(a.length() == 2); + CHECK(a.getNumber(0) == 3.0f); + + a.removeWhere([](JSONElement *) { return true; }); + CHECK(a.length() == 0); +} + +TEST_CASE("equals compares by value: numbers numerically, objects ignoring key order, arrays in order") { + JSONNumber twoF(2.0f); + CHECK(JSONNumber(2).equals(&twoF)); + JSONNumber two(2); + JSONString twoStr("2"); + JSONBool yes(true); + CHECK_FALSE(two.equals(&twoStr)); + CHECK_FALSE(twoStr.equals(&two)); + CHECK_FALSE(yes.equals(&two)); + CHECK_FALSE(two.equals(nullptr)); + JSONObject empty; + CHECK(empty.get("missing")->equals(nullptr)); + + JSONObject a, b, c; + a.set("x", 1); + a.set("y", std::string("s")); + b.set("y", std::string("s")); + b.set("x", 1); + c.set("x", 1); + c.set("y", std::string("t")); + CHECK(a.equals(&b)); + CHECK(b.equals(&a)); + CHECK_FALSE(a.equals(&c)); + + JSONObject d; + d.set("x", 1); + CHECK_FALSE(a.equals(&d)); + CHECK_FALSE(d.equals(&a)); + + JSONArray l1, l2, l3; + l1.add(1); l1.add(2); + l2.add(1); l2.add(2); + l3.add(2); l3.add(1); + CHECK(l1.equals(&l2)); + CHECK_FALSE(l1.equals(&l3)); + l2.add(3); + CHECK_FALSE(l1.equals(&l2)); + CHECK_FALSE(l1.equals(&a)); +} + +TEST_CASE("JSONArray::contains returns the stored element deep-equal to the target") { + JSONArray a; + a.add(Obj("code", "r01")); + a.add(Obj("code", "r02")); + + JSONObject *target = Obj("code", "r02"); + JSONElement *found = a.contains(target); + CHECK(found == a.get(1)); + CHECK(found != target); + delete target; + + JSONObject *absent = Obj("code", "r33"); + CHECK(a.contains(absent) == nullptr); + delete absent; + CHECK(a.contains(nullptr) == nullptr); +} + +TEST_CASE("find resolves dotted and bracketed paths through objects and arrays") { + JSONObject root; + JSONObject *item = new JSONObject(); + item->set("name", std::string("Spirit")); + JSONArray *stats = new JSONArray(); + stats->add(Obj("name", "to All Skills")); + stats->add(Obj("name", "Faster Cast Rate")); + item->set("stats", stats); + root.set("item", item); + root.set("my key", std::string("spaced")); + root.set("a.b", std::string("dotted key")); + + CHECK(root.find("item.name")->toString() == "Spirit"); + CHECK(root.find("item.stats.1.name")->toString() == "Faster Cast Rate"); + CHECK(root.find("item.stats[0].name")->toString() == "to All Skills"); + CHECK(root.find("item.stats[1]")->getType() == JSON_OBJECT); + CHECK(root.find("item[\"name\"]")->toString() == "Spirit"); + CHECK(root.find("item['name']")->toString() == "Spirit"); + CHECK(root.find("[\"my key\"]")->toString() == "spaced"); + CHECK(root.find("[\"a.b\"]")->toString() == "dotted key"); + CHECK(stats->find("0.name")->toString() == "to All Skills"); + CHECK(stats->find("[1].name")->toString() == "Faster Cast Rate"); + CHECK(stats->find("1")->getType() == JSON_OBJECT); + CHECK(stats->find("this") == stats); + CHECK(root.find("item.this") == item); +} + +TEST_CASE("find of a missing path segment yields the Null element instead of crashing") { + JSONObject root; + JSONArray *stats = new JSONArray(); + stats->add(Obj("name", "x")); + root.set("stats", stats); + root.set("name", std::string("Shako")); + + CHECK(root.find("missing")->getType() == JSON_NULL); + CHECK(root.find("missing.deeper.still")->getType() == JSON_NULL); + CHECK(root.find("stats.1.name")->getType() == JSON_NULL); // index == length + CHECK(root.find("stats[5]")->getType() == JSON_NULL); + CHECK(root.find("stats.name")->getType() == JSON_NULL); // arrays have no named members + CHECK(root.find("name.length")->getType() == JSON_NULL); // scalars have no members + CHECK(stats->find("-1")->getType() == JSON_NULL); +} + +TEST_CASE("Compact serialization writes sorted keys and skips value-less members") { + JSONObject o; + o.set("type", std::string("Shako")); + o.set("iLevel", 87); + o.set("isEthereal", true); + o.set("sockets", 0); + o.set("unidentified", false); + o.set("name", std::string("")); + o.set("socketed", new JSONArray()); + CHECK(Compact(o) == "{\"iLevel\": 87,\"isEthereal\": true,\"type\": \"Shako\"}"); + + JSONObject quoted; + quoted.set("name", std::string("say \"hi\"\n")); + CHECK(Compact(quoted) == "{\"name\": \"say \\\"hi\\\"\\n\"}"); + + JSONArray a; + a.add(1); + a.add(0); + a.add(std::string("x")); + a.add(Obj("k", "v")); + CHECK(Compact(a) == "[1,\"x\",{\"k\": \"v\"}]"); + + JSONObject neg; + neg.set("v", -5); + CHECK(Compact(neg) == "{\"v\": -5}"); +} + +TEST_CASE("Serializing an element without a value writes nothing and reports false") { + std::string buf; + JSONWriter w(buf, SER_OPT_NONE); + JSONObject empty; + JSONArray emptyArr; + JSONString emptyStr(""); + JSONNumber zero(0); + JSONBool no(false); + CHECK_FALSE(empty.serialize(w)); + CHECK_FALSE(emptyArr.serialize(w)); + CHECK_FALSE(emptyStr.serialize(w)); + CHECK_FALSE(zero.serialize(w)); + CHECK_FALSE(no.serialize(w)); + CHECK(buf == ""); + + // An object whose members all lack values still has keys, so it is written as {}. + JSONObject onlyFalsy; + onlyFalsy.set("a", 0); + CHECK(Compact(onlyFalsy) == "{}"); +} + +TEST_CASE("Formatted serialization indents nested objects by two spaces per level") { + JSONObject o; + o.set("a", 1); + JSONObject *c = new JSONObject(); + c->set("d", std::string("x")); + o.set("c", c); + o.set("b", true); + CHECK(Formatted(o) == + "{\n" + " \"a\": 1,\n" + " \"b\": true,\n" + " \"c\": {\n" + " \"d\": \"x\"\n" + " }\n" + "}"); +} + +TEST_CASE("Formatted serialization of the stash export shape puts each object on its own indented block") { + // StashExport writes an array of item objects, each with a nested array of stat objects. + JSONArray data; + JSONObject *item = new JSONObject(); + item->set("type", std::string("Shako")); + JSONArray *stats = new JSONArray(); + JSONObject *stat = new JSONObject(); + stat->set("value", 2); + stats->add(stat); + item->set("stats", stats); + data.add(item); + data.add(Obj("type", "Jewel")); + + CHECK(Formatted(data) == + "[\n" + " {\n" + " \"stats\": [\n" + " {\n" + " \"value\": 2\n" + " }\n" + " ],\n" + " \"type\": \"Shako\"\n" + " },\n" + " {\n" + " \"type\": \"Jewel\"\n" + " }\n" + "]"); +} + +} diff --git a/tests/MustacheTests.cpp b/tests/MustacheTests.cpp new file mode 100644 index 00000000..007a2a80 --- /dev/null +++ b/tests/MustacheTests.cpp @@ -0,0 +1,574 @@ +#include "doctest/doctest.h" + +#include "BH.h" +#include "JSONObject.h" +#include "Mustache.h" + +#include +#include +#include +#include + +// BH's Mustache implementation (BH/Mustache.cpp) renders the stash export "text" formats. +// Spec: https://mustache.github.io/mustache.5.html. BH extensions exercised here: +// - section comparisons {{#key=V}} {{#key!V}} {{#key>N}} {{#key>name}} (render with only the current element, no parent lookup) +// - {{this}} for the current element; literal "\n", "\t", "\r" escapes in template text +// - output is Markdown text, so values are inserted verbatim (no HTML escaping) + +namespace { + typedef std::function Factory; + + class Partials { + public: + std::map> templates; + + void Add(const std::string &name, const std::string &source) { + Mustache::AMustacheTemplate *t = Mustache::parse(source); + REQUIRE(t != nullptr); + templates[name].reset(t); + } + + Factory MakeFactory() { + return [this](std::string name) -> Mustache::AMustacheTemplate* { + auto it = templates.find(name); + return it == templates.end() ? nullptr : it->second.get(); + }; + } + }; + + std::string Render(const std::string &templ, JSONElement *data, Partials *partials = nullptr) { + Mustache::Context ctx(data, partials ? partials->MakeFactory() : Factory(nullptr)); + std::unique_ptr t(Mustache::parse(templ)); + REQUIRE(t != nullptr); + return Mustache::renderTemplate(t.get(), ctx); + } + + JSONObject *Obj(const std::string &key, const std::string &value) { + JSONObject *o = new JSONObject(); + o->set(key, value); + return o; + } + + JSONObject *StatEntry(int value, const std::string &name, const std::string &skill = "") { + JSONObject *s = new JSONObject(); + s->set("value", value); + s->set("name", name); + if (!skill.empty()) { + s->set("skill", skill); + } + return s; + } + + JSONObject *RangedStat(int value, const std::string &name, int min, int max) { + JSONObject *s = StatEntry(value, name); + JSONObject *range = new JSONObject(); + range->set("min", min); + range->set("max", max); + s->set("range", range); + return s; + } +} + +TEST_SUITE("Mustache") { + +TEST_CASE("Variables render strings, numbers and booleans; missing keys and objects render empty") { + JSONObject o; + o.set("name", std::string("Shako")); + o.set("iLevel", 87); + o.set("isEthereal", true); + o.set("range", Obj("min", "1")); + CHECK(Render("{{name}} (L{{iLevel}}) eth={{isEthereal}} [{{missing}}]{{range}}", &o) == + "Shako (L87) eth=true []"); + CHECK(Render("{{iLevel}}{{iLevel}}", &o) == "8787"); +} + +TEST_CASE("Variable values are inserted verbatim because the export is Markdown text, not HTML") { + JSONObject o; + o.set("name", std::string("Tal Rasha's & \"Crest\"")); + CHECK(Render("{{name}}", &o) == "Tal Rasha's & \"Crest\""); +} + +TEST_CASE("Text without complete tags passes through unchanged") { + JSONObject o; + o.set("b", std::string("B")); + CHECK(Render("", &o) == ""); + CHECK(Render("no tags here", &o) == "no tags here"); + CHECK(Render("a {{b", &o) == "a {{b"); + CHECK(Render("{{b}} }} {", &o) == "B }} {"); +} + +TEST_CASE("Comments are removed from the output") { + JSONObject o; + o.set("name", std::string("x")); + CHECK(Render("a{{! this is ignored }}b", &o) == "ab"); + CHECK(Render("{{!name}}{{name}}", &o) == "x"); +} + +TEST_CASE("Backslash escapes \\n \\t \\r written in template text become control characters") { + // BH also translates the two-character sequences \n, \t, \r in template text (legacy from the + // line-based config format, which users' templates may still rely on). + JSONObject o; + o.set("v", std::string("keep\\n")); + CHECK(Render("line1\\nline2\\tx\\r", &o) == "line1\nline2\tx\r"); + // only template text is translated, never inserted values + CHECK(Render("{{v}}", &o) == "keep\\n"); +} + +TEST_CASE("Dotted and indexed names resolve into nested objects and arrays") { + JSONObject o; + JSONObject *item = new JSONObject(); + item->set("name", std::string("Spirit")); + JSONArray *stats = new JSONArray(); + stats->add(StatEntry(2, "to All Skills")); + stats->add(StatEntry(35, "Faster Cast Rate")); + item->set("stats", stats); + o.set("item", item); + CHECK(Render("{{item.name}}: {{item.stats.1.value}}% {{item.stats[1].name}}", &o) == + "Spirit: 35% Faster Cast Rate"); + CHECK(Render("[{{item.stats.2.name}}][{{item.nope.deeper}}]", &o) == "[][]"); +} + +TEST_CASE("Sections render once for truthy scalars and not at all for falsy or missing values") { + JSONObject o; + o.set("yes", true); + o.set("no", false); + o.set("zero", 0); + o.set("num", 3); + o.set("empty", std::string("")); + o.set("str", std::string("s")); + o.set("emptyList", new JSONArray()); + o.set("emptyObj", new JSONObject()); + CHECK(Render("{{#yes}}Y{{/yes}}", &o) == "Y"); + CHECK(Render("{{#num}}N{{/num}}", &o) == "N"); + CHECK(Render("{{#str}}S{{/str}}", &o) == "S"); + CHECK(Render("{{#no}}x{{/no}}{{#zero}}x{{/zero}}{{#empty}}x{{/empty}}", &o) == ""); + CHECK(Render("{{#missing}}x{{/missing}}{{#emptyList}}x{{/emptyList}}{{#emptyObj}}x{{/emptyObj}}", &o) == ""); + CHECK(Render("a{{#no}}b{{/no}}c", &o) == "ac"); +} + +TEST_CASE("An object section pushes the object as context and falls back to outer keys") { + JSONObject o; + o.set("name", std::string("Magic Find")); + JSONObject *range = new JSONObject(); + range->set("min", 25); + range->set("max", 50); + o.set("range", range); + CHECK(Render("{{#range}}{{name}} {{min}}-{{max}}{{/range}}", &o) == "Magic Find 25-50"); + // after the section, the outer context is active again + CHECK(Render("{{#range}}{{min}}{{/range}}[{{min}}]", &o) == "25[]"); +} + +TEST_CASE("A list section renders its body once per element, in order") { + JSONObject o; + JSONArray *stats = new JSONArray(); + stats->add(StatEntry(2, "a")); + stats->add(StatEntry(3, "b")); + stats->add(StatEntry(4, "c")); + o.set("stats", stats); + o.set("sep", std::string(";")); + CHECK(Render("{{#stats}}<{{value}} {{name}}>{{sep}}{{/stats}}", &o) == "<2 a>;<3 b>;<4 c>;"); + + JSONArray *nums = new JSONArray(); + nums->add(1); + nums->add(2); + nums->add(3); + o.set("nums", nums); + CHECK(Render("{{#nums}}{{this}},{{/nums}}", &o) == "1,2,3,"); +} + +TEST_CASE("{{#this}} iterates a root-level array") { + JSONArray root; + root.add(Obj("type", "Shako")); + root.add(Obj("type", "Jewel")); + CHECK(Render("{{#this}}* {{type}}\n{{/this}}", &root) == "* Shako\n* Jewel\n"); +} + +TEST_CASE("Nested sections with the same name close at the matching tag") { + JSONObject o; + JSONObject *a = new JSONObject(); + a->set("a", std::string("inner")); + o.set("a", a); + CHECK(Render("{{#a}}[{{#a}}{{a}}{{/a}}]{{/a}}!", &o) == "[inner]!"); +} + +TEST_CASE("Inverted sections render only when the value is falsy, missing or an empty list") { + JSONObject o; + o.set("yes", true); + o.set("no", false); + o.set("zero", 0); + o.set("empty", std::string("")); + o.set("emptyList", new JSONArray()); + JSONArray *list = new JSONArray(); + list->add(1); + o.set("list", list); + o.set("name", std::string("ctx")); + CHECK(Render("{{^no}}1{{/no}}{{^zero}}2{{/zero}}{{^empty}}3{{/empty}}{{^missing}}4{{/missing}}{{^emptyList}}5{{/emptyList}}", &o) == "12345"); + CHECK(Render("{{^yes}}x{{/yes}}{{^list}}x{{/list}}", &o) == ""); + // the body of an inverted section still sees the surrounding context + CHECK(Render("{{^missing}}{{name}}{{/missing}}", &o) == "ctx"); +} + +TEST_CASE("A close tag that does not match the open section is a parse error") { + CHECK(Mustache::parse("{{#a}}x{{/b}}") == nullptr); + CHECK(Mustache::parse("{{#a}}{{#b}}x{{/a}}{{/b}}") == nullptr); + JSONObject o; + Mustache::Context ctx(&o, nullptr); + CHECK(Mustache::renderTemplate(nullptr, ctx) == ""); +} + +TEST_CASE("Mustache::render renders a template string in one call") { + JSONObject o; + o.set("name", std::string("Shako")); + Mustache::Context ctx(&o, nullptr); + CHECK(Mustache::render("Found {{name}}{{#name}}!{{/name}}", ctx) == "Found Shako!"); +} + +TEST_CASE("{{#key=value}} renders only when the value's text equals exactly") { + JSONObject o; + o.set("name", std::string("Shako")); + const std::string t = "{{#quality=Unique}}U:{{name}}/{{quality}}{{/quality}}"; + + o.set("quality", std::string("Unique")); + CHECK(Render(t, &o) == "U:Shako/Unique"); + o.set("quality", std::string("Magic")); + CHECK(Render(t, &o) == ""); + o.set("quality", std::string("unique")); + CHECK(Render(t, &o) == ""); + o.set("quality", std::string("Unique2")); + CHECK(Render(t, &o) == ""); + + o.set("sockets", 4); + CHECK(Render("{{#sockets=4}}four{{/sockets}}{{#sockets=3}}three{{/sockets}}", &o) == "four"); +} + +TEST_CASE("{{#key!value}} renders only when the value's text differs") { + JSONObject o; + const std::string t = "{{#quality!Unique}}not unique{{/quality}}"; + o.set("quality", std::string("Rare")); + CHECK(Render(t, &o) == "not unique"); + o.set("quality", std::string("Unique")); + CHECK(Render(t, &o) == ""); +} + +TEST_CASE("{{#key>N}} and {{#key "10" as text but 9 < 10 as numbers + o.set("iLevel", 9); + CHECK(Render("{{#iLevel>10}}x{{/iLevel}}", &o) == ""); + o.set("iLevel", std::string("100")); + CHECK(Render("{{#iLevel>99.5}}x{{/iLevel}}", &o) == "x"); +} + +TEST_CASE("{{#key$A|B}} renders when the value is one of the |-separated alternatives") { + JSONObject o; + const std::string t = "{{#quality$Magic|Rare}}MR{{/quality}}"; + o.set("quality", std::string("Magic")); + CHECK(Render(t, &o) == "MR"); + o.set("quality", std::string("Rare")); + CHECK(Render(t, &o) == "MR"); + o.set("quality", std::string("Unique")); + CHECK(Render(t, &o) == ""); + o.set("quality", std::string("Mag")); + CHECK(Render(t, &o) == ""); + o.set("quality", std::string("Magic|Rare")); + CHECK(Render(t, &o) == ""); + o.set("quality", std::string("Set")); + CHECK(Render("{{#quality$Set}}single{{/quality}}", &o) == "single"); +} + +TEST_CASE("{{#key^A|B}} renders when the value is none of the alternatives") { + JSONObject o; + const std::string t = "{{#quality^Unique|Magic|Rare}}other{{/quality}}"; + o.set("quality", std::string("Set")); + CHECK(Render(t, &o) == "other"); + o.set("quality", std::string("Normal")); + CHECK(Render(t, &o) == "other"); + o.set("quality", std::string("Unique")); + CHECK(Render(t, &o) == ""); + o.set("quality", std::string("Magic")); + CHECK(Render(t, &o) == ""); + o.set("quality", std::string("Rare")); + CHECK(Render(t, &o) == ""); +} + +TEST_CASE("Comparison sections never render for a missing key") { + JSONObject o; + CHECK(Render("{{#quality=Unique}}a{{/quality}}{{#quality!Unique}}b{{/quality}}" + "{{#quality>1}}c{{/quality}}{{#quality$Unique}}d{{/quality}}{{#quality^Unique}}e{{/quality}}", &o) == ""); +} + +TEST_CASE("Partials render with the current context and unknown partials render empty") { + JSONObject o; + o.set("name", std::string("Outer")); + o.set("child", Obj("name", "Inner")); + Partials p; + p.Add("p", "<{{name}}>"); + CHECK(Render("{{>p}}{{#child}}{{>p}}{{/child}}{{>nope}}", &o, &p) == ""); + // without a template factory, partial tags render nothing + CHECK(Render("a{{>p}}b", &o) == "ab"); +} + +TEST_CASE("Partials nest and can recurse over nested data") { + JSONObject o; + JSONObject *a = new JSONObject(); + a->set("name", std::string("a")); + JSONArray *kids = new JSONArray(); + kids->add(Obj("name", "b")); + a->set("kids", kids); + JSONArray *top = new JSONArray(); + top->add(a); + o.set("kids", top); + Partials p; + p.Add("node", "({{name}}{{#kids}}{{>>node}}{{/kids}})"); + CHECK(Render("{{#kids}}{{>>node}}{{/kids}}", &o, &p) == "(a(b))"); +} + +TEST_CASE("Isolated partials {{>>name}} see only the current element, not outer contexts") { + JSONObject o; + o.set("name", std::string("Outer")); + o.set("isEthereal", true); + JSONObject *child = new JSONObject(); + child->set("type", std::string("Jewel")); + o.set("child", child); + Partials p; + p.Add("p", "{{#isEthereal}}Eth {{/isEthereal}}{{type}}/{{name}}"); + p.Add("wrap", "[{{>p}}]"); + CHECK(Render("{{#child}}{{>p}}|{{>>p}}{{/child}}", &o, &p) == "Eth Jewel/Outer|Jewel/"); + // isolation drops parent data but keeps the template factory, so nested partials still resolve + CHECK(Render("{{#child}}{{>>wrap}}{{/child}}", &o, &p) == "[Jewel/]"); +} + +TEST_CASE("Context::find searches the context chain from innermost to outermost") { + JSONObject outer; + outer.set("name", std::string("Outer")); + outer.set("shared", std::string("outer-shared")); + JSONObject inner; + inner.set("shared", std::string("inner-shared")); + inner.set("own", std::string("mine")); + + Mustache::Context root(&outer, nullptr); + Mustache::Context child(&root, &inner); + CHECK(child.find("own")->toString() == "mine"); + CHECK(child.find("shared")->toString() == "inner-shared"); + CHECK(child.find("name")->toString() == "Outer"); + CHECK(child.find("nowhere")->getType() == JSON_NULL); + CHECK(child.find("this") == &inner); + + Mustache::Context isolated(child); + CHECK(isolated.find("own")->toString() == "mine"); + CHECK(isolated.find("name")->getType() == JSON_NULL); +} + +TEST_CASE("Context::findTemplate uses the nearest ancestor's template factory") { + Partials p; + p.Add("t", "T"); + JSONObject a, b; + Mustache::Context root(&a, p.MakeFactory()); + Mustache::Context child(&root, &b); + Mustache::Context grandchild(&child, &b); + CHECK(grandchild.findTemplate("t") == p.templates["t"].get()); + CHECK(grandchild.findTemplate("unknown") == nullptr); + Mustache::Context isolated(grandchild); + CHECK(isolated.findTemplate("t") == p.templates["t"].get()); + + Mustache::Context bare(&a, nullptr); + Mustache::Context bareChild(&bare, &b); + CHECK(bareChild.findTemplate("t") == nullptr); +} + +// ---- Known bug ---- + +TEST_CASE("An integer 0 in the outermost context renders as 0" * doctest::should_fail()) { + // BUG: JSONNumber::toString formats the integer 0 with "%f", so it renders "0.000000". A zero is + // only printed when it is looked up in the outermost context (the template root, or the element + // an isolated {{>>partial}} is rendered with): deeper lookups treat 0 as falsy and keep walking + // up the parent chain. Integer JSON values must print as integers ("0", like every other int). + JSONObject o; + o.set("kills", 0); + CHECK(Render("Kills: {{kills}}", &o) == "Kills: 0"); + + JSONObject root; + JSONObject *child = new JSONObject(); + child->set("sockets", 0); + root.set("child", child); + Partials p; + p.Add("s", "[{{sockets}}]"); + // {{#child}} itself is entered because the object is non-empty; the isolated partial sees only it. + CHECK(Render("{{#child}}{{>>s}}{{/child}}", &root, &p) == "[0]"); +} + +// ---- Default stash export templates (BHApp::stash.mustacheFormat defaults in BH.h) ---- + +namespace { + // Mirrors StashExport: every mustacheFormat entry is parsed and registered under its name. + class DefaultStashTemplates : public Partials { + public: + DefaultStashTemplates() { + for (auto it = App.stash.mustacheFormat.defValues.begin(); it != App.stash.mustacheFormat.defValues.end(); it++) { + Add(it->first, it->second); + } + } + + std::string RenderAs(const std::string &name, JSONElement *data) { + Mustache::Context ctx(data, MakeFactory()); + return Mustache::renderTemplate(templates[name].get(), ctx); + } + }; + + JSONObject *Item(const std::string &quality, const std::string &type, int iLevel) { + JSONObject *item = new JSONObject(); + item->set("quality", quality); + item->set("type", type); + item->set("iLevel", iLevel); + return item; + } + + JSONArray *Stats(JSONObject *a, JSONObject *b = nullptr, JSONObject *c = nullptr) { + JSONArray *s = new JSONArray(); + s->add(a); + s->add(b); + s->add(c); + return s; + } + + JSONObject *Shako() { + JSONObject *it = Item("Unique", "Shako", 87); + it->set("name", std::string("Harlequin Crest")); + it->set("defense", 141); + it->set("stats", Stats(StatEntry(2, "to All Skills"), + RangedStat(50, "% Better Chance of Getting Magic Items", 25, 50), + StatEntry(1, "Skill", "Teleport"))); + return it; + } + + JSONObject *Spirit() { + JSONObject *it = Item("Normal", "Monarch", 85); + it->set("isRuneword", true); + it->set("runeword", std::string("Spirit")); + it->set("sockets", 4); + it->set("defense", 148); + it->set("stats", Stats(StatEntry(2, "to All Skills"))); + JSONArray *socketed = new JSONArray(); + socketed->add(Item("Normal", "Tal Rune", 1)); + it->set("socketed", socketed); + return it; + } + + JSONObject *EthRareWithJewel() { + JSONObject *it = Item("Rare", "Grim Helm", 80); + it->set("name", std::string("Doom Visor")); + it->set("isEthereal", true); + it->set("sockets", 1); + it->set("stats", Stats(StatEntry(20, "Life"))); + JSONObject *jewel = Item("Magic", "Jewel", 70); + jewel->set("name", std::string("Ruby Jewel of Fervor")); + jewel->set("stats", Stats(StatEntry(15, "Increased Attack Speed"))); + JSONArray *socketed = new JSONArray(); + socketed->add(jewel); + it->set("socketed", socketed); + return it; + } + + JSONObject *SkullStack() { + JSONObject *it = Item("Normal", "Perfect Skull", 1); + it->set("count", 3); + return it; + } + + JSONObject *SetHelm() { + JSONObject *it = Item("Set", "Death Mask", 66); + it->set("name", std::string("Tal Rasha's Horadric Crest")); + return it; + } +} + +TEST_CASE("Every default stash template parses") { + for (auto it = App.stash.mustacheFormat.defValues.begin(); it != App.stash.mustacheFormat.defValues.end(); it++) { + INFO(it->first); + std::unique_ptr t(Mustache::parse(it->second)); + CHECK(t != nullptr); + } +} + +TEST_CASE("Default item template: unique header is bold with level, then defense and stat lines") { + DefaultStashTemplates t; + std::unique_ptr shako(Shako()); + CHECK(t.RenderAs("item", shako.get()) == + "**Harlequin Crest** (L87)" + "\n\n >141 defense" + "\n\n > 2 to All Skills" + "\n\n > 50 (25-50) % Better Chance of Getting Magic Items" + "\n\n > 1 Teleport" + "\n"); +} + +TEST_CASE("Default item template: an unidentified unique (no name) shows its base type") { + DefaultStashTemplates t; + std::unique_ptr it(Item("Unique", "Shako", 87)); + CHECK(t.RenderAs("item", it.get()) == "**Shako** (L87)\n"); +} + +TEST_CASE("Default item template: runewords show bold runeword name, base, sockets and hide socketed runes") { + DefaultStashTemplates t; + std::unique_ptr spirit(Spirit()); + CHECK(t.RenderAs("item", spirit.get()) == + "**Spirit** Monarch (L85)[4]" + "\n\n >148 defense" + "\n\n > 2 to All Skills" + "\n"); +} + +TEST_CASE("Default item template: ethereal rare with a socketed jewel lists the jewel as a nested item") { + // The jewel is rendered via {{>>item}}, so it must not inherit "Eth" or "[1]" from its parent. + DefaultStashTemplates t; + std::unique_ptr helm(EthRareWithJewel()); + CHECK(t.RenderAs("item", helm.get()) == + "Eth **Doom Visor** (L80)[1]" + "\n\n > 20 Life" + "\n\n * **Ruby Jewel of Fervor** (L70)" + "\n\n > 15 Increased Attack Speed" + "\n" + "\n"); +} + +TEST_CASE("Default item template: plain and set items are not bold; stacked items show their count") { + DefaultStashTemplates t; + std::unique_ptr skulls(SkullStack()); + std::unique_ptr set(SetHelm()); + CHECK(t.RenderAs("item", skulls.get()) == "Perfect Skull (L1) **x3**\n"); + CHECK(t.RenderAs("item", set.get()) == "Tal Rasha's Horadric Crest (L66)\n"); +} + +TEST_CASE("Default stash template renders every item as a bullet separated by blank lines") { + DefaultStashTemplates t; + JSONArray data; + data.add(Shako()); + data.add(Spirit()); + data.add(EthRareWithJewel()); + data.add(SkullStack()); + data.add(SetHelm()); + CHECK(t.RenderAs("stash", &data) == + "* **Harlequin Crest** (L87)\n\n >141 defense\n\n > 2 to All Skills" + "\n\n > 50 (25-50) % Better Chance of Getting Magic Items\n\n > 1 Teleport\n\n\n" + "* **Spirit** Monarch (L85)[4]\n\n >148 defense\n\n > 2 to All Skills\n\n\n" + "* Eth **Doom Visor** (L80)[1]\n\n > 20 Life\n\n * **Ruby Jewel of Fervor** (L70)" + "\n\n > 15 Increased Attack Speed\n\n\n\n" + "* Perfect Skull (L1) **x3**\n\n\n" + "* Tal Rasha's Horadric Crest (L66)\n\n\n"); + + JSONArray empty; + CHECK(t.RenderAs("stash", &empty) == ""); +} + +} From 9a8084f7ea32f226f44a7684b2a19ad002c9c767 Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:06 -0400 Subject: [PATCH 4/8] Test loot filter formulas --- tests/BH.Tests.vcxproj | 1 + tests/FormulaTests.cpp | 944 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 945 insertions(+) create mode 100644 tests/FormulaTests.cpp diff --git a/tests/BH.Tests.vcxproj b/tests/BH.Tests.vcxproj index fd611555..e446ceaa 100644 --- a/tests/BH.Tests.vcxproj +++ b/tests/BH.Tests.vcxproj @@ -117,6 +117,7 @@ + diff --git a/tests/FormulaTests.cpp b/tests/FormulaTests.cpp new file mode 100644 index 00000000..7e5a597a --- /dev/null +++ b/tests/FormulaTests.cpp @@ -0,0 +1,944 @@ +#include "doctest/doctest.h" + +#include +#include +#include +#include +#include +#include + +#include "Formula.h" +#include "LootFilter.h" + +// Formula.h is the expression language behind the loot filter's `Formula[KEY]: ...` definitions +// and inline `$f(...)` islands. Expected values follow the PD2 wiki's "Formulas" section +// (https://wiki.projectdiablo2.com/wiki/Formula_Info, transcluded on Item_Filtering), which says +// formulas "closely resemble Excel or Sheets formulas", plus ordinary arithmetic. + +namespace { + +// A tiny variable context so Formula can be exercised without the game. +struct Ctx { + float x = 0; + float y = 0; + int xReads = 0; + int yReads = 0; + int boomReads = 0; + std::vector lastParams; +}; + +const std::unordered_map>& Defs() { + static const std::unordered_map> defs = { + { L"x", { 0, [](FormulaStatus& err, Ctx* c, const std::vector& ids) -> float { + c->xReads++; + return c->x; + } } }, + { L"y", { 0, [](FormulaStatus& err, Ctx* c, const std::vector& ids) -> float { + c->yReads++; + return c->y; + } } }, + { L"x_y", { 0, [](FormulaStatus& err, Ctx* c, const std::vector& ids) -> float { + return 42; + } } }, + // one integer parameter, echoed back + { L"p", { 1, [](FormulaStatus& err, Ctx* c, const std::vector& ids) -> float { + c->lastParams = ids; + return (float)ids[0]; + } } }, + // two integer parameters + { L"q", { 2, [](FormulaStatus& err, Ctx* c, const std::vector& ids) -> float { + c->lastParams = ids; + return (float)(ids[0] * 1000 + ids[1]); + } } }, + // a variable whose lookup fails at run time + { L"boom", { 0, [](FormulaStatus& err, Ctx* c, const std::vector& ids) -> float { + c->boomReads++; + err = FormulaStatus::MATH_ERROR; + return 0; + } } }, + }; + return defs; +} + +// True when `text` does not compile. BH only distinguishes OK from not OK (a rejected definition +// or island is skipped), so the tests do not pin which error status the parser reports. +bool Rejected(const std::wstring& text) { + std::unique_ptr> out; + return Formula::Compile(text, out, Defs()) != FormulaStatus::OK; +} + +std::unique_ptr> MustCompile(const std::wstring& text) { + std::unique_ptr> out; + FormulaStatus st = Formula::Compile(text, out, Defs()); + INFO("formula: ", std::string(text.begin(), text.end())); + REQUIRE(st == FormulaStatus::OK); + REQUIRE(out); + return out; +} + +// Compiles and runs `text`; the run must succeed. +float Eval(const std::wstring& text, Ctx& ctx) { + auto f = MustCompile(text); + float v = 0; + INFO("formula: ", std::string(text.begin(), text.end())); + REQUIRE(f->execute(&ctx, v) == FormulaStatus::OK); + return v; +} + +float Eval(const std::wstring& text) { + Ctx ctx; + return Eval(text, ctx); +} + +FormulaStatus RunStatus(const std::wstring& text, Ctx& ctx, float& v) { + auto f = MustCompile(text); + return f->execute(&ctx, v); +} + +// ---- loot filter helpers ---- + +// The name BH shows for an item the game calls "Axe", as a narrow string so failures print +// readably (the names in these tests are plain ASCII). +std::string Name(support::TestItem& item) { + std::wstring name = support::NameOf(item, L"Axe"); + std::string out; + for (wchar_t c : name) { + out += c < 128 ? (char)c : '?'; + } + return out; +} + +// What BH shows as the item's name when the only rule is `ItemDisplay[]: `. +std::string Show(const std::string& action, support::TestItem& item, const std::string& extraLines = "") { + support::LoadFilter(extraLines + "ItemDisplay[]: " + action + "\n"); + return Name(item); +} + +// How the filter renders the inline formula `$f(expr)` for the item. +std::string Render(const std::string& expr, support::TestItem& item) { + return Show("<$f(" + expr + ")>", item); +} + +} // namespace + +TEST_SUITE("Formula") { + +TEST_CASE("number literals parse as decimal reals") { + CHECK(Eval(L"0") == 0.0f); + CHECK(Eval(L"42") == 42.0f); + CHECK(Eval(L"007") == 7.0f); + CHECK(Eval(L"2.5") == 2.5f); + CHECK(Eval(L".5") == 0.5f); + CHECK(Eval(L"5.") == 5.0f); + CHECK(Eval(L"0.0000001") == doctest::Approx(1e-7).epsilon(1e-6)); +} + +TEST_CASE("malformed number literals are rejected") { + // a lone dot is not a number + CHECK(Rejected(L".")); + CHECK(Rejected(L"1 + .")); + // two dots make two adjacent numbers, which is not an expression + CHECK(Rejected(L"1.2.3")); + CHECK(Rejected(L"1 2")); +} + +TEST_CASE("characters outside the formula language are rejected") { + // the wiki lists the operators == > < != >= <= + - * / ^ !; '%' is not modulus (MOD() is) + CHECK(Rejected(L"7 % 3")); + CHECK(Rejected(L"1 = 1")); + CHECK(Rejected(L"1 && 1")); + CHECK(Rejected(L"1 || 0")); + CHECK(Rejected(L"$1")); + CHECK(Rejected(L"[1]")); +} + +TEST_CASE("multiplication and division bind tighter than addition and subtraction") { + CHECK(Eval(L"1 + 2 * 3") == 7.0f); + CHECK(Eval(L"2 * 3 + 1") == 7.0f); + CHECK(Eval(L"10 - 6 / 2") == 7.0f); + CHECK(Eval(L"(1 + 2) * 3") == 9.0f); + CHECK(Eval(L"((((2))))") == 2.0f); + CHECK(Eval(L"2 * (3 + (4 - 1)) / 3") == 4.0f); +} + +TEST_CASE("binary operators of equal precedence group left to right") { + CHECK(Eval(L"10 - 4 - 3") == 3.0f); + CHECK(Eval(L"100 / 10 / 5") == 2.0f); + CHECK(Eval(L"12 / 3 * 2") == 8.0f); + CHECK(Eval(L"1 - 2 + 3") == 2.0f); +} + +TEST_CASE("exponentiation binds tightest and groups right to left") { + CHECK(Eval(L"2 ^ 10") == 1024.0f); + CHECK(Eval(L"2 * 3 ^ 2") == 18.0f); + CHECK(Eval(L"3 ^ 2 * 2") == 18.0f); + // 2^(3^2) = 2^9, not (2^3)^2 = 64. Right-to-left grouping is deliberate in the parser + // (parseExpression recurses at the same precedence for '^') and is the usual mathematical + // convention, unlike Excel/Sheets which group '^' left to right. + CHECK(Eval(L"2 ^ 3 ^ 2") == 512.0f); + CHECK(Eval(L"4 ^ 0.5") == 2.0f); + CHECK(Eval(L"2 ^ -1") == 0.5f); +} + +TEST_CASE("unary minus, plus and not apply to the operand that follows") { + CHECK(Eval(L"-3") == -3.0f); + CHECK(Eval(L"--3") == 3.0f); + CHECK(Eval(L"+3") == 3.0f); + CHECK(Eval(L"-(2 + 3)") == -5.0f); + CHECK(Eval(L"2 * -3") == -6.0f); + CHECK(Eval(L"2 - -3") == 5.0f); + CHECK(Eval(L"-2 + 5") == 3.0f); + // Excel convention (formulas "closely resemble Excel"): negation binds tighter than ^ + CHECK(Eval(L"-2 ^ 2") == 4.0f); + CHECK(Eval(L"-(2 ^ 2)") == -4.0f); +} + +TEST_CASE("logical not turns zero into 1 and anything else into 0") { + CHECK(Eval(L"!0") == 1.0f); + CHECK(Eval(L"!5") == 0.0f); + CHECK(Eval(L"!-1") == 0.0f); + CHECK(Eval(L"!0.5") == 0.0f); + CHECK(Eval(L"!!5") == 1.0f); + CHECK(Eval(L"!(1 > 2)") == 1.0f); + // not applies to its operand only: (!0) + 1 + CHECK(Eval(L"!0 + 1") == 2.0f); +} + +TEST_CASE("comparisons yield 1 or 0 at their boundaries") { + CHECK(Eval(L"3 == 3") == 1.0f); + CHECK(Eval(L"3 == 3.5") == 0.0f); + CHECK(Eval(L"3 != 3") == 0.0f); + CHECK(Eval(L"3 != 4") == 1.0f); + CHECK(Eval(L"3 > 3") == 0.0f); + CHECK(Eval(L"4 > 3") == 1.0f); + CHECK(Eval(L"3 < 3") == 0.0f); + CHECK(Eval(L"2 < 3") == 1.0f); + CHECK(Eval(L"3 >= 3") == 1.0f); + CHECK(Eval(L"2.9 >= 3") == 0.0f); + CHECK(Eval(L"3 <= 3") == 1.0f); + CHECK(Eval(L"3.1 <= 3") == 0.0f); + CHECK(Eval(L"-1 < 0") == 1.0f); +} + +TEST_CASE("comparisons bind looser than arithmetic and chain left to right") { + CHECK(Eval(L"1 + 1 == 2") == 1.0f); + CHECK(Eval(L"2 == 1 + 1") == 1.0f); + CHECK(Eval(L"2 * 3 > 5") == 1.0f); + CHECK(Eval(L"2 * 3 > 6") == 0.0f); + CHECK(Eval(L"10 - 4 >= 2 ^ 3") == 0.0f); + // (3 > 2) > 1 -> 1 > 1 + CHECK(Eval(L"3 > 2 > 1") == 0.0f); + // (1 < 2) == 1 + CHECK(Eval(L"1 < 2 == 1") == 1.0f); + // (2 == 2) == 2 -> 1 == 2 + CHECK(Eval(L"2 == 2 == 2") == 0.0f); + // a comparison result is an ordinary number + CHECK(Eval(L"(5 > 3) + (2 > 1) + (0 > 1)") == 2.0f); +} + +TEST_CASE("division is real division and follows IEEE rules for zero divisors") { + CHECK(Eval(L"7 / 2") == 3.5f); + CHECK(Eval(L"-7 / 2") == -3.5f); + CHECK(Eval(L"1 / 4") == 0.25f); + CHECK(Eval(L"1 / 3 * 3") == doctest::Approx(1.0)); + // dividing by zero is not a compile or run error; the output path reports such values as f_err + CHECK(Eval(L"1 / 0") == std::numeric_limits::infinity()); + CHECK(Eval(L"-1 / 0") == -std::numeric_limits::infinity()); + CHECK(std::isnan(Eval(L"0 / 0"))); +} + +TEST_CASE("whitespace between tokens is ignored") { + CHECK(Eval(L"1+2*3") == 7.0f); + CHECK(Eval(L" 1 +\t2 \r\n* 3 ") == 7.0f); + CHECK(Eval(L"max ( 1 , 2 )") == 2.0f); + CHECK(Eval(L"3>=3") == 1.0f); + // a space splits a two-character operator, and a lone '=' is not an operator + CHECK(Rejected(L"3 > = 3")); +} + +TEST_CASE("function names and variables are case-insensitive") { + Ctx ctx; + ctx.x = 4; + CHECK(Eval(L"MAX(1, 2)") == 2.0f); + CHECK(Eval(L"Max(1, 2)") == 2.0f); + CHECK(Eval(L"SQRt(4)") == 2.0f); + CHECK(Eval(L"X + x", ctx) == 8.0f); + CHECK(Eval(L"X_Y") == 42.0f); +} + +TEST_CASE("empty and incomplete expressions are rejected") { + CHECK(Rejected(L"")); + CHECK(Rejected(L" ")); + CHECK(Rejected(L"1 +")); + CHECK(Rejected(L"* 2")); + CHECK(Rejected(L"1 * * 2")); + CHECK(Rejected(L"-")); + CHECK(Rejected(L"!")); + CHECK(Rejected(L"1 >")); + // '!' is only a prefix operator + CHECK(Rejected(L"1 ! 2")); + CHECK(Rejected(L"1 ,")); +} + +TEST_CASE("unbalanced parentheses are rejected") { + CHECK(Rejected(L"(1 + 2")); + CHECK(Rejected(L"1 + 2)")); + CHECK(Rejected(L"((1)")); + CHECK(Rejected(L"(1))")); + CHECK(Rejected(L"()")); + CHECK(Rejected(L")(")); + CHECK(Rejected(L"max(1, 2")); + // juxtaposition is not multiplication + CHECK(Rejected(L"2(3)")); +} + +TEST_CASE("IF picks the second argument when the first is non-zero, otherwise the third") { + CHECK(Eval(L"if(1, 10, 20)") == 10.0f); + CHECK(Eval(L"if(0, 10, 20)") == 20.0f); + CHECK(Eval(L"if(-0.5, 10, 20)") == 10.0f); + CHECK(Eval(L"if(2 > 1, 10, 20)") == 10.0f); + Ctx ctx; + ctx.x = 0; + CHECK(Eval(L"if(x, 10, 20)", ctx) == 20.0f); + ctx.x = 3; + CHECK(Eval(L"if(x, 10, 20)", ctx) == 10.0f); + CHECK(Eval(L"if(x > 2, if(x > 5, 3, 2), 1)", ctx) == 2.0f); +} + +TEST_CASE("AND, OR and XOR return 1 or 0 by the truthiness of all arguments") { + CHECK(Eval(L"and(1, 2, -1)") == 1.0f); + CHECK(Eval(L"and(1, 0, 1)") == 0.0f); + CHECK(Eval(L"and(0.5)") == 1.0f); + CHECK(Eval(L"or(0, 0)") == 0.0f); + CHECK(Eval(L"or(0, 3)") == 1.0f); + CHECK(Eval(L"or(-2)") == 1.0f); + // XOR is true when an odd number of arguments are true + CHECK(Eval(L"xor(1, 0)") == 1.0f); + CHECK(Eval(L"xor(1, 1)") == 0.0f); + CHECK(Eval(L"xor(1, 1, 1)") == 1.0f); + CHECK(Eval(L"xor(0)") == 0.0f); + CHECK(Eval(L"xor(5, 0, 0, 7)") == 0.0f); + Ctx ctx; + ctx.x = 2; + ctx.y = 0; + CHECK(Eval(L"and(x > 1, y == 0)", ctx) == 1.0f); + CHECK(Eval(L"or(x > 5, y)", ctx) == 0.0f); +} + +TEST_CASE("AND, OR and IF do not evaluate arguments they do not need") { + // the wiki: "AND and OR are short-circuited" (listed under "Minor Details: Tidbits that may + // or may not change"; if that changes, update these together with the wiki) + Ctx ctx; + float v = -1; + ctx.x = 0; + CHECK(RunStatus(L"and(x, boom)", ctx, v) == FormulaStatus::OK); + CHECK(v == 0.0f); + CHECK(ctx.boomReads == 0); + + ctx.x = 1; + CHECK(RunStatus(L"or(x, boom)", ctx, v) == FormulaStatus::OK); + CHECK(v == 1.0f); + CHECK(ctx.boomReads == 0); + + ctx.x = 1; + CHECK(RunStatus(L"if(x, 7, boom)", ctx, v) == FormulaStatus::OK); + CHECK(v == 7.0f); + ctx.x = 0; + CHECK(RunStatus(L"if(x, boom, 8)", ctx, v) == FormulaStatus::OK); + CHECK(v == 8.0f); + CHECK(ctx.boomReads == 0); + + // arguments that are needed are evaluated, and their errors are reported + ctx.x = 1; + CHECK(RunStatus(L"and(x, boom)", ctx, v) == FormulaStatus::MATH_ERROR); + ctx.x = 0; + CHECK(RunStatus(L"or(x, boom)", ctx, v) == FormulaStatus::MATH_ERROR); + CHECK(ctx.boomReads == 2); +} + +TEST_CASE("a constant IF condition selects its branch without reading the other one") { + // the wiki: "constant expressions such as POW(3,3) or IF(TRUE,STAT1,STAT2) are evaluated once" + // (listed under "Minor Details: Tidbits that may or may not change") + Ctx ctx; + float v = -1; + ctx.x = 4; + ctx.y = 9; + CHECK(RunStatus(L"if(1, x, boom)", ctx, v) == FormulaStatus::OK); + CHECK(v == 4.0f); + CHECK(RunStatus(L"if(0, boom, y)", ctx, v) == FormulaStatus::OK); + CHECK(v == 9.0f); + CHECK(ctx.boomReads == 0); +} + +TEST_CASE("rounding functions follow the wiki examples") { + CHECK(Eval(L"floor(1.5)") == 1.0f); + CHECK(Eval(L"floor(-1.5)") == -2.0f); + CHECK(Eval(L"floor(3)") == 3.0f); + CHECK(Eval(L"ceil(1.49)") == 2.0f); + CHECK(Eval(L"ceil(-1.5)") == -1.0f); + CHECK(Eval(L"ceil(3)") == 3.0f); + CHECK(Eval(L"round(1.49)") == 1.0f); + CHECK(Eval(L"round(1.5)") == 2.0f); + // Excel's ROUND rounds halves away from zero (no banker's rounding) + CHECK(Eval(L"round(2.5)") == 3.0f); + CHECK(Eval(L"round(-1.5)") == -2.0f); + CHECK(Eval(L"round(-1.49)") == -1.0f); +} + +TEST_CASE("MIN, MAX and AVERAGE take one or more arguments") { + CHECK(Eval(L"min(3, -1, 2)") == -1.0f); + CHECK(Eval(L"max(3, -1, 2)") == 3.0f); + CHECK(Eval(L"min(7)") == 7.0f); + CHECK(Eval(L"max(-7)") == -7.0f); + CHECK(Eval(L"max(-3, -2)") == -2.0f); + CHECK(Eval(L"min(2.5, 2.25)") == 2.25f); + CHECK(Eval(L"average(8, 8, 8, 8, 8)") == 8.0f); + CHECK(Eval(L"average(1, 2)") == 1.5f); + CHECK(Eval(L"average(-4, 4, 3)") == 1.0f); + CHECK(Eval(L"max(min(3, 5), 1 + 1)") == 3.0f); + Ctx ctx; + ctx.x = 60; + CHECK(Eval(L"min(x, 50) <= 50", ctx) == 1.0f); + CHECK(Eval(L"min(x, 50)", ctx) == 50.0f); +} + +TEST_CASE("MOD keeps the sign of the dividend and works on reals") { + // the wiki example: MOD(-17,5)==-2 + CHECK(Eval(L"mod(-17, 5)") == -2.0f); + CHECK(Eval(L"mod(17, 5)") == 2.0f); + CHECK(Eval(L"mod(17, -5)") == 2.0f); + CHECK(Eval(L"mod(10, 5)") == 0.0f); + CHECK(Eval(L"mod(7.5, 2)") == 1.5f); + CHECK(Eval(L"mod(3, 7)") == 3.0f); + CHECK(std::isnan(Eval(L"mod(5, 0)"))); +} + +TEST_CASE("power, root and logarithm functions") { + CHECK(Eval(L"pow(2, 2)") == 4.0f); + CHECK(Eval(L"pow(3, 3)") == 27.0f); + CHECK(Eval(L"pow(9, 0.5)") == 3.0f); + CHECK(Eval(L"pow(2, -2)") == 0.25f); + CHECK(Eval(L"pow(5, 0)") == 1.0f); + CHECK(Eval(L"sqrt(4)") == 2.0f); + CHECK(Eval(L"sqrt(2)") == doctest::Approx(1.41421356)); + CHECK(std::isnan(Eval(L"sqrt(-1)"))); + CHECK(Eval(L"exp(0)") == 1.0f); + CHECK(Eval(L"exp(1)") == doctest::Approx(2.718281828)); + CHECK(Eval(L"ln(1)") == 0.0f); + CHECK(Eval(L"ln(exp(2))") == doctest::Approx(2.0)); + CHECK(Eval(L"ln(0)") == -std::numeric_limits::infinity()); + CHECK(std::isnan(Eval(L"ln(-1)"))); + Ctx ctx; + ctx.x = 50; + CHECK(Eval(L"exp(ln(x))", ctx) == doctest::Approx(50.0)); +} + +TEST_CASE("COUNT counts true arguments and COUNTIF counts matches of the last argument") { + CHECK(Eval(L"count(1, 0, 1, 1)") == 3.0f); + CHECK(Eval(L"count(0)") == 0.0f); + CHECK(Eval(L"count(-2, 0.5, 0)") == 2.0f); + // the wiki example: COUNTIF(1,0,1)==1 (the last value is the one searched for) + CHECK(Eval(L"countif(1, 0, 1)") == 1.0f); + CHECK(Eval(L"countif(2, 2, 2, 2)") == 3.0f); + CHECK(Eval(L"countif(1, 2)") == 0.0f); + CHECK(Eval(L"countif(3, 1, 3, 3, 3)") == 3.0f); + Ctx ctx; + ctx.x = 3; + ctx.y = 4; + CHECK(Eval(L"countif(x, y, 3, x + 1, 4)", ctx) == 2.0f); +} + +TEST_CASE("ABS and SIGN") { + CHECK(Eval(L"abs(-1)") == 1.0f); + CHECK(Eval(L"abs(2.5)") == 2.5f); + CHECK(Eval(L"abs(-0.25)") == 0.25f); + CHECK(Eval(L"abs(0)") == 0.0f); + CHECK(Eval(L"sign(-1)") == -1.0f); + CHECK(Eval(L"sign(0)") == 0.0f); + CHECK(Eval(L"sign(1)") == 1.0f); + CHECK(Eval(L"sign(-0.001)") == -1.0f); + CHECK(Eval(L"sign(250)") == 1.0f); +} + +TEST_CASE("functions with the wrong number of arguments are rejected") { + CHECK(Rejected(L"if(1, 2)")); + CHECK(Rejected(L"if(1, 2, 3, 4)")); + CHECK(Rejected(L"mod(1)")); + CHECK(Rejected(L"pow(1, 2, 3)")); + CHECK(Rejected(L"countif(1)")); + CHECK(Rejected(L"sqrt(1, 2)")); + CHECK(Rejected(L"abs()")); + CHECK(Rejected(L"sign(1, 2)")); + CHECK(Rejected(L"round(1, 2)")); + // "All functions require one or more arguments" + CHECK(Rejected(L"max()")); + CHECK(Rejected(L"min()")); + CHECK(Rejected(L"and()")); + CHECK(Rejected(L"or()")); + CHECK(Rejected(L"xor()")); + CHECK(Rejected(L"count()")); + CHECK(Rejected(L"average()")); + // an arity error inside a larger expression still rejects the whole formula + CHECK(Rejected(L"1 + max(2, abs(3, 4))")); +} + +TEST_CASE("malformed function calls are rejected") { + CHECK(Rejected(L"max")); + CHECK(Rejected(L"max 1")); + CHECK(Rejected(L"max(1,)")); + CHECK(Rejected(L"max(,1)")); + CHECK(Rejected(L"max(1 2)")); + CHECK(Rejected(L"max(1,,2)")); + CHECK(Rejected(L"maximum(1)")); +} + +TEST_CASE("variables are read from the context on every run") { + auto f = MustCompile(L"x * (2 + 3) - y"); + Ctx a; + a.x = 2; + a.y = 1; + Ctx b; + b.x = -3; + b.y = 0.5f; + float v = 0; + REQUIRE(f->execute(&a, v) == FormulaStatus::OK); + CHECK(v == 9.0f); + REQUIRE(f->execute(&b, v) == FormulaStatus::OK); + CHECK(v == -15.5f); + a.x = 10; + REQUIRE(f->execute(&a, v) == FormulaStatus::OK); + CHECK(v == 49.0f); + CHECK(a.xReads == 2); +} + +TEST_CASE("unknown variables are rejected") { + CHECK(Rejected(L"z")); + CHECK(Rejected(L"x + z")); + CHECK(Rejected(L"max(x, nope)")); + // a name is a whole run of letters and underscores, not a prefix match + CHECK(Rejected(L"xy")); + CHECK(Rejected(L"x_")); + // a variable cannot be called like a function + CHECK(Rejected(L"x(1)")); +} + +TEST_CASE("parameterized variables take integer parameters separated by commas") { + // wiki: "Parameters are integers separated by a ','", e.g. stat3, MULTI83,2 + Ctx ctx; + CHECK(Eval(L"p7", ctx) == 7.0f); + CHECK(Eval(L"P 7", ctx) == 7.0f); + CHECK(Eval(L"p0", ctx) == 0.0f); + CHECK(Eval(L"q83,2", ctx) == 83002.0f); + CHECK(ctx.lastParams == std::vector{ 83, 2 }); + CHECK(Eval(L"q 1 , 5", ctx) == 1005.0f); + CHECK(Eval(L"p3 + p4 * 2", ctx) == 11.0f); + // inside a call, the variable consumes exactly its own parameters + CHECK(Eval(L"max(q1,2, 3)", ctx) == 1002.0f); + CHECK(Eval(L"min(p9, 4)", ctx) == 4.0f); + Eval(L"p2147483647", ctx); + CHECK(ctx.lastParams == std::vector{ 2147483647 }); +} + +TEST_CASE("parameterized variables reject missing or non-integer parameters") { + CHECK(Rejected(L"p")); + CHECK(Rejected(L"p + 1")); + CHECK(Rejected(L"p(7)")); + CHECK(Rejected(L"p1.5")); + CHECK(Rejected(L"p-1")); + CHECK(Rejected(L"p x")); + CHECK(Rejected(L"q1")); + CHECK(Rejected(L"q1 2")); + CHECK(Rejected(L"q1,")); + // beyond the int range + CHECK(Rejected(L"p2147483648")); + CHECK(Rejected(L"p99999999999")); +} + +TEST_CASE("a failing variable makes the whole run fail") { + Ctx ctx; + float v = 0; + CHECK(RunStatus(L"boom", ctx, v) == FormulaStatus::MATH_ERROR); + CHECK(RunStatus(L"1 + boom", ctx, v) == FormulaStatus::MATH_ERROR); + CHECK(RunStatus(L"boom * 0", ctx, v) == FormulaStatus::MATH_ERROR); + CHECK(RunStatus(L"max(1, -boom)", ctx, v) == FormulaStatus::MATH_ERROR); + CHECK(RunStatus(L"if(boom, 1, 2)", ctx, v) == FormulaStatus::MATH_ERROR); + ctx.x = 0; + CHECK(RunStatus(L"if(x, 1, boom)", ctx, v) == FormulaStatus::MATH_ERROR); + // failing at run time is not a compile problem + CHECK_FALSE(Rejected(L"boom")); + // a failure does not stick to the compiled formula + auto f = MustCompile(L"if(x, boom, 5)"); + ctx.x = 1; + CHECK(f->execute(&ctx, v) == FormulaStatus::MATH_ERROR); + ctx.x = 0; + REQUIRE(f->execute(&ctx, v) == FormulaStatus::OK); + CHECK(v == 5.0f); +} + +TEST_CASE("truthiness is any value other than zero") { + // the wiki's truthiness table + CHECK(Formula::IsTrue(1.5f)); + CHECK(Formula::IsTrue(1.0f)); + CHECK_FALSE(Formula::IsTrue(0.0f)); + CHECK(Formula::IsTrue(0.0000001f)); + CHECK(Formula::IsTrue(-1.0f)); + CHECK_FALSE(Formula::IsTrue(-0.0f)); +} + +} // TEST_SUITE("Formula") + +TEST_SUITE("Formula loot filter") { + +TEST_CASE("a Formula definition is referenced as FORMULA in conditions") { + support::LoadFilter( + "Formula[STRONG]: STAT0 >= 20\n" + "ItemDisplay[FORMULASTRONG]: Strong %NAME%\n"); + support::TestItem at("hax", ITEM_QUALITY_UNIQUE); + at.Stat(STAT_STRENGTH, 20); + support::TestItem below("hax", ITEM_QUALITY_UNIQUE); + below.Stat(STAT_STRENGTH, 19); + CHECK(Name(at) == "Strong Axe"); + CHECK(Name(below) == "Axe"); + CHECK(support::Matches(L"FORMULASTRONG", at)); + CHECK_FALSE(support::Matches(L"FORMULASTRONG", below)); + CHECK(support::Matches(L"!FORMULASTRONG", below)); +} + +TEST_CASE("a formula condition compares the real value, not a truncated integer") { + support::LoadFilter("Formula[HALF]: STAT0 / 2\n"); + support::TestItem odd("hax", ITEM_QUALITY_RARE); + odd.Stat(STAT_STRENGTH, 11); // 5.5 + support::TestItem even("hax", ITEM_QUALITY_RARE); + even.Stat(STAT_STRENGTH, 10); // 5 + CHECK(support::Matches(L"FORMULAHALF>5", odd)); + CHECK_FALSE(support::Matches(L"FORMULAHALF>5", even)); + CHECK_FALSE(support::Matches(L"FORMULAHALF=5", odd)); + CHECK(support::Matches(L"FORMULAHALF=5", even)); + CHECK(support::Matches(L"FORMULAHALF<6", odd)); + CHECK_FALSE(support::Matches(L"FORMULAHALF<5", even)); + CHECK(support::Matches(L"FORMULAHALF~5-6", odd)); + CHECK(support::Matches(L"FORMULAHALF~5-6", even)); + CHECK_FALSE(support::Matches(L"FORMULAHALF~6-9", odd)); + // used alone it is a truthiness test: 5.5 != 0 + CHECK(support::Matches(L"FORMULAHALF", odd)); + support::TestItem none("hax", ITEM_QUALITY_RARE); + CHECK_FALSE(support::Matches(L"FORMULAHALF", none)); +} + +TEST_CASE("formula conditions combine with other conditions") { + support::LoadFilter("Formula[RES]: min(STAT39, STAT43) >= 30\n"); + support::TestItem ring("rin", ITEM_QUALITY_RARE); + ring.Stat(STAT_FIRERESIST, 30).Stat(STAT_COLDRESIST, 35); + CHECK(support::Matches(L"rin RARE FORMULARES", ring)); + CHECK_FALSE(support::Matches(L"rin UNI FORMULARES", ring)); + CHECK(support::Matches(L"UNI OR FORMULARES", ring)); + CHECK_FALSE(support::Matches(L"rin !FORMULARES", ring)); +} + +TEST_CASE("a formula that fails at run time never matches") { + // SK999 is past the last skill id, which the SK variable reports as a math error + support::LoadFilter("Formula[BAD]: SK999 + 1\n"); + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK_FALSE(support::Matches(L"FORMULABAD", item)); + CHECK_FALSE(support::Matches(L"FORMULABAD<5", item)); + CHECK_FALSE(support::Matches(L"FORMULABAD=0", item)); + CHECK_FALSE(support::Matches(L"FORMULABAD~0-100", item)); +} + +TEST_CASE("a mixed-case formula reference in a condition still refers to the formula" * doctest::should_fail()) { + // BUG: the wiki says "an input reference only requires the first letter (F) to be capitalized + // the rest is case-insensitive" (listed under "Minor Details: Tidbits that may or may not + // change"), but BuildConditions looks the key up verbatim in formulaMap, whose keys are + // upper-cased. "Formulastrong" is not found and no condition is added, so the rule matches + // items the formula rejects. + support::LoadFilter("Formula[STRONG]: STAT0 >= 20\n"); + support::TestItem weak("hax", ITEM_QUALITY_UNIQUE); + weak.Stat(STAT_STRENGTH, 5); + CHECK_FALSE(support::Matches(L"Formulastrong", weak)); + CHECK_FALSE(support::Matches(L"FORMULAstrong", weak)); +} + +TEST_CASE("a formula's value is shown with %FORMULA% in the item name") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_STRENGTH, 15).Stat(STAT_DEXTERITY, 10); + CHECK(Show("%NAME% (%FORMULASUM%)", item, "Formula[SUM]: STAT0 + STAT2\n") == "Axe (25)"); + CHECK(Show("%FORMULA_A%/%FORMULA_B%", item, "Formula[_A]: STAT0 / 2\nFormula[_B]: STAT2 * 3\n") == "7.5/30"); + // wiki: "an output reference is always case-insensitive" (a "Minor Details" tidbit that may change) + CHECK(Show("%formulasum%", item, "Formula[SUM]: STAT0 + STAT2\n") == "25"); + CHECK(Show("%FormulaSum%", item, "Formula[SUM]: STAT0 + STAT2\n") == "25"); +} + +TEST_CASE("formula output shows 0-2 decimals, dropping trailing zeros") { + // the wiki's "Item Output" table + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(Render("1.49", item) == "<1.49>"); + CHECK(Render("1.495", item) == "<1.5>"); + CHECK(Render("1.494", item) == "<1.49>"); + CHECK(Render("1.5", item) == "<1.5>"); + CHECK(Render("TRUE", item) == "<1>"); + CHECK(Render("FALSE", item) == "<0>"); + CHECK(Render("0", item) == "<0>"); + CHECK(Render(".0000001", item) == "<0>"); + CHECK(Render("2/3", item) == "<0.67>"); + CHECK(Render("100", item) == "<100>"); + CHECK(Render("100.5", item) == "<100.5>"); + CHECK(Render("1.1", item) == "<1.1>"); + CHECK(Render("0.05", item) == "<0.05>"); + CHECK(Render("-7/2", item) == "<-3.5>"); + CHECK(Render("-42", item) == "<-42>"); +} + +TEST_CASE("a negative formula value that rounds to zero is shown as 0" * doctest::should_fail()) { + // BUG: ReplaceBindFormula prints with "%.2f" and strips ".00", so any negative value that + // rounds to zero ("-0.00") is shown as "-0". The wiki's "Item Output" table renders values that + // round to zero (.0000001, 0, FALSE) as 0. An ordinary filter hits this: negating a stat the + // item does not have, $f(-STAT5), evaluates to -0 and is shown as "-0". + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(Render("-STAT5", item) == "<0>"); + CHECK(Render("-.0000001", item) == "<0>"); +} + +TEST_CASE("formula output is f_err for math errors and values beyond 2^31") { + // wiki: "If a math error occurs or the value is too large to be rendered, the text is f_err. + // The maximum absolute value of a renderable result is 2^31 (2147483648)." + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(Render("1/0", item) == ""); + CHECK(Render("-1/0", item) == ""); + CHECK(Render("0/0", item) == ""); + CHECK(Render("sqrt(-1)", item) == ""); + CHECK(Render("ln(0)", item) == ""); + CHECK(Render("SK999", item) == ""); + CHECK(Render("2^31", item) == "<2147483648>"); + CHECK(Render("-(2^31)", item) == "<-2147483648>"); + // 2^31 + 256 is the next float above 2^31 + CHECK(Render("2^31 + 256", item) == ""); + CHECK(Render("-(2^31) - 256", item) == ""); + CHECK(Render("2^40", item) == ""); +} + +TEST_CASE("Formula definitions that do not compile are skipped") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_STRENGTH, 5); + // the reference is left as unknown text + CHECK(Show("[%FORMULABAD%]", item, "Formula[BAD]: STAT0 +\n") == "[%FORMULABAD%]"); + CHECK(Show("[%FORMULABAD%]", item, "Formula[BAD]: nosuchvar\n") == "[%FORMULABAD%]"); + CHECK(Show("[%FORMULABAD%]", item, "Formula[BAD]: max()\n") == "[%FORMULABAD%]"); + // one broken definition does not disturb the others + CHECK(Show("%FORMULAGOOD%", item, "Formula[BAD]: (\nFormula[GOOD]: STAT0 * 2\n") == "10"); +} + +TEST_CASE("reloading the filter forgets formulas that are no longer defined") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(Show("[%FORMULAX%]", item, "Formula[X]: 7\n") == "[7]"); + CHECK(Show("[%FORMULAX%]", item) == "[%FORMULAX%]"); + support::LoadFilter("Formula[X]: 1\n"); + CHECK(support::Matches(L"FORMULAX", item)); + support::LoadFilter("Formula[X]: 0\n"); + CHECK_FALSE(support::Matches(L"FORMULAX", item)); +} + +TEST_CASE("Formula definitions cannot use aliases") { + // wiki: "Aliases cannot be used in the definition." + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_STRENGTH, 8); + CHECK(Show("[%FORMULAX%]", item, "Alias[HALFSTR]: STAT0/2\nFormula[X]: HALFSTR\n") == "[%FORMULAX%]"); +} + +TEST_CASE("inline $f() in a condition works as a boolean and as a value comparison") { + // the wiki's "Item Input" examples + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(1, 10).Stat(STAT_DEXTERITY, 11); // STAT1 + STAT2 = 21 + support::LoadFilter("ItemDisplay[$f(STAT1+STAT2>20)]: yes\n"); + CHECK(Name(item) == "yes"); + support::LoadFilter("ItemDisplay[$f(STAT1+STAT2>21)]: yes\n"); + CHECK(Name(item) == "Axe"); + support::LoadFilter("ItemDisplay[$f(STAT1+STAT2)>20]: yes\n"); + CHECK(Name(item) == "yes"); + support::LoadFilter("ItemDisplay[$f(STAT1+STAT2)>21]: yes\n"); + CHECK(Name(item) == "Axe"); + // spaces inside the island do not split the condition + support::LoadFilter("ItemDisplay[UNI $f( STAT1 + STAT2 == 21 ) hax]: yes\n"); + CHECK(Name(item) == "yes"); + support::LoadFilter("ItemDisplay[!$f(STAT1 + STAT2 == 21)]: yes\n"); + CHECK(Name(item) == "Axe"); +} + +TEST_CASE("inline $f() can be one term of an add condition") { + // wiki: ItemDisplay[$f(STAT1)+STAT2>15] is an add condition; the formula's real value is added + support::LoadFilter("ItemDisplay[$f(STAT0/2)+STAT2>15]: yes\n"); + support::TestItem over("hax", ITEM_QUALITY_UNIQUE); + over.Stat(STAT_STRENGTH, 11).Stat(STAT_DEXTERITY, 10); // 5.5 + 10 = 15.5 + support::TestItem under("hax", ITEM_QUALITY_UNIQUE); + under.Stat(STAT_STRENGTH, 11).Stat(STAT_DEXTERITY, 9); // 5.5 + 9 = 14.5 + support::TestItem exact("hax", ITEM_QUALITY_UNIQUE); + exact.Stat(STAT_STRENGTH, 10).Stat(STAT_DEXTERITY, 10); // 5 + 10 = 15, not > 15 + CHECK(Name(over) == "yes"); + CHECK(Name(under) == "Axe"); + CHECK(Name(exact) == "Axe"); +} + +TEST_CASE("a named formula can be one term of an add condition") { + support::LoadFilter("Formula[Q]: STAT0 / 4\n"); + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_STRENGTH, 10).Stat(STAT_DEXTERITY, 8); // 2.5 + 8 = 10.5 + CHECK(support::Matches(L"FORMULAQ+STAT2>10", item)); + CHECK_FALSE(support::Matches(L"FORMULAQ+STAT2>11", item)); + CHECK(support::Matches(L"STAT2+FORMULAQ<11", item)); +} + +TEST_CASE("inline $f() in an action is replaced by the formula's value") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_STRENGTH, 20).Stat(STAT_DEXTERITY, 7); + CHECK(Show("%NAME% [$f(STAT0 * 2)]", item) == "Axe [40]"); + CHECK(Show("$f(STAT0 + 1) and $f(STAT2/2)", item) == "21 and 3.5"); + // parentheses inside the island are matched + CHECK(Show("[$f(max(STAT0, STAT2) * (1 + 1))]", item) == "[40]"); + CHECK(Show("[$f((STAT0))]", item) == "[20]"); + // text right after the island stays + CHECK(Show("$f(1)$f(2)x", item) == "12x"); +} + +TEST_CASE("inline $f() that does not compile or is not closed stays as text") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(Show("[$f(1 +)]", item) == "[$f(1 +)]"); + CHECK(Show("[$f(nosuchvar)]", item) == "[$f(nosuchvar)]"); + CHECK(Show("[$f(1 + 2]", item) == "[$f(1 + 2]"); + // a broken island does not stop later islands from working + CHECK(Show("$f(max()) $f(2*3)", item) == "$f(max()) 6"); + // wiki: "The 'f' must be lowercase" + CHECK(Show("[$F(1+1)]", item) == "[$F(1+1)]"); +} + +TEST_CASE("inline $f() can use aliases") { + // wiki: aliases can be used in an inline definition + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_STRENGTH, 13); + support::LoadFilter("Alias[HALFSTR]: STAT0/2\nItemDisplay[$f(HALFSTR)>6]: [$f(%HALFSTR%)]\n"); + CHECK(Name(item) == "[6.5]"); + item.Stat(STAT_STRENGTH, 12); + ResetCaches(); // BH caches names per item until something invalidates them + CHECK(Name(item) == "Axe"); +} + +TEST_CASE("many inline formulas in one filter each keep their own value") { + std::string action; + std::string expected; + for (int i = 1; i <= 10; i++) { + action += "$f(" + std::to_string(i) + ")-"; + expected += std::to_string(i) + "-"; + } + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(Show(action, item) == expected); + + // 40 rules with two islands each: more islands than letters A-Z, so the generated + // names must not repeat + std::string filter; + for (int i = 1; i <= 40; i++) { + filter += "ItemDisplay[$f(STAT0==" + std::to_string(i) + ")]: v$f(STAT0*100+" + std::to_string(i) + ")\n"; + } + support::LoadFilter(filter); + support::TestItem a("hax", ITEM_QUALITY_UNIQUE); + a.Stat(STAT_STRENGTH, 3); + support::TestItem b("hax", ITEM_QUALITY_UNIQUE); + b.Stat(STAT_STRENGTH, 14); // 27th and 28th islands + support::TestItem c("hax", ITEM_QUALITY_UNIQUE); + c.Stat(STAT_STRENGTH, 40); + support::TestItem d("hax", ITEM_QUALITY_UNIQUE); + d.Stat(STAT_STRENGTH, 41); + CHECK(Name(a) == "v303"); + CHECK(Name(b) == "v1414"); + CHECK(Name(c) == "v4040"); + CHECK(Name(d) == "Axe"); +} + +TEST_CASE("STAT and MULTI read the item's stats, with life and mana in whole points") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_VITALITY, 12) + .Stat(STAT_MAXHP, 40 * 256) // the game stores life in 1/256ths + .Stat(STAT_CLASSSKILLS, 3, 2) // +3 necromancer skills + .Stat(STAT_CLASSSKILLS, 1, 4); // +1 barbarian skills + CHECK(Render("STAT3", item) == "<12>"); + CHECK(Render("stat 3", item) == "<12>"); + CHECK(Render("STAT7", item) == "<40>"); + CHECK(Render("LIFE", item) == "<40>"); + CHECK(Render("MULTI83,2", item) == "<3>"); + CHECK(Render("MULTI83,4", item) == "<1>"); + CHECK(Render("MULTI83,0", item) == "<0>"); + CHECK(Render("STAT5", item) == "<0>"); +} + +TEST_CASE("CHARSTAT, CLVL and DIFF read the player and the game, not the item") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_STRENGTH, 50).Stat(STAT_LEVEL, 3); + fake::SetStat(&fake::Player(), STAT_STRENGTH, 120); + fake::SetStat(&fake::Player(), STAT_LEVEL, 85); + fake::SetDifficulty(2); + CHECK(Render("CHARSTAT0", item) == "<120>"); + CHECK(Render("STAT0", item) == "<50>"); + CHECK(Render("CLVL", item) == "<85>"); + CHECK(Render("DIFF", item) == "<2>"); + fake::SetDifficulty(0); + CHECK(Render("DIFF", item) == "<0>"); + // a level-dependent condition + support::LoadFilter("ItemDisplay[$f(CLVL >= 80 + DIFF*2)]: late\n"); + fake::SetDifficulty(2); + CHECK(Name(item) == "late"); + fake::SetStat(&fake::Player(), STAT_LEVEL, 83); + ResetCaches(); + CHECK(Name(item) == "Axe"); +} + +TEST_CASE("item property variables: quality, flags and item level") { + support::TestItem uni("hax", ITEM_QUALITY_UNIQUE); + uni.ItemLevel(87).Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + support::TestItem rare("hax", ITEM_QUALITY_RARE); + rare.ItemLevel(12).Flags(0); + CHECK(Render("UNI*100 + RARE*10 + ETH*2 + ID", uni) == "<103>"); + CHECK(Render("UNI*100 + RARE*10 + ETH*2 + ID", rare) == "<10>"); + CHECK(Render("ILVL", uni) == "<87>"); + CHECK(Render("ILVL / 2", rare) == "<6>"); + CHECK(Render("TRUE + TRUE + FALSE", rare) == "<2>"); +} + +TEST_CASE("named-stat variables read the matching item stats") { + support::TestItem item("rin", ITEM_QUALITY_RARE); + item.Stat(STAT_FIRERESIST, 30) + .Stat(STAT_LIGHTNINGRESIST, 20) + .Stat(STAT_COLDRESIST, 25) + .Stat(STAT_POISONRESIST, 15) + .Stat(STAT_FASTERCAST, 10) + .Stat(STAT_MAGICFIND, 24) + .Stat(STAT_ALLSKILLS, 2) + .Stat(STAT_SOCKETS, 1); + CHECK(Render("FRES + LRES + CRES + PRES", item) == "<90>"); + // RES is all resistances: the smallest of the four + CHECK(Render("RES", item) == "<15>"); + CHECK(Render("FCR", item) == "<10>"); + CHECK(Render("MFIND", item) == "<24>"); + CHECK(Render("ALLSK", item) == "<2>"); + CHECK(Render("SOCK", item) == "<1>"); + CHECK(Render("SOCKETS", item) == "<1>"); +} + +TEST_CASE("RES is 0 unless the item has every resistance") { + support::TestItem item("rin", ITEM_QUALITY_RARE); + item.Stat(STAT_FIRERESIST, 30).Stat(STAT_LIGHTNINGRESIST, 20).Stat(STAT_COLDRESIST, 25); + CHECK(Render("RES", item) == "<0>"); +} + +TEST_CASE("skill variables read single, oskill and class skill bonuses") { + support::TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_SINGLESKILL, 3, 54) // +3 Teleport + .Stat(STAT_NONCLASSSKILL, 1, 54) // +1 Teleport (oskill) + .Stat(STAT_CLASSSKILLS, 2, 1); // +2 sorceress skills + CHECK(Render("SK54", item) == "<3>"); + CHECK(Render("OS54", item) == "<1>"); + CHECK(Render("SK53", item) == "<0>"); + CHECK(Render("CLSK1", item) == "<2>"); + CHECK(Render("CLSK6", item) == "<0>"); + // class ids run 0 (Amazon) to 6 (Assassin) + CHECK(Render("CLSK7", item) == ""); + CHECK(Render("OS999", item) == ""); +} + +} // TEST_SUITE("Formula loot filter") From 0c02a8a5ef86175623af0435543317293e54b539 Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:06 -0400 Subject: [PATCH 5/8] Test loot filter conditions --- tests/BH.Tests.vcxproj | 2 + tests/ItemConditionTests.cpp | 999 +++++++++++++++++++++++++++++++ tests/ItemStatConditionTests.cpp | 851 ++++++++++++++++++++++++++ 3 files changed, 1852 insertions(+) create mode 100644 tests/ItemConditionTests.cpp create mode 100644 tests/ItemStatConditionTests.cpp diff --git a/tests/BH.Tests.vcxproj b/tests/BH.Tests.vcxproj index e446ceaa..a71191a2 100644 --- a/tests/BH.Tests.vcxproj +++ b/tests/BH.Tests.vcxproj @@ -118,6 +118,8 @@ + + diff --git a/tests/ItemConditionTests.cpp b/tests/ItemConditionTests.cpp new file mode 100644 index 00000000..3af74de9 --- /dev/null +++ b/tests/ItemConditionTests.cpp @@ -0,0 +1,999 @@ +// Loot filter condition language: item identity/property conditions and the boolean grammar +// (Condition::BuildConditions, Condition::ProcessConditions, Rule::Convert / EvaluateTree). +// Expected values follow the PD2 item filtering wiki (https://wiki.projectdiablo2.com/wiki/Item_Filtering), +// the game's data (BH/Constants.h mirrors it) and the Arreat Summit affix level formula. +#include "doctest/doctest.h" + +#include +#include + +#include "BH.h" +#include "Constants.h" +#include "FakeEngine.h" +#include "LootFilter.h" + +using support::Matches; +using support::TestItem; + +namespace { + +std::wstring Wide(const char* s) { + return std::wstring(s, s + std::strlen(s)); +} + +// Puts `item` into `inv`, an inventory owned by `owner`, at storage `location`. +void PlaceIn(TestItem& item, Inventory& inv, UnitAny* owner, BYTE location) { + std::memset(&inv, 0, sizeof(inv)); + inv.pOwner = owner; + item.data().pOwnerInventory = &inv; + item.data().ItemLocation = location; +} + +// ItemAttributes field a group keyword reads. +enum GroupField { kBase, kWeapon, kArmor, kMisc }; + +unsigned int& Field(TestItem& item, GroupField field) { + switch (field) { + case kBase: return item.attrs().baseFlags; + case kWeapon: return item.attrs().weaponFlags; + case kArmor: return item.attrs().armorFlags; + default: return item.attrs().miscFlags; + } +} + +struct GroupKeyword { + const char* name; + GroupField field; + unsigned int flag; +}; + +// Every item group keyword the wiki lists, with the ItemTypes-derived group it stands for. +const GroupKeyword kGroups[] = { + {"NORM", kBase, ITEM_GROUP_NORMAL}, + {"EXC", kBase, ITEM_GROUP_EXCEPTIONAL}, + {"ELT", kBase, ITEM_GROUP_ELITE}, + {"CLASS", kBase, ITEM_GROUP_CLASS}, + {"WEAPON", kWeapon, ITEM_GROUP_ALLWEAPON}, + {"MACE", kWeapon, ITEM_GROUP_ALLMACE}, + {"AXE", kWeapon, ITEM_GROUP_AXE}, + {"CLUB", kWeapon, ITEM_GROUP_CLUB}, + {"TMACE", kWeapon, ITEM_GROUP_TIPPED_MACE}, + {"HAMMER", kWeapon, ITEM_GROUP_HAMMER}, + {"SWORD", kWeapon, ITEM_GROUP_SWORD}, + {"DAGGER", kWeapon, ITEM_GROUP_DAGGER}, + {"THROWING", kWeapon, ITEM_GROUP_THROWING}, + {"JAV", kWeapon, ITEM_GROUP_JAVELIN}, + {"SPEAR", kWeapon, ITEM_GROUP_SPEAR}, + {"POLEARM", kWeapon, ITEM_GROUP_POLEARM}, + {"BOW", kWeapon, ITEM_GROUP_BOW}, + {"XBOW", kWeapon, ITEM_GROUP_CROSSBOW}, + {"STAFF", kWeapon, ITEM_GROUP_STAFF}, + {"WAND", kWeapon, ITEM_GROUP_WAND}, + {"SCEPTER", kWeapon, ITEM_GROUP_SCEPTER}, + {"ZON", kWeapon, ITEM_GROUP_AMAZON_WEAPON}, + {"SIN", kWeapon, ITEM_GROUP_ASSASSIN_KATAR}, + {"SOR", kWeapon, ITEM_GROUP_SORCERESS_ORB}, + {"ARMOR", kArmor, ITEM_GROUP_ALLARMOR}, + {"HELM", kArmor, ITEM_GROUP_HELM}, + {"CHEST", kArmor, ITEM_GROUP_BODY_ARMOR}, + {"SHIELD", kArmor, ITEM_GROUP_SHIELD}, + {"GLOVES", kArmor, ITEM_GROUP_GLOVES}, + {"BOOTS", kArmor, ITEM_GROUP_BOOTS}, + {"BELT", kArmor, ITEM_GROUP_BELT}, + {"CIRC", kArmor, ITEM_GROUP_CIRCLET}, + {"BAR", kArmor, ITEM_GROUP_BARBARIAN_HELM}, + {"DRU", kArmor, ITEM_GROUP_DRUID_PELT}, + {"NEC", kArmor, ITEM_GROUP_NECROMANCER_SHIELD}, + {"DIN", kArmor, ITEM_GROUP_PALADIN_SHIELD}, + {"MISC", kMisc, ITEM_GROUP_ALLMISC}, + {"JEWELRY", kMisc, ITEM_GROUP_JEWELRY}, + {"CHARM", kMisc, ITEM_GROUP_CHARM}, + {"QUIVER", kMisc, ITEM_GROUP_QUIVER}, +}; + +} // namespace + +TEST_SUITE("ItemConditions") { + +// ---- Item codes ------------------------------------------------------------------------------ + +TEST_CASE("a lowercase 3-letter token is an item code that matches only that base") { + TestItem axe("hax", ITEM_QUALITY_NORMAL); + TestItem doubleAxe("2ax", ITEM_QUALITY_NORMAL); + TestItem rune("r01", ITEM_QUALITY_NORMAL); + + CHECK(Matches(L"hax", axe)); + CHECK_FALSE(Matches(L"axe", axe)); + CHECK_FALSE(Matches(L"hax", doubleAxe)); + // Codes may contain digits anywhere. + CHECK(Matches(L"2ax", doubleAxe)); + CHECK(Matches(L"r01", rune)); + CHECK_FALSE(Matches(L"r02", rune)); + // A 4th character is part of the code: "haxx" is a different code. + CHECK_FALSE(Matches(L"haxx", axe)); + // Codes combine with other conditions. + CHECK(Matches(L"hax NMAG", axe)); + CHECK_FALSE(Matches(L"hax MAG", axe)); +} + +TEST_CASE("keywords are case-sensitive where an item code and a keyword share letters") { + // "axe" is the Axe's item code, AXE the axe group keyword; "bar" is the Bardiche's code, BAR the + // barbarian helm keyword. + TestItem axe("axe", ITEM_QUALITY_NORMAL); + axe.attrs().weaponFlags = ITEM_GROUP_ALLWEAPON | ITEM_GROUP_AXE; + TestItem handAxe("hax", ITEM_QUALITY_NORMAL); + handAxe.attrs().weaponFlags = ITEM_GROUP_ALLWEAPON | ITEM_GROUP_AXE; + CHECK(Matches(L"axe", axe)); + CHECK_FALSE(Matches(L"axe", handAxe)); + CHECK(Matches(L"AXE", axe)); + CHECK(Matches(L"AXE", handAxe)); + + TestItem bardiche("bar", ITEM_QUALITY_NORMAL); + bardiche.attrs().weaponFlags = ITEM_GROUP_ALLWEAPON | ITEM_GROUP_POLEARM; + TestItem barbHelm("ba1", ITEM_QUALITY_NORMAL); + barbHelm.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_HELM | ITEM_GROUP_BARBARIAN_HELM; + CHECK(Matches(L"bar", bardiche)); + CHECK_FALSE(Matches(L"bar", barbHelm)); + CHECK(Matches(L"BAR", barbHelm)); + CHECK_FALSE(Matches(L"BAR", bardiche)); +} + +// ---- Quality and properties ------------------------------------------------------------------ + +TEST_CASE("each rarity keyword matches exactly its item quality") { + struct QualityKeyword { + const char* name; + DWORD quality; + }; + const QualityKeyword keywords[] = { + {"INF", ITEM_QUALITY_INFERIOR}, + {"SUP", ITEM_QUALITY_SUPERIOR}, + {"MAG", ITEM_QUALITY_MAGIC}, + {"SET", ITEM_QUALITY_SET}, + {"RARE", ITEM_QUALITY_RARE}, + {"UNI", ITEM_QUALITY_UNIQUE}, + {"CRAFT", ITEM_QUALITY_CRAFT}, + }; + for (DWORD quality = ITEM_QUALITY_INFERIOR; quality <= ITEM_QUALITY_CRAFT; quality++) { + TestItem item("lsd", quality); + CAPTURE(quality); + for (const auto& kw : keywords) { + INFO(kw.name); + CHECK(Matches(Wide(kw.name), item) == (kw.quality == quality)); + } + } +} + +TEST_CASE("NMAG matches inferior, normal and superior items but nothing magical") { + for (DWORD quality = ITEM_QUALITY_INFERIOR; quality <= ITEM_QUALITY_CRAFT; quality++) { + TestItem item("lsd", quality); + CAPTURE(quality); + CHECK(Matches(L"NMAG", item) == (quality <= ITEM_QUALITY_SUPERIOR)); + } +} + +TEST_CASE("NORM, EXC and ELT are base tiers, not rarities") { + TestItem normalQualityEliteBase("7ls", ITEM_QUALITY_NORMAL); + normalQualityEliteBase.attrs().baseFlags = ITEM_GROUP_ELITE; + CHECK_FALSE(Matches(L"NORM", normalQualityEliteBase)); + CHECK_FALSE(Matches(L"EXC", normalQualityEliteBase)); + CHECK(Matches(L"ELT", normalQualityEliteBase)); + + TestItem uniqueNormalBase("lsd", ITEM_QUALITY_UNIQUE); + uniqueNormalBase.attrs().baseFlags = ITEM_GROUP_NORMAL; + CHECK(Matches(L"NORM", uniqueNormalBase)); + CHECK(Matches(L"NORM UNI", uniqueNormalBase)); +} + +TEST_CASE("ETH, RW and ID test the item's ethereal, runeword and identified flags") { + TestItem plain("lsd", ITEM_QUALITY_NORMAL); + plain.Flags(0); + CHECK_FALSE(Matches(L"ETH", plain)); + CHECK_FALSE(Matches(L"RW", plain)); + CHECK_FALSE(Matches(L"ID", plain)); + + TestItem runeword("lsd", ITEM_QUALITY_NORMAL); + runeword.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL | ITEM_RUNEWORD); + CHECK(Matches(L"ETH", runeword)); + CHECK(Matches(L"RW", runeword)); + CHECK(Matches(L"ID", runeword)); + CHECK(Matches(L"ETH RW ID", runeword)); + CHECK_FALSE(Matches(L"!ID", runeword)); +} + +TEST_CASE("GEMMED matches items with something socketed into them") { + TestItem empty("lsd", ITEM_QUALITY_NORMAL); + CHECK_FALSE(Matches(L"GEMMED", empty)); + + Inventory sockets; + std::memset(&sockets, 0, sizeof(sockets)); + TestItem socketedNothing("lsd", ITEM_QUALITY_NORMAL); + socketedNothing.unit()->pInventory = &sockets; + CHECK_FALSE(Matches(L"GEMMED", socketedNothing)); + + Inventory filled; + std::memset(&filled, 0, sizeof(filled)); + filled.dwItemCount = 1; + TestItem gemmed("lsd", ITEM_QUALITY_NORMAL); + gemmed.unit()->pInventory = &filled; + CHECK(Matches(L"GEMMED", gemmed)); +} + +TEST_CASE("FOOLS matches only items with both max damage and attack rating per level") { + TestItem fools("lsd", ITEM_QUALITY_MAGIC); + fools.Stat(STAT_MAXDAMAGEPERLEVEL, 8).Stat(STAT_ATTACKRATINGPERLEVEL, 16); + CHECK(Matches(L"FOOLS", fools)); + + TestItem damageOnly("lsd", ITEM_QUALITY_MAGIC); + damageOnly.Stat(STAT_MAXDAMAGEPERLEVEL, 8); + CHECK_FALSE(Matches(L"FOOLS", damageOnly)); + + TestItem ratingOnly("lsd", ITEM_QUALITY_MAGIC); + ratingOnly.Stat(STAT_ATTACKRATINGPERLEVEL, 16); + CHECK_FALSE(Matches(L"FOOLS", ratingOnly)); +} + +// ---- Runes, gems, gold ----------------------------------------------------------------------- + +TEST_CASE("RUNE compares the rune number (El = 1 .. Zod = 33) and only applies to runes") { + TestItem el("r01", ITEM_QUALITY_NORMAL); + el.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_RUNE; + TestItem ber("r30", ITEM_QUALITY_NORMAL); + ber.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_RUNE; + TestItem zod("r33", ITEM_QUALITY_NORMAL); + zod.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_RUNE; + + CHECK(Matches(L"RUNE=1", el)); + CHECK_FALSE(Matches(L"RUNE>1", el)); + CHECK(Matches(L"RUNE=30", ber)); + CHECK(Matches(L"RUNE>29", ber)); + CHECK_FALSE(Matches(L"RUNE>30", ber)); + CHECK(Matches(L"RUNE<31", ber)); + CHECK(Matches(L"RUNE=33", zod)); + CHECK(Matches(L"RUNE~30-33", zod)); + CHECK_FALSE(Matches(L"RUNE~30-33", el)); + + // Not a rune (no rune item type): never satisfies a RUNE comparison. + TestItem notRune("r30", ITEM_QUALITY_NORMAL); + notRune.attrs().miscFlags = ITEM_GROUP_ALLMISC; + CHECK_FALSE(Matches(L"RUNE=30", notRune)); + CHECK_FALSE(Matches(L"RUNE<34", notRune)); +} + +TEST_CASE("GEMLEVEL/GEM compare gem quality 1-5 (Chipped..Perfect)") { + const unsigned int levels[] = {ITEM_GROUP_CHIPPED, ITEM_GROUP_FLAWED, ITEM_GROUP_REGULAR, ITEM_GROUP_FLAWLESS, + ITEM_GROUP_PERFECT}; + for (int level = 1; level <= 5; level++) { + TestItem gem("gem", ITEM_QUALITY_NORMAL); + gem.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_RUBY | levels[level - 1]; + CAPTURE(level); + CHECK(Matches(L"GEMLEVEL=" + std::to_wstring(level), gem)); + CHECK(Matches(L"GEM=" + std::to_wstring(level), gem)); + CHECK_FALSE(Matches(L"GEM>" + std::to_wstring(level), gem)); + CHECK_FALSE(Matches(L"GEM<" + std::to_wstring(level), gem)); + } + + TestItem notGem("r01", ITEM_QUALITY_NORMAL); + notGem.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_RUNE; + CHECK_FALSE(Matches(L"GEM<6", notGem)); + CHECK_FALSE(Matches(L"GEMLEVEL~1-5", notGem)); +} + +TEST_CASE("GEMTYPE compares gem type 1-7 (Amethyst, Diamond, Emerald, Ruby, Sapphire, Topaz, Skull)") { + const unsigned int types[] = {ITEM_GROUP_AMETHYST, ITEM_GROUP_DIAMOND, ITEM_GROUP_EMERALD, ITEM_GROUP_RUBY, + ITEM_GROUP_SAPPHIRE, ITEM_GROUP_TOPAZ, ITEM_GROUP_SKULL}; + for (int type = 1; type <= 7; type++) { + TestItem gem("gem", ITEM_QUALITY_NORMAL); + gem.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_PERFECT | types[type - 1]; + CAPTURE(type); + CHECK(Matches(L"GEMTYPE=" + std::to_wstring(type), gem)); + CHECK_FALSE(Matches(L"GEMTYPE=" + std::to_wstring(type % 7 + 1), gem)); + CHECK(Matches(L"GEMTYPE=" + std::to_wstring(type) + L" GEMLEVEL=5", gem)); + } + + TestItem notGem("cm1", ITEM_QUALITY_MAGIC); + notGem.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_CHARM; + CHECK_FALSE(Matches(L"GEMTYPE<8", notGem)); +} + +TEST_CASE("GOLD compares the stack size of gold piles only") { + TestItem pile("gld", ITEM_QUALITY_NORMAL); + pile.Stat(STAT_GOLD, 99); + TestItem hundred("gld", ITEM_QUALITY_NORMAL); + hundred.Stat(STAT_GOLD, 100); + + CHECK(Matches(L"GOLD<100", pile)); + CHECK_FALSE(Matches(L"GOLD<100", hundred)); + CHECK(Matches(L"GOLD=100", hundred)); + CHECK(Matches(L"GOLD>99", hundred)); + + // The wiki's "ItemDisplay[GOLD<100]: hides gold stacks if they are less than 100" must not hide + // everything else. + TestItem sword("lsd", ITEM_QUALITY_NORMAL); + CHECK_FALSE(Matches(L"GOLD<100", sword)); +} + +// ---- Item, quality and affix levels ---------------------------------------------------------- + +TEST_CASE("ILVL compares the item level with strict < and >, exact = and inclusive ~ ranges") { + TestItem item("lsd", ITEM_QUALITY_RARE); + item.ItemLevel(50); + + CHECK(Matches(L"ILVL=50", item)); + CHECK_FALSE(Matches(L"ILVL=49", item)); + CHECK(Matches(L"ILVL>49", item)); + CHECK_FALSE(Matches(L"ILVL>50", item)); + CHECK(Matches(L"ILVL<51", item)); + CHECK_FALSE(Matches(L"ILVL<50", item)); + CHECK(Matches(L"ILVL~50-60", item)); + CHECK(Matches(L"ILVL~40-50", item)); + CHECK_FALSE(Matches(L"ILVL~51-60", item)); + CHECK_FALSE(Matches(L"ILVL~40-49", item)); +} + +TEST_CASE("a value condition without a comparison operator never matches") { + TestItem item("lsd", ITEM_QUALITY_RARE); + item.ItemLevel(50); + CHECK_FALSE(Matches(L"ILVL", item)); + CHECK_FALSE(Matches(L"RARE ILVL", item)); +} + +TEST_CASE("QLVL compares the base item's quality level") { + TestItem item("xsk", ITEM_QUALITY_RARE); + item.attrs().qualityLevel = 34; + CHECK(Matches(L"QLVL=34", item)); + CHECK(Matches(L"QLVL>33", item)); + CHECK_FALSE(Matches(L"QLVL>34", item)); +} + +TEST_CASE("ALVL follows the game's affix level formula") { + // alvl = ilvl - qlvl/2 while ilvl < 99 - qlvl/2, else 2*ilvl - 99 (ilvl raised to qlvl, capped at 99); + // with a magic level, alvl = ilvl + mlvl capped at 99. + TestItem low("xsk", ITEM_QUALITY_MAGIC); + low.ItemLevel(40); + low.attrs().qualityLevel = 20; + CHECK(Matches(L"ALVL=30", low)); + CHECK_FALSE(Matches(L"ALVL=40", low)); + + TestItem belowKnee("xsk", ITEM_QUALITY_MAGIC); + belowKnee.ItemLevel(68); + belowKnee.attrs().qualityLevel = 60; + CHECK(Matches(L"ALVL=38", belowKnee)); + + TestItem aboveKnee("xsk", ITEM_QUALITY_MAGIC); + aboveKnee.ItemLevel(70); + aboveKnee.attrs().qualityLevel = 60; + CHECK(Matches(L"ALVL=41", aboveKnee)); + + TestItem high("xsk", ITEM_QUALITY_MAGIC); + high.ItemLevel(85); + high.attrs().qualityLevel = 60; + CHECK(Matches(L"ALVL=71", high)); + + // Item level below the base's quality level counts as the quality level. + TestItem underQlvl("xsk", ITEM_QUALITY_MAGIC); + underQlvl.ItemLevel(10); + underQlvl.attrs().qualityLevel = 30; + CHECK(Matches(L"ALVL=15", underQlvl)); + + TestItem wand("wnd", ITEM_QUALITY_MAGIC); + wand.ItemLevel(30); + wand.attrs().qualityLevel = 10; + wand.attrs().magicLevel = 1; + CHECK(Matches(L"ALVL=31", wand)); + + TestItem cappedWand("wnd", ITEM_QUALITY_MAGIC); + cappedWand.ItemLevel(99); + cappedWand.attrs().qualityLevel = 10; + cappedWand.attrs().magicLevel = 3; + CHECK(Matches(L"ALVL=99", cappedWand)); + + TestItem overLevel("rin", ITEM_QUALITY_MAGIC); + overLevel.ItemLevel(120); + CHECK(Matches(L"ALVL=99", overLevel)); +} + +TEST_CASE("CRAFTALVL is the affix level of a craft made by this character from the item") { + // Crafted item level = clvl/2 + ilvl/2. + fake::SetStat(&fake::Player(), STAT_LEVEL, 90); + TestItem amulet("amu", ITEM_QUALITY_MAGIC); + amulet.ItemLevel(84); + amulet.attrs().qualityLevel = 60; // craft ilvl 87 -> 2*87 - 99 + CHECK(Matches(L"CRAFTALVL=75", amulet)); + CHECK_FALSE(Matches(L"CRAFTALVL>75", amulet)); + + fake::SetStat(&fake::Player(), STAT_LEVEL, 40); + TestItem ring("rin", ITEM_QUALITY_MAGIC); + ring.ItemLevel(20); // craft ilvl 30, qlvl 0 + CHECK(Matches(L"CRAFTALVL=30", ring)); +} + +TEST_CASE("REROLLALVL is the affix level after the cube reroll recipe") { + fake::SetStat(&fake::Player(), STAT_LEVEL, 90); + + // Magic items keep their item level. + TestItem magic("xsk", ITEM_QUALITY_MAGIC); + magic.ItemLevel(50); + magic.attrs().qualityLevel = 20; + CHECK(Matches(L"REROLLALVL=40", magic)); + + // Rares reroll at 40% of the item level + 40% of the character level: 32 + 36 = 68. + TestItem rare("xsk", ITEM_QUALITY_RARE); + rare.ItemLevel(80); + rare.attrs().qualityLevel = 40; + CHECK(Matches(L"REROLLALVL=48", rare)); + + // Items that cannot be rerolled have no reroll affix level. + TestItem unique("xsk", ITEM_QUALITY_UNIQUE); + unique.ItemLevel(80); + CHECK_FALSE(Matches(L"REROLLALVL>0", unique)); + + TestItem map("t11", ITEM_QUALITY_MAGIC); + map.ItemLevel(80); + map.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_MAP; + CHECK_FALSE(Matches(L"REROLLALVL>0", map)); + + TestItem corrupted("xsk", ITEM_QUALITY_MAGIC); + corrupted.ItemLevel(50); + corrupted.Stat(STAT_CORRUPTED, 1); + CHECK_FALSE(Matches(L"REROLLALVL>0", corrupted)); +} + +// ---- Affixes ----------------------------------------------------------------------------------- + +// PREFIX/AUTOMOD values are compared after BH subtracts the affix-table offset Item.cpp computes at +// game join (PREFIX_OFFSET/AUTOMOD_OFFSET). These tests pin that subtraction with arbitrary offsets; +// they make no claim about which numbering the game's tables use. +TEST_CASE("PREFIX matches any of the item's prefixes, less the prefix table offset") { + PREFIX_OFFSET = 700; + TestItem item("cm3", ITEM_QUALITY_MAGIC); + item.data().wPrefix[1] = 700 + 279; + + CHECK(Matches(L"PREFIX=279", item)); + CHECK_FALSE(Matches(L"PREFIX=979", item)); + CHECK_FALSE(Matches(L"PREFIX=280", item)); + // The wiki: ranges use ~. + CHECK(Matches(L"PREFIX~279-353", item)); + CHECK_FALSE(Matches(L"PREFIX~280-353", item)); + // The wiki: < and > have no effect with PREFIX. + CHECK_FALSE(Matches(L"PREFIX>1", item)); + CHECK_FALSE(Matches(L"PREFIX<1000", item)); + // Several PREFIX conditions can be required at once. + item.data().wPrefix[2] = 700 + 12; + CHECK(Matches(L"PREFIX=279 PREFIX=12", item)); +} + +TEST_CASE("SUFFIX matches any of the item's suffixes") { + TestItem item("rin", ITEM_QUALITY_RARE); + item.data().wSuffix[0] = 662; + item.data().wSuffix[2] = 158; + CHECK(Matches(L"SUFFIX=662", item)); + CHECK(Matches(L"SUFFIX=158", item)); + CHECK(Matches(L"RARE SUFFIX=662 SUFFIX=158", item)); + CHECK_FALSE(Matches(L"SUFFIX=159", item)); + CHECK_FALSE(Matches(L"SUFFIX>100", item)); + CHECK(Matches(L"SUFFIX~150-160", item)); +} + +TEST_CASE("affixes of unidentified rares are unknown to PREFIX and SUFFIX") { + TestItem item("rin", ITEM_QUALITY_RARE); + item.Flags(0); + item.data().wPrefix[0] = 5; + item.data().wSuffix[0] = 7; + CHECK_FALSE(Matches(L"PREFIX=5", item)); + CHECK_FALSE(Matches(L"SUFFIX=7", item)); + item.Flags(ITEM_IDENTIFIED); + CHECK(Matches(L"PREFIX=5", item)); + CHECK(Matches(L"SUFFIX=7", item)); +} + +TEST_CASE("AUTOMOD compares the automod id, less the automod table offset") { + AUTOMOD_OFFSET = 1500; + TestItem item("ob1", ITEM_QUALITY_MAGIC); + item.data().wAutoPrefix = 1500 + 3; + CHECK(Matches(L"AUTOMOD=3", item)); + CHECK_FALSE(Matches(L"AUTOMOD=1503", item)); + CHECK(Matches(L"AUTOMOD~1-5", item)); + + // Not visible until a magic or rare item is identified. + item.Flags(0); + CHECK_FALSE(Matches(L"AUTOMOD=3", item)); +} + +// ---- Item groups ------------------------------------------------------------------------------- + +TEST_CASE("each item group keyword matches exactly its own group") { + for (const auto& group : kGroups) { + TestItem item("xxx", ITEM_QUALITY_NORMAL); + Field(item, group.field) = group.flag; + INFO(group.name); + for (const auto& keyword : kGroups) { + INFO(keyword.name); + const bool same = keyword.field == group.field && keyword.flag == group.flag; + CHECK(Matches(Wide(keyword.name), item) == same); + } + } +} + +TEST_CASE("numbered group codes are synonyms of the named ones (EQ1-7, WP1-13, CL1-7)") { + struct Synonym { + const char* numbered; + const char* named; + }; + const Synonym synonyms[] = { + {"EQ1", "HELM"}, {"EQ2", "CHEST"}, {"EQ3", "SHIELD"}, {"EQ4", "GLOVES"}, {"EQ5", "BOOTS"}, {"EQ6", "BELT"}, + {"EQ7", "CIRC"}, {"WP1", "AXE"}, {"WP2", "MACE"}, {"WP3", "SWORD"}, {"WP4", "DAGGER"}, {"WP5", "THROWING"}, + {"WP6", "JAV"}, {"WP7", "SPEAR"}, {"WP8", "POLEARM"}, {"WP9", "BOW"}, {"WP10", "XBOW"}, {"WP11", "STAFF"}, + {"WP12", "WAND"}, {"WP13", "SCEPTER"}, {"CL1", "DRU"}, {"CL2", "BAR"}, {"CL3", "DIN"}, {"CL4", "NEC"}, + {"CL5", "SIN"}, {"CL6", "SOR"}, {"CL7", "ZON"}, + }; + for (const auto& synonym : synonyms) { + INFO(synonym.numbered); + const GroupKeyword* group = nullptr; + for (const auto& g : kGroups) { + if (std::strcmp(g.name, synonym.named) == 0) { + group = &g; + } + } + REQUIRE(group != nullptr); + TestItem member("xxx", ITEM_QUALITY_NORMAL); + Field(member, group->field) = group->flag; + TestItem other("yyy", ITEM_QUALITY_NORMAL); + Field(other, group->field) = ~group->flag; + CHECK(Matches(Wide(synonym.numbered), member)); + CHECK_FALSE(Matches(Wide(synonym.numbered), other)); + } +} + +TEST_CASE("group keywords read weapon, armor and misc groups separately even where bits coincide") { + // Throwing axe as Item.cpp classifies it: all weapons + axes + throwing weapons, normal tier. + TestItem throwingAxe("tax", ITEM_QUALITY_NORMAL); + throwingAxe.attrs().baseFlags = ITEM_GROUP_NORMAL; + throwingAxe.attrs().weaponFlags = ITEM_GROUP_ALLWEAPON | ITEM_GROUP_AXE | ITEM_GROUP_THROWING; + CHECK(Matches(L"WEAPON AXE THROWING NORM", throwingAxe)); + CHECK_FALSE(Matches(L"JAV", throwingAxe)); + CHECK_FALSE(Matches(L"DAGGER", throwingAxe)); + CHECK_FALSE(Matches(L"ARMOR", throwingAxe)); + CHECK_FALSE(Matches(L"MISC", throwingAxe)); + + // Club: a mace subtype. + TestItem club("clb", ITEM_QUALITY_NORMAL); + club.attrs().weaponFlags = ITEM_GROUP_ALLWEAPON | ITEM_GROUP_ALLMACE | ITEM_GROUP_CLUB; + CHECK(Matches(L"MACE CLUB", club)); + CHECK_FALSE(Matches(L"TMACE", club)); + CHECK_FALSE(Matches(L"HAMMER", club)); + // HELM shares its bit with MACE in the armor flags; a mace is not a helm. + CHECK_FALSE(Matches(L"HELM", club)); + + // Paladin shield: a shield and a class item. + TestItem paladinShield("pa1", ITEM_QUALITY_NORMAL); + paladinShield.attrs().baseFlags = ITEM_GROUP_NORMAL | ITEM_GROUP_CLASS; + paladinShield.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_SHIELD | ITEM_GROUP_PALADIN_SHIELD; + CHECK(Matches(L"ARMOR SHIELD DIN CLASS", paladinShield)); + CHECK_FALSE(Matches(L"NEC", paladinShield)); + CHECK_FALSE(Matches(L"WEAPON", paladinShield)); + CHECK_FALSE(Matches(L"HELM", paladinShield)); + + // Grand charm: misc only. + TestItem charm("cm3", ITEM_QUALITY_MAGIC); + charm.attrs().baseFlags = ITEM_GROUP_NORMAL; + charm.attrs().miscFlags = ITEM_GROUP_ALLMISC | ITEM_GROUP_CHARM; + CHECK(Matches(L"MISC CHARM", charm)); + CHECK_FALSE(Matches(L"JEWELRY", charm)); + CHECK_FALSE(Matches(L"WEAPON", charm)); + CHECK_FALSE(Matches(L"ARMOR", charm)); +} + +TEST_CASE("1H and 2H follow the weapon's handedness in the game") { + // "ssp" is the Short Spear, a javelin; "sst" is the Short Staff. + const char* oneHanded[] = {"hax", "wnd", "clb", "ssd", "tkf", "jav", "ssp", "ktr", "ob1", "am5"}; + const char* twoHanded[] = {"lax", "sst", "spr", "sbw", "lbb", "am1"}; + for (const char* code : oneHanded) { + TestItem weapon(code, ITEM_QUALITY_NORMAL); + INFO(code); + CHECK(Matches(L"1H", weapon)); + CHECK_FALSE(Matches(L"2H", weapon)); + } + for (const char* code : twoHanded) { + TestItem weapon(code, ITEM_QUALITY_NORMAL); + INFO(code); + CHECK(Matches(L"2H", weapon)); + CHECK_FALSE(Matches(L"1H", weapon)); + } + TestItem helm("cap", ITEM_QUALITY_NORMAL); + CHECK_FALSE(Matches(L"1H", helm)); + CHECK_FALSE(Matches(L"2H", helm)); +} + +TEST_CASE("WIDTH, HEIGHT and AREA compare the inventory size") { + TestItem item("lsd", ITEM_QUALITY_NORMAL); + item.attrs().width = 2; + item.attrs().height = 3; + CHECK(Matches(L"WIDTH=2", item)); + CHECK_FALSE(Matches(L"WIDTH=3", item)); + CHECK(Matches(L"HEIGHT=3", item)); + CHECK(Matches(L"AREA=6", item)); + CHECK(Matches(L"AREA>5", item)); + CHECK_FALSE(Matches(L"AREA>6", item)); +} + +TEST_CASE("MAPTIER compares the map tier (0 PvP, 1-5)") { + TestItem t1("t11", ITEM_QUALITY_MAGIC); + t1.attrs().category = ITEM_TYPE_T1_MAP; + TestItem t3("t31", ITEM_QUALITY_RARE); + t3.attrs().category = ITEM_TYPE_T3_MAP; + TestItem pvp("pv1", ITEM_QUALITY_NORMAL); + pvp.attrs().category = ITEM_TYPE_PVP_MAP_DESERT; + + CHECK(Matches(L"MAPTIER=1", t1)); + CHECK(Matches(L"MAPTIER<2", t1)); + CHECK(Matches(L"MAPTIER=3", t3)); + CHECK(Matches(L"MAPTIER>2", t3)); + CHECK_FALSE(Matches(L"MAPTIER>3", t3)); + CHECK(Matches(L"MAPTIER=0", pvp)); + CHECK_FALSE(Matches(L"MAPTIER>0", pvp)); +} + +// BUG: the wiki defines MAPTIER as "map tier, 0 - 5 (PvP, T1, T2, T3, Dungeon, Unique)"; an item that is +// not a map has no tier. MapTierCondition gives it -1 and compares that, so MAPTIER0", sword)); + CHECK_FALSE(Matches(L"MAPTIER<2", sword)); +} + +// ---- Character and game state ------------------------------------------------------------------ + +TEST_CASE("class keywords match the player's character class") { + const char* classes[] = {"AMAZON", "SORCERESS", "NECROMANCER", "PALADIN", "BARBARIAN", "DRUID", "ASSASSIN"}; + TestItem item("lsd", ITEM_QUALITY_NORMAL); + for (DWORD playerClass = 0; playerClass < 7; playerClass++) { + fake::Player().dwTxtFileNo = playerClass; + CAPTURE(playerClass); + for (DWORD keyword = 0; keyword < 7; keyword++) { + INFO(classes[keyword]); + CHECK(Matches(Wide(classes[keyword]), item) == (keyword == playerClass)); + } + } +} + +TEST_CASE("CLVL compares the character's level") { + TestItem item("lsd", ITEM_QUALITY_NORMAL); + fake::SetStat(&fake::Player(), STAT_LEVEL, 10); + CHECK_FALSE(Matches(L"CLVL>10", item)); + CHECK(Matches(L"CLVL=10", item)); + fake::SetStat(&fake::Player(), STAT_LEVEL, 11); + CHECK(Matches(L"CLVL>10", item)); + CHECK(Matches(L"CLVL~11-99", item)); +} + +TEST_CASE("DIFF compares the difficulty (0 Normal, 1 Nightmare, 2 Hell)") { + TestItem item("lsd", ITEM_QUALITY_NORMAL); + CHECK(Matches(L"DIFF=0", item)); + CHECK_FALSE(Matches(L"DIFF>0", item)); + fake::SetDifficulty(2); + CHECK(Matches(L"DIFF=2", item)); + CHECK(Matches(L"DIFF>1", item)); + CHECK_FALSE(Matches(L"DIFF<2", item)); +} + +TEST_CASE("FILTLVL compares the selected filter strictness level") { + TestItem item("yps", ITEM_QUALITY_NORMAL); + App.lootfilter.filterLevel.uValue = 1; + CHECK_FALSE(Matches(L"yps FILTLVL>1", item)); + App.lootfilter.filterLevel.uValue = 2; + CHECK(Matches(L"yps FILTLVL>1", item)); + App.lootfilter.filterLevel.uValue = 0; + CHECK(Matches(L"FILTLVL=0", item)); + CHECK_FALSE(Matches(L"FILTLVL>0", item)); +} + +TEST_CASE("MAPID compares the zone the character is in, and is false outside any zone") { + TestItem item("lsd", ITEM_QUALITY_NORMAL); + fake::SetAreaId(1); // Rogue Encampment + CHECK(Matches(L"MAPID=1", item)); + CHECK_FALSE(Matches(L"MAPID>1", item)); + fake::SetAreaId(132); // Worldstone Chamber + CHECK(Matches(L"MAPID~128-132", item)); + fake::SetAreaId(0); + CHECK_FALSE(Matches(L"MAPID<5", item)); + CHECK_FALSE(Matches(L"MAPID=0", item)); +} + +TEST_CASE("PRICE and SELLPRICE compare the vendor sell value, BUYPRICE the buy value") { + TestItem item("lsd", ITEM_QUALITY_RARE); + fake::SetPrice(item.unit(), TRANSACTIONTYPE_SELL, 1000); + fake::SetPrice(item.unit(), TRANSACTIONTYPE_BUY, 5000); + CHECK(Matches(L"PRICE=1000", item)); + CHECK(Matches(L"PRICE>999", item)); + CHECK_FALSE(Matches(L"PRICE>1000", item)); + CHECK_FALSE(Matches(L"PRICE=5000", item)); + CHECK(Matches(L"SELLPRICE=1000", item)); + CHECK_FALSE(Matches(L"SELLPRICE=5000", item)); + CHECK(Matches(L"BUYPRICE=5000", item)); + CHECK_FALSE(Matches(L"BUYPRICE=1000", item)); +} + +// ---- Item location ----------------------------------------------------------------------------- + +TEST_CASE("EQUIPPED matches items worn by the character only") { + Inventory inv; + TestItem worn("cap", ITEM_QUALITY_NORMAL); + PlaceIn(worn, inv, &fake::Player(), STORAGE_NULL); + worn.data().BodyLocation = 1; // head + worn.unit()->dwMode = ITEM_MODE_EQUIPPED; + CHECK(Matches(L"EQUIPPED", worn)); + CHECK_FALSE(Matches(L"INVENTORY", worn)); + CHECK_FALSE(Matches(L"STASH", worn)); + CHECK_FALSE(Matches(L"GROUND", worn)); + // No mercenary: nothing is merc-equipped. + CHECK_FALSE(Matches(L"MERC", worn)); + + UnitAny otherPlayer; + std::memset(&otherPlayer, 0, sizeof(otherPlayer)); + otherPlayer.dwType = UNIT_PLAYER; + Inventory otherInv; + TestItem theirs("cap", ITEM_QUALITY_NORMAL); + PlaceIn(theirs, otherInv, &otherPlayer, STORAGE_NULL); + theirs.data().BodyLocation = 1; + theirs.unit()->dwMode = ITEM_MODE_EQUIPPED; + CHECK_FALSE(Matches(L"EQUIPPED", theirs)); +} + +TEST_CASE("INVENTORY, STASH and CUBE match the character's storage the item is in") { + Inventory inv1, inv2, inv3, inv4; + TestItem inInventory("rin", ITEM_QUALITY_MAGIC); + PlaceIn(inInventory, inv1, &fake::Player(), STORAGE_INVENTORY); + CHECK(Matches(L"INVENTORY", inInventory)); + CHECK_FALSE(Matches(L"STASH", inInventory)); + CHECK_FALSE(Matches(L"CUBE", inInventory)); + CHECK_FALSE(Matches(L"EQUIPPED", inInventory)); + + TestItem inStash("rin", ITEM_QUALITY_MAGIC); + PlaceIn(inStash, inv2, &fake::Player(), STORAGE_STASH); + CHECK(Matches(L"STASH", inStash)); + CHECK_FALSE(Matches(L"INVENTORY", inStash)); + CHECK_FALSE(Matches(L"CUBE", inStash)); + + TestItem inCube("rin", ITEM_QUALITY_MAGIC); + PlaceIn(inCube, inv3, &fake::Player(), STORAGE_CUBE); + CHECK(Matches(L"CUBE", inCube)); + CHECK_FALSE(Matches(L"INVENTORY", inCube)); + CHECK_FALSE(Matches(L"STASH", inCube)); + + TestItem inBelt("hp5", ITEM_QUALITY_NORMAL); + PlaceIn(inBelt, inv4, &fake::Player(), STORAGE_BELT); + inBelt.unit()->dwMode = ITEM_MODE_IN_BELT; + CHECK_FALSE(Matches(L"INVENTORY", inBelt)); +} + +TEST_CASE("GROUND matches items lying on or being dropped to the ground") { + TestItem lying("rin", ITEM_QUALITY_MAGIC); + lying.unit()->dwMode = ITEM_MODE_ON_GROUND; + CHECK(Matches(L"GROUND", lying)); + CHECK_FALSE(Matches(L"INVENTORY", lying)); + + TestItem dropping("rin", ITEM_QUALITY_MAGIC); + dropping.unit()->dwMode = ITEM_MODE_BEING_DROPPED; + CHECK(Matches(L"GROUND", dropping)); + + Inventory inv; + TestItem carried("rin", ITEM_QUALITY_MAGIC); + PlaceIn(carried, inv, &fake::Player(), STORAGE_INVENTORY); + carried.unit()->dwMode = ITEM_MODE_INV_STASH_CUBE_STORE; + CHECK_FALSE(Matches(L"GROUND", carried)); +} + +TEST_CASE("SHOP matches items owned by a merchant") { + UnitAny akara; + std::memset(&akara, 0, sizeof(akara)); + akara.dwType = UNIT_MONSTER; + akara.dwTxtFileNo = NPCID_Akara; + Inventory shopInv; + TestItem forSale("rin", ITEM_QUALITY_MAGIC); + PlaceIn(forSale, shopInv, &akara, STORAGE_INVENTORY); + CHECK(Matches(L"SHOP", forSale)); + + // Kashya hires out mercenaries; she sells nothing. + UnitAny kashya; + std::memset(&kashya, 0, sizeof(kashya)); + kashya.dwType = UNIT_MONSTER; + kashya.dwTxtFileNo = NPCID_Kashya; + Inventory npcInv; + TestItem npcItem("rin", ITEM_QUALITY_MAGIC); + PlaceIn(npcItem, npcInv, &kashya, STORAGE_INVENTORY); + CHECK_FALSE(Matches(L"SHOP", npcItem)); + + Inventory mine; + TestItem carried("rin", ITEM_QUALITY_MAGIC); + PlaceIn(carried, mine, &fake::Player(), STORAGE_INVENTORY); + CHECK_FALSE(Matches(L"SHOP", carried)); + + TestItem lying("rin", ITEM_QUALITY_MAGIC); + lying.unit()->dwMode = ITEM_MODE_ON_GROUND; + CHECK_FALSE(Matches(L"SHOP", lying)); +} + +// ---- Boolean grammar --------------------------------------------------------------------------- + +TEST_CASE("a rule without conditions matches every item") { + TestItem item("lsd", ITEM_QUALITY_NORMAL); + CHECK(Matches(L"", item)); +} + +TEST_CASE("TRUE and FALSE are constant conditions") { + TestItem item("lsd", ITEM_QUALITY_NORMAL); + CHECK(Matches(L"TRUE", item)); + CHECK_FALSE(Matches(L"FALSE", item)); + CHECK_FALSE(Matches(L"TRUE FALSE", item)); + CHECK(Matches(L"FALSE OR TRUE", item)); +} + +TEST_CASE("adjacent conditions are implicitly ANDed") { + TestItem ethUnique("lsd", ITEM_QUALITY_UNIQUE); + ethUnique.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + TestItem unique("lsd", ITEM_QUALITY_UNIQUE); + + CHECK(Matches(L"UNI ETH", ethUnique)); + CHECK_FALSE(Matches(L"UNI ETH", unique)); + CHECK(Matches(L"UNI AND ETH", ethUnique)); + CHECK_FALSE(Matches(L"UNI AND ETH", unique)); + CHECK(Matches(L"UNI && ETH", ethUnique)); + CHECK_FALSE(Matches(L"UNI && ETH", unique)); +} + +TEST_CASE("OR and || match when either side does") { + TestItem unique("lsd", ITEM_QUALITY_UNIQUE); + TestItem set("lsd", ITEM_QUALITY_SET); + TestItem rare("lsd", ITEM_QUALITY_RARE); + CHECK(Matches(L"UNI OR SET", unique)); + CHECK(Matches(L"UNI OR SET", set)); + CHECK_FALSE(Matches(L"UNI OR SET", rare)); + CHECK(Matches(L"UNI || SET", set)); + CHECK_FALSE(Matches(L"UNI || SET", rare)); +} + +TEST_CASE("! negates the condition or group right after it") { + TestItem ethRare("lsd", ITEM_QUALITY_RARE); + ethRare.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + TestItem ethUnique("lsd", ITEM_QUALITY_UNIQUE); + ethUnique.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + TestItem rare("lsd", ITEM_QUALITY_RARE); + + CHECK(Matches(L"!UNI", rare)); + CHECK_FALSE(Matches(L"!UNI", ethUnique)); + // Negation binds to the next operand only. + CHECK(Matches(L"!UNI ETH", ethRare)); + CHECK_FALSE(Matches(L"!UNI ETH", ethUnique)); + CHECK_FALSE(Matches(L"!UNI ETH", rare)); + CHECK(Matches(L"ETH !UNI", ethRare)); + CHECK_FALSE(Matches(L"ETH !UNI", ethUnique)); + CHECK(Matches(L"!UNI OR ETH", ethUnique)); + CHECK_FALSE(Matches(L"!ETH OR UNI", ethRare)); + // Groups. + CHECK(Matches(L"!(UNI OR SET)", rare)); + CHECK_FALSE(Matches(L"!(UNI OR SET)", ethUnique)); + // Double negation, and "!" as its own token. + CHECK(Matches(L"!!ETH", ethRare)); + CHECK_FALSE(Matches(L"!!ETH", rare)); + CHECK(Matches(L"! ETH", rare)); + CHECK_FALSE(Matches(L"! ETH", ethRare)); +} + +TEST_CASE("parentheses group conditions") { + TestItem ethSet("lsd", ITEM_QUALITY_SET); + ethSet.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + TestItem set("lsd", ITEM_QUALITY_SET); + TestItem ethRare("lsd", ITEM_QUALITY_RARE); + ethRare.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + + CHECK(Matches(L"(UNI OR SET) ETH", ethSet)); + CHECK_FALSE(Matches(L"(UNI OR SET) ETH", set)); + CHECK_FALSE(Matches(L"(UNI OR SET) ETH", ethRare)); + CHECK(Matches(L"ETH (UNI OR SET)", ethSet)); + CHECK_FALSE(Matches(L"ETH (UNI OR SET)", ethRare)); + // Separate paren tokens, redundant nesting. + CHECK(Matches(L"( UNI OR SET ) ETH", ethSet)); + CHECK(Matches(L"((SET))", set)); + CHECK(Matches(L"ETH ( RARE OR ( SET ETH ) )", ethSet)); + CHECK_FALSE(Matches(L"ETH ( RARE OR ( SET !ETH ) )", ethSet)); +} + +TEST_CASE("AND and OR bind equally and group left to right") { + // The wiki defines no precedence. ProcessConditions gives AND and OR equal precedence, grouping + // left to right ("A OR B C" is "(A OR B) AND C"); this protects that de-facto behaviour, which + // existing unparenthesised filters depend on. + TestItem unique("lsd", ITEM_QUALITY_UNIQUE); + TestItem ethUnique("lsd", ITEM_QUALITY_UNIQUE); + ethUnique.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + TestItem ethSet("lsd", ITEM_QUALITY_SET); + ethSet.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + TestItem set("lsd", ITEM_QUALITY_SET); + + CHECK_FALSE(Matches(L"UNI OR SET ETH", unique)); + CHECK(Matches(L"UNI OR SET ETH", ethUnique)); + CHECK(Matches(L"UNI OR SET AND ETH", ethSet)); + // An explicit AND has the same precedence as the implicit one. + CHECK_FALSE(Matches(L"UNI OR SET AND ETH", unique)); + // (ETH AND UNI) OR SET + CHECK(Matches(L"ETH UNI OR SET", set)); + CHECK_FALSE(Matches(L"ETH UNI OR SET", unique)); +} + +TEST_CASE("the wiki's gold example: (GOLD<100 OR (GOLD<1000 CLVL>50))") { + const std::wstring rule = L"(GOLD<100 OR (GOLD<1000 CLVL>50))"; + TestItem tiny("gld", ITEM_QUALITY_NORMAL); + tiny.Stat(STAT_GOLD, 50); + TestItem medium("gld", ITEM_QUALITY_NORMAL); + medium.Stat(STAT_GOLD, 500); + TestItem large("gld", ITEM_QUALITY_NORMAL); + large.Stat(STAT_GOLD, 1500); + + fake::SetStat(&fake::Player(), STAT_LEVEL, 40); + CHECK(Matches(rule, tiny)); + CHECK_FALSE(Matches(rule, medium)); + CHECK_FALSE(Matches(rule, large)); + fake::SetStat(&fake::Player(), STAT_LEVEL, 51); + CHECK(Matches(rule, tiny)); + CHECK(Matches(rule, medium)); + CHECK_FALSE(Matches(rule, large)); +} + +TEST_CASE("the wiki's helm example: MAG !ID HELM !(BAR OR DRU OR ELT)") { + const std::wstring rule = L"MAG !ID HELM !(BAR OR DRU OR ELT)"; + TestItem helm("cap", ITEM_QUALITY_MAGIC); + helm.Flags(0); + helm.attrs().baseFlags = ITEM_GROUP_NORMAL; + helm.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_HELM; + CHECK(Matches(rule, helm)); + + TestItem barbHelm("ba1", ITEM_QUALITY_MAGIC); + barbHelm.Flags(0); + barbHelm.attrs().baseFlags = ITEM_GROUP_NORMAL | ITEM_GROUP_CLASS; + barbHelm.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_HELM | ITEM_GROUP_BARBARIAN_HELM; + CHECK_FALSE(Matches(rule, barbHelm)); + + TestItem eliteHelm("uap", ITEM_QUALITY_MAGIC); + eliteHelm.Flags(0); + eliteHelm.attrs().baseFlags = ITEM_GROUP_ELITE; + eliteHelm.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_HELM; + CHECK_FALSE(Matches(rule, eliteHelm)); + + TestItem identified("cap", ITEM_QUALITY_MAGIC); + identified.attrs().baseFlags = ITEM_GROUP_NORMAL; + identified.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_HELM; + CHECK_FALSE(Matches(rule, identified)); +} + +TEST_CASE("a rule with a dangling operator never matches") { + TestItem unique("lsd", ITEM_QUALITY_UNIQUE); + CHECK_FALSE(Matches(L"UNI OR", unique)); + CHECK_FALSE(Matches(L"OR UNI", unique)); + CHECK_FALSE(Matches(L"UNI AND", unique)); + CHECK_FALSE(Matches(L"UNI AND AND UNI", unique)); + CHECK_FALSE(Matches(L"UNI OR OR UNI", unique)); + CHECK_FALSE(Matches(L"UNI !", unique)); + CHECK_FALSE(Matches(L"!", unique)); +} + +TEST_CASE("an unknown upper-case keyword does not stop the other conditions being evaluated") { + TestItem rare("lsd", ITEM_QUALITY_RARE); + CHECK_FALSE(Matches(L"SET NOTAKEYWORD", rare)); + CHECK_FALSE(Matches(L"NOTAKEYWORD SET", rare)); +} + +// BUG: an unmatched ")" makes ProcessConditions stop and return the conditions read so far, so the +// valid conditions after it are silently dropped: "ETH ) UNI" acts as plain "ETH". The wiki says +// conditions written one after another are all required (implicit AND), and BH already rejects other +// malformed expressions (a dangling AND/OR: the rule never matches). Whether the stray paren makes the +// rule never match or is ignored ("ETH UNI"), an ethereal rare must not match. +TEST_CASE("an unmatched closing parenthesis does not drop the rest of the rule" * doctest::should_fail()) { + TestItem ethRare("lsd", ITEM_QUALITY_RARE); + ethRare.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + CHECK_FALSE(Matches(L"ETH ) UNI", ethRare)); + CHECK_FALSE(Matches(L"ETH) UNI", ethRare)); +} + +} // TEST_SUITE diff --git a/tests/ItemStatConditionTests.cpp b/tests/ItemStatConditionTests.cpp new file mode 100644 index 00000000..701f58f6 --- /dev/null +++ b/tests/ItemStatConditionTests.cpp @@ -0,0 +1,851 @@ +#include "doctest/doctest.h" + +#include +#include + +#include "Constants.h" +#include "FakeEngine.h" +#include "LootFilter.h" + +// Stat-based loot filter conditions (ItemDisplay.cpp): STAT/CHARSTAT/MULTI, the named stat codes, +// skills, resists, ED/EDEF/EDAM, durability, requirements, upgrades, base damage/block, max +// sockets, MINDMG/MAXDMG and "+" sums. Stat ids come from Constants.h (the game's +// ItemStatCost.txt order); rules and expected values from the PD2 filter documentation +// (https://wiki.projectdiablo2.com/wiki/Item_Filtering) and Diablo II's item rules. + +using support::Matches; +using support::TestItem; + +namespace { + +// A 4-character item code as the game stores it in Weapons/Armor/Misc.txt (space padded). +DWORD Code(const char* code) { + char buf[4] = { ' ', ' ', ' ', ' ' }; + std::memcpy(buf, code, (std::min)(std::strlen(code), sizeof(buf))); + DWORD value; + std::memcpy(&value, buf, sizeof(value)); + return value; +} + +std::wstring Widen(const char* s) { + return std::wstring(s, s + std::strlen(s)); +} + +// Txt file numbers for upgrade records, far away from the ones TestItem hands out. +const DWORD kExceptionalTxt = 60001; +const DWORD kEliteTxt = 60002; + +// The Hand Axe line from Weapons.txt: hax (normal) -> 9ha Hatchet -> 7ha Tomahawk. +void LinkHandAxeCodes(ItemsTxt& txt) { + txt.dwnormcode = Code("hax"); + txt.dwubercode = Code("9ha"); + txt.dwultracode = Code("7ha"); +} + +void AddHandAxeUpgrades() { + ItemsTxt& hatchet = fake::AddItemTxt(kExceptionalTxt, "9ha"); + LinkHandAxeCodes(hatchet); + hatchet.wreqstr = 25; + hatchet.wreqdex = 25; + hatchet.blevelreq = 19; + ItemsTxt& tomahawk = fake::AddItemTxt(kEliteTxt, "7ha"); + LinkHandAxeCodes(tomahawk); + tomahawk.wreqstr = 125; + tomahawk.wreqdex = 67; + tomahawk.blevelreq = 40; +} + +} // namespace + +TEST_SUITE("ItemStatConditions") { + +// ---- STAT / MULTI / comparison operators --------------------------------------------------- + +TEST_CASE("STAT compares the item's stat with =, <, > and ~ at exact thresholds") { + TestItem ring("rin", ITEM_QUALITY_RARE); + ring.Stat(STAT_FASTERCAST, 10); + + CHECK(Matches(L"STAT105=10", ring)); + CHECK_FALSE(Matches(L"STAT105=9", ring)); + CHECK_FALSE(Matches(L"STAT105=11", ring)); + + CHECK(Matches(L"STAT105>9", ring)); + CHECK_FALSE(Matches(L"STAT105>10", ring)); + CHECK(Matches(L"STAT105<11", ring)); + CHECK_FALSE(Matches(L"STAT105<10", ring)); + + // BETWEEN is inclusive at both ends. + CHECK(Matches(L"STAT105~10-20", ring)); + CHECK(Matches(L"STAT105~5-10", ring)); + CHECK_FALSE(Matches(L"STAT105~11-20", ring)); + CHECK_FALSE(Matches(L"STAT105~1-9", ring)); +} + +TEST_CASE("STAT on a stat the item lacks compares as zero") { + TestItem ring("rin", ITEM_QUALITY_MAGIC); + CHECK(Matches(L"STAT80=0", ring)); + CHECK_FALSE(Matches(L"STAT80>0", ring)); +} + +TEST_CASE("STAT accepts negative thresholds for negative stats") { + // "Requirements -20%" is stat 91 with value -20. + TestItem armor("xtp", ITEM_QUALITY_UNIQUE); + armor.Stat(STAT_REDUCEDREQUIREMENTS, -20); + CHECK(Matches(L"STAT91=-20", armor)); + CHECK(Matches(L"STAT91<-10", armor)); + CHECK_FALSE(Matches(L"STAT91<-20", armor)); + CHECK_FALSE(Matches(L"STAT91>0", armor)); +} + +// How the id-bound tests observe whether a condition was evaluated, without asserting what a +// rejected condition does to the rest of the rule: the item is unique and carries the stat value +// the condition asks for, so "SET OR " matches only if was evaluated, and +// "UNI " fails only if it was evaluated. + +TEST_CASE("STAT reads ids inside the ItemStatCost table and not ids past it") { + // STAT_MAX is the number of ItemStatCost.txt rows (Item.cpp), so valid ids are 0..STAT_MAX-1. + TestItem charm("cm3", ITEM_QUALITY_UNIQUE); + charm.Stat(358, 7); + charm.Stat(360, 7); + charm.Stat(361, 7); + + STAT_MAX = 359; + CHECK(Matches(L"SET OR STAT358=7", charm)); + CHECK_FALSE(Matches(L"UNI STAT358=8", charm)); + CHECK_FALSE(Matches(L"SET OR STAT360=7", charm)); + + // STAT_MAX follows the game's table at runtime (PD2 has more rows than 1.13c). + STAT_MAX = 362; + CHECK(Matches(L"SET OR STAT361=7", charm)); + CHECK_FALSE(Matches(L"UNI STAT361=8", charm)); +} + +TEST_CASE("MULTI, reads one layer of a multi-layered stat") { + // Wiki examples: MULTI107,20=3 (Thunderstroke, +3 Lightning Bolt), MULTI83,2=2 (Hellfire Torch). + TestItem jav("7tw", ITEM_QUALITY_UNIQUE); + jav.Stat(STAT_SINGLESKILL, 3, 20); + jav.Stat(STAT_SINGLESKILL, 1, 21); + + CHECK(Matches(L"MULTI107,20=3", jav)); + CHECK(Matches(L"MULTI107,21=1", jav)); + CHECK_FALSE(Matches(L"MULTI107,20>3", jav)); + CHECK(Matches(L"MULTI107,22=0", jav)); + // The layer is part of the identity: layer 0 is a different stat entry. + CHECK_FALSE(Matches(L"MULTI107,0>0", jav)); +} + +TEST_CASE("a MULTI stat id too large for an int does not abort loading the filter" + * doctest::should_fail()) { + // BUG: COND_MULTI accepts up to 10 digits per number ([0-9]{1,10}) and converts them with + // std::stoi, which throws std::out_of_range for 9999999999. Nothing catches it, so one typo + // aborts Condition::BuildConditions and ItemDisplay::InitializeItemRules, i.e. the whole filter + // load, while every other malformed number is simply rejected by the parser. The valid rule + // comes first and the bad one cannot match a ring (SET), so the expectation holds however the + // bad condition ends up being rejected. + TestItem ring("rin", ITEM_QUALITY_RARE); + support::LoadFilter( + "ItemDisplay[rin]: Found %NAME%\n" + "ItemDisplay[SET MULTI9999999999,0=1]: Bad %NAME%\n"); + CHECK(support::NameOf(ring, L"Ring") == L"Found Ring"); +} + +// ---- Named stat codes ------------------------------------------------------------------------ + +TEST_CASE("each named stat code reads its own stat and no other") { + struct Named { + const char* code; + int stat; + int raw; // the value as stored on the item for a displayed value of 17 + }; + // Stat ids: Constants.h / ItemStatCost.txt. Life and mana are stored in 1/256 units. + const Named named[] = { + { "FRES", STAT_FIRERESIST, 17 }, + { "CRES", STAT_COLDRESIST, 17 }, + { "LRES", STAT_LIGHTNINGRESIST, 17 }, + { "PRES", STAT_POISONRESIST, 17 }, + { "IAS", STAT_IAS, 17 }, + { "FCR", STAT_FASTERCAST, 17 }, + { "FHR", STAT_FASTERHITRECOVERY, 17 }, + { "FBR", STAT_FASTERBLOCK, 17 }, + { "FRW", STAT_FASTERRUNWALK, 17 }, + { "MFIND", STAT_MAGICFIND, 17 }, + { "GFIND", STAT_GOLDFIND, 17 }, + { "STR", STAT_STRENGTH, 17 }, + { "DEX", STAT_DEXTERITY, 17 }, + { "AR", STAT_ATTACKRATING, 17 }, + { "ARPER", STAT_TOHITPERCENT, 17 }, + { "DTM", STAT_DAMAGETOMANA, 17 }, + { "MAEK", STAT_MANAAFTEREACHKILL, 17 }, + { "REPLIFE", STAT_REPLENISHLIFE, 17 }, + { "REPQUANT", STAT_REPLENISHESQUANTITY, 17 }, + { "REPAIR", STAT_REPAIRSDURABILITY, 17 }, + { "QTY", STAT_AMMOQUANTITY, 17 }, + { "DEF", STAT_DEFENSE, 17 }, + { "EDEF", STAT_ENHANCEDDEFENSE, 17 }, + { "EDAM", STAT_ENHANCEDMAXIMUMDAMAGE, 17 }, + { "ALLSK", STAT_ALLSKILLS, 17 }, + { "LIFE", STAT_MAXHP, 17 * 256 }, + { "MANA", STAT_MAXMANA, 17 * 256 }, + }; + for (const Named& n : named) { + INFO("item carries only the stat for ", n.code); + TestItem item("amu", ITEM_QUALITY_RARE); + item.Stat(n.stat, n.raw); + const std::wstring code = Widen(n.code); + CHECK(Matches(code + L"=17", item)); + CHECK(Matches(code + L">16", item)); + CHECK_FALSE(Matches(code + L">17", item)); + CHECK(Matches(code + L"~17-17", item)); + for (const Named& other : named) { + if (other.stat == n.stat) { + continue; + } + INFO("reading ", other.code); + CHECK_FALSE(Matches(Widen(other.code) + L">0", item)); + } + } +} + +TEST_CASE("LIFE and MANA compare whole points although the game stores them in 1/256 units") { + TestItem amulet("amu", ITEM_QUALITY_RARE); + amulet.Stat(STAT_MAXHP, 30 * 256); + amulet.Stat(STAT_MAXMANA, 45 * 256); + + CHECK(Matches(L"LIFE=30", amulet)); + CHECK(Matches(L"LIFE>29", amulet)); + CHECK_FALSE(Matches(L"LIFE>30", amulet)); + CHECK(Matches(L"LIFE~20-30", amulet)); + CHECK(Matches(L"LIFE~30-40", amulet)); + CHECK_FALSE(Matches(L"LIFE~31-40", amulet)); + CHECK_FALSE(Matches(L"LIFE~10-29", amulet)); + + CHECK(Matches(L"MANA=45", amulet)); + CHECK(Matches(L"MANA<46", amulet)); + CHECK_FALSE(Matches(L"MANA<45", amulet)); + + // STAT7/STAT9 are the same stats and use the same whole-point scale as LIFE/MANA + // (and as the %STAT7%/%STAT9% keywords, which divide by 256). + CHECK(Matches(L"STAT7=30", amulet)); + CHECK(Matches(L"STAT9=45", amulet)); +} + +// ---- Skills ---------------------------------------------------------------------------------- + +TEST_CASE("SK, OS, CLSK, TABSK and ALLSK read the matching skill stats") { + TestItem item("7tw", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_SINGLESKILL, 3, 20); // +3 Lightning Bolt (Thunderstroke, wiki: SK20=3) + item.Stat(STAT_NONCLASSSKILL, 20, 74); // +20 Corpse Explosion oskill (Corpsemourn, OS74=20) + item.Stat(STAT_CLASSSKILLS, 2, 2); // +2 Necromancer skills (Hellfire Torch, CLSK2=2) + item.Stat(STAT_SKILLTAB, 5, 25); // +5 Offensive Auras (Cloudcrack, TABSK25>4) + item.Stat(STAT_ALLSKILLS, 1); + + CHECK(Matches(L"SK20=3", item)); + CHECK_FALSE(Matches(L"SK20>3", item)); + CHECK(Matches(L"SK21=0", item)); + + CHECK(Matches(L"OS74=20", item)); + CHECK_FALSE(Matches(L"OS20>0", item)); // single skill +3 is not an oskill + + CHECK(Matches(L"CLSK2=2", item)); + CHECK_FALSE(Matches(L"CLSK1>0", item)); + + CHECK(Matches(L"TABSK25>4", item)); + CHECK_FALSE(Matches(L"TABSK25>5", item)); + CHECK_FALSE(Matches(L"TABSK24>0", item)); + + CHECK(Matches(L"ALLSK=1", item)); + CHECK_FALSE(Matches(L"ALLSK>1", item)); +} + +TEST_CASE("skill codes read ids inside their tables and not ids past them") { + // SKILL_MAX is the number of Skills.txt rows (Item.cpp), so valid skill ids are 0..SKILL_MAX-1. + TestItem item("amu", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_SINGLESKILL, 1, 356); + item.Stat(STAT_SINGLESKILL, 1, 358); + item.Stat(STAT_NONCLASSSKILL, 1, 356); + item.Stat(STAT_NONCLASSSKILL, 1, 358); + item.Stat(STAT_CLASSSKILLS, 1, 6); + item.Stat(STAT_CLASSSKILLS, 1, 7); + item.Stat(STAT_SKILLTAB, 1, 50); + item.Stat(STAT_SKILLTAB, 1, 51); + item.Stat(STAT_CHARGED, 1, 356 * 64 + 1); + item.Stat(STAT_CHARGED, 1, 358 * 64 + 1); + + SKILL_MAX = 357; + CHECK(Matches(L"SET OR SK356=1", item)); + CHECK_FALSE(Matches(L"UNI SK356=2", item)); + CHECK_FALSE(Matches(L"SET OR SK358=1", item)); + CHECK(Matches(L"SET OR OS356=1", item)); + CHECK_FALSE(Matches(L"UNI OS356=2", item)); + CHECK_FALSE(Matches(L"SET OR OS358=1", item)); + CHECK(Matches(L"SET OR CHSK356=1", item)); + CHECK_FALSE(Matches(L"UNI CHSK356=2", item)); + CHECK_FALSE(Matches(L"SET OR CHSK358=1", item)); + + // Seven classes: ids 0 (Amazon) .. 6 (Assassin). + CHECK(Matches(L"SET OR CLSK6=1", item)); + CHECK_FALSE(Matches(L"UNI CLSK6=2", item)); + CHECK_FALSE(Matches(L"SET OR CLSK7=1", item)); + + // Skill tabs run up to 50 (Assassin Martial Arts). + CHECK(Matches(L"SET OR TABSK50=1", item)); + CHECK_FALSE(Matches(L"UNI TABSK50=2", item)); + CHECK_FALSE(Matches(L"SET OR TABSK51=1", item)); +} + +TEST_CASE("CHSK compares the highest level of charges for that skill") { + // Charged skills are stat 204 with layer (skill_id * 64) + skill_level. + TestItem wand("wnd", ITEM_QUALITY_UNIQUE); + wand.Stat(STAT_CHARGED, 20, 54 * 64 + 3); // Teleport level 3 + wand.Stat(STAT_CHARGED, 5, 54 * 64 + 7); // Teleport level 7 + wand.Stat(STAT_CHARGED, 30, 53 * 64 + 20); // Chain Lightning level 20 (another skill) + + CHECK(Matches(L"CHSK54=7", wand)); + CHECK(Matches(L"CHSK54>6", wand)); + CHECK_FALSE(Matches(L"CHSK54>7", wand)); + CHECK(Matches(L"CHSK53=20", wand)); + CHECK(Matches(L"CHSK52=0", wand)); + // A plain +skill is not a charge. + TestItem orb("ob1", ITEM_QUALITY_MAGIC); + orb.Stat(STAT_SINGLESKILL, 3, 54); + CHECK_FALSE(Matches(L"CHSK54>0", orb)); +} + +// ---- Character stats ------------------------------------------------------------------------- + +TEST_CASE("CHARSTAT reads the character's stats, not the item's") { + TestItem ring("rin", ITEM_QUALITY_RARE); + ring.Stat(STAT_STRENGTH, 5); + fake::SetStat(&fake::Player(), STAT_STRENGTH, 120); + fake::SetStat(&fake::Player(), STAT_LEVEL, 75); + + CHECK(Matches(L"CHARSTAT0=120", ring)); + CHECK(Matches(L"CHARSTAT0>119", ring)); + CHECK_FALSE(Matches(L"CHARSTAT0>120", ring)); + CHECK_FALSE(Matches(L"CHARSTAT0=5", ring)); + CHECK(Matches(L"CHARSTAT12~70-75", ring)); + CHECK_FALSE(Matches(L"CHARSTAT12~76-99", ring)); + // The item's own +5 strength is STAT0, unaffected by the character. + CHECK(Matches(L"STAT0=5", ring)); +} + +TEST_CASE("CHARSTAT reads ids inside the ItemStatCost table and not ids past it") { + TestItem ring("rin", ITEM_QUALITY_RARE); + fake::SetStat(&fake::Player(), 358, 4); + fake::SetStat(&fake::Player(), 360, 4); + STAT_MAX = 359; + CHECK(Matches(L"SET OR CHARSTAT358=4", ring)); + CHECK_FALSE(Matches(L"RARE CHARSTAT358=5", ring)); + CHECK_FALSE(Matches(L"SET OR CHARSTAT360=4", ring)); +} + +// ---- Resistances ----------------------------------------------------------------------------- + +TEST_CASE("RES requires every one of the four resistances to pass") { + TestItem uniform("amu", ITEM_QUALITY_RARE); + uniform.Stat(STAT_FIRERESIST, 25); + uniform.Stat(STAT_COLDRESIST, 25); + uniform.Stat(STAT_LIGHTNINGRESIST, 25); + uniform.Stat(STAT_POISONRESIST, 25); + CHECK(Matches(L"RES=25", uniform)); + CHECK(Matches(L"RES>24", uniform)); + CHECK_FALSE(Matches(L"RES>25", uniform)); + CHECK(Matches(L"RES~20-25", uniform)); + CHECK_FALSE(Matches(L"RES~26-30", uniform)); + + // RES>N requires each resistance to exceed N: one resistance at 29 fails RES>29. + TestItem lowPoison("amu", ITEM_QUALITY_RARE); + lowPoison.Stat(STAT_FIRERESIST, 30); + lowPoison.Stat(STAT_COLDRESIST, 30); + lowPoison.Stat(STAT_LIGHTNINGRESIST, 30); + lowPoison.Stat(STAT_POISONRESIST, 29); + CHECK(Matches(L"RES>28", lowPoison)); + CHECK_FALSE(Matches(L"RES>29", lowPoison)); + + // A single resistance is not all resistances. + TestItem fireOnly("amu", ITEM_QUALITY_RARE); + fireOnly.Stat(STAT_FIRERESIST, 40); + CHECK_FALSE(Matches(L"RES>0", fireOnly)); + CHECK(Matches(L"FRES=40", fireOnly)); +} + +TEST_CASE("MAXRES is the lowest of the four maximum resistances, 0 unless all four are present") { + TestItem amulet("amu", ITEM_QUALITY_UNIQUE); + amulet.Stat(STAT_MAXFIRERESIST, 5); + amulet.Stat(STAT_MAXCOLDRESIST, 5); + amulet.Stat(STAT_MAXLIGHTNINGRESIST, 5); + amulet.Stat(STAT_MAXPOISONRESIST, 4); + CHECK(Matches(L"MAXRES=4", amulet)); + CHECK_FALSE(Matches(L"MAXRES>4", amulet)); + + TestItem fireMax("amu", ITEM_QUALITY_UNIQUE); + fireMax.Stat(STAT_MAXFIRERESIST, 5); + fireMax.Stat(STAT_MAXCOLDRESIST, 5); + fireMax.Stat(STAT_MAXLIGHTNINGRESIST, 5); + CHECK(Matches(L"MAXRES=0", fireMax)); + CHECK_FALSE(Matches(L"MAXRES>0", fireMax)); +} + +TEST_CASE("ALLATTRIB is the lowest of the four attributes, 0 unless all four are present") { + TestItem charm("cm1", ITEM_QUALITY_UNIQUE); + charm.Stat(STAT_STRENGTH, 20); + charm.Stat(STAT_DEXTERITY, 15); + charm.Stat(STAT_VITALITY, 20); + charm.Stat(STAT_ENERGY, 20); + CHECK(Matches(L"ALLATTRIB=15", charm)); + CHECK(Matches(L"ALLATTRIB>14", charm)); + CHECK_FALSE(Matches(L"ALLATTRIB>15", charm)); + + // Strength, dexterity and vitality without energy is not "+X to all attributes". + TestItem noEnergy("cm1", ITEM_QUALITY_UNIQUE); + noEnergy.Stat(STAT_STRENGTH, 20); + noEnergy.Stat(STAT_DEXTERITY, 20); + noEnergy.Stat(STAT_VITALITY, 20); + CHECK(Matches(L"ALLATTRIB=0", noEnergy)); + CHECK_FALSE(Matches(L"ALLATTRIB>0", noEnergy)); +} + +// ---- Enhanced defense / damage, durability ---------------------------------------------------- + +TEST_CASE("ED reads enhanced defense on armor and enhanced damage on weapons") { + TestItem helm("uap", ITEM_QUALITY_MAGIC); + helm.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_HELM; + helm.Stat(STAT_ENHANCEDDEFENSE, 100); + helm.Stat(STAT_ENHANCEDMAXIMUMDAMAGE, 40); + CHECK(Matches(L"ED=100", helm)); + CHECK(Matches(L"ED>99", helm)); + CHECK_FALSE(Matches(L"ED>100", helm)); + + TestItem sword("7cr", ITEM_QUALITY_MAGIC); + sword.Stat(STAT_ENHANCEDMAXIMUMDAMAGE, 200); + sword.Stat(STAT_ENHANCEDMINIMUMDAMAGE, 200); + sword.Stat(STAT_ENHANCEDDEFENSE, 30); + CHECK(Matches(L"ED=200", sword)); + CHECK(Matches(L"ED~150-200", sword)); + CHECK_FALSE(Matches(L"ED~201-300", sword)); +} + +TEST_CASE("EDEF and EDAM include runeword bonuses but ED does not") { + // Wiki: EDEF/EDAM include bonuses from runewords and sockets; ED does not include these. + TestItem armor("xtp", ITEM_QUALITY_SUPERIOR); + armor.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_BODY_ARMOR; + armor.Flags(ITEM_IDENTIFIED | ITEM_RUNEWORD); + armor.Stat(STAT_ENHANCEDDEFENSE, 15); + fake::SetRunewordStat(armor.unit(), STAT_ENHANCEDDEFENSE, 200); + + CHECK(Matches(L"EDEF=215", armor)); + CHECK_FALSE(Matches(L"EDEF>215", armor)); + CHECK(Matches(L"ED=15", armor)); + + TestItem weapon("7cr", ITEM_QUALITY_SUPERIOR); + weapon.Flags(ITEM_IDENTIFIED | ITEM_RUNEWORD); + weapon.Stat(STAT_ENHANCEDMAXIMUMDAMAGE, 10); + fake::SetRunewordStat(weapon.unit(), STAT_ENHANCEDMAXIMUMDAMAGE, 300); + CHECK(Matches(L"EDAM=310", weapon)); + CHECK(Matches(L"ED=10", weapon)); +} + +TEST_CASE("runeword state stats count only on items flagged as runewords") { + TestItem armor("xtp", ITEM_QUALITY_SUPERIOR); + armor.Stat(STAT_ENHANCEDDEFENSE, 15); + fake::SetRunewordStat(armor.unit(), STAT_ENHANCEDDEFENSE, 200); + CHECK(Matches(L"EDEF=15", armor)); + + armor.Flags(ITEM_IDENTIFIED | ITEM_RUNEWORD); + CHECK(Matches(L"EDEF=215", armor)); +} + +TEST_CASE("EDEF includes enhanced defense from socketed items but ED does not") { + TestItem jewel("jew", ITEM_QUALITY_MAGIC); + jewel.Stat(STAT_ENHANCEDDEFENSE, 15); + TestItem rune("r09", ITEM_QUALITY_NORMAL); + rune.Stat(STAT_ENHANCEDDEFENSE, 0); // a socketed item without the stat adds nothing + + Inventory sockets = {}; + sockets.pFirstItem = jewel.unit(); + jewel.data().pNextInvItem = rune.unit(); + + TestItem armor("xtp", ITEM_QUALITY_MAGIC); + armor.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_BODY_ARMOR; + armor.unit()->pInventory = &sockets; + armor.Stat(STAT_SOCKETS, 2); + armor.Stat(STAT_ENHANCEDDEFENSE, 50); + + CHECK(Matches(L"EDEF=65", armor)); + CHECK(Matches(L"ED=50", armor)); +} + +TEST_CASE("MAXDUR reads Increase Maximum Durability %") { + TestItem armor("xtp", ITEM_QUALITY_MAGIC); + armor.Stat(STAT_ENHANCEDMAXDURABILITY, 25); + CHECK(Matches(L"MAXDUR=25", armor)); + CHECK(Matches(L"MAXDUR>24", armor)); + CHECK_FALSE(Matches(L"MAXDUR>25", armor)); + CHECK(Matches(L"MAXDUR~20-30", armor)); + + TestItem plain("xtp", ITEM_QUALITY_NORMAL); + plain.Stat(STAT_MAXDURABILITY, 50); // plain durability is not the percentage bonus + CHECK(Matches(L"MAXDUR=0", plain)); +} + +// ---- MINDMG / MAXDMG --------------------------------------------------------------------------- + +TEST_CASE("MINDMG and MAXDMG take the largest of the one-hand, two-hand and throwing bonuses") { + TestItem axe("7ga", ITEM_QUALITY_RARE); + axe.Stat(STAT_MINIMUMDAMAGE, 5); + axe.Stat(STAT_SECONDARYMINIMUMDAMAGE, 8); + axe.Stat(STAT_MAXIMUMDAMAGE, 12); + axe.Stat(STAT_SECONDARYMAXIMUMDAMAGE, 10); + + CHECK(Matches(L"MINDMG=8", axe)); + CHECK_FALSE(Matches(L"MINDMG>8", axe)); + CHECK(Matches(L"MAXDMG=12", axe)); + CHECK(Matches(L"MAXDMG~12-15", axe)); + + TestItem jav("7ja", ITEM_QUALITY_RARE); + jav.Stat(STAT_MINIMUMDAMAGE, 3); + jav.Stat(STAT_MINIMUMTHROWINGDAMAGE, 9); + jav.Stat(STAT_MAXIMUMTHROWINGDAMAGE, 20); + CHECK(Matches(L"MINDMG=9", jav)); + CHECK(Matches(L"MAXDMG=20", jav)); + + TestItem ring("rin", ITEM_QUALITY_RARE); + CHECK(Matches(L"MINDMG=0", ring)); + CHECK_FALSE(Matches(L"MAXDMG>0", ring)); +} + +TEST_CASE("MAXDMG includes runeword bonuses") { + TestItem sword("7cr", ITEM_QUALITY_SUPERIOR); + sword.Flags(ITEM_IDENTIFIED | ITEM_RUNEWORD); + sword.Stat(STAT_MAXIMUMDAMAGE, 2); + fake::SetRunewordStat(sword.unit(), STAT_MAXIMUMDAMAGE, 10); + CHECK(Matches(L"MAXDMG=12", sword)); +} + +// ---- Base damage, block, sockets ------------------------------------------------------------- + +TEST_CASE("BASE damage codes read the base item's damage for its own damage type") { + TestItem axe("7ga", ITEM_QUALITY_NORMAL); // Great Axe-like two-hander + axe.txt().b2handmindam = 24; + axe.txt().b2handmaxdam = 38; + CHECK(Matches(L"BASEMINTWOH=24", axe)); + CHECK(Matches(L"BASEMAXTWOH=38", axe)); + CHECK_FALSE(Matches(L"BASEMAXTWOH>38", axe)); + CHECK(Matches(L"BASEMINONEH=0", axe)); + + TestItem jav("jav", ITEM_QUALITY_NORMAL); // Javelin: 1-14 melee, 6-22 thrown + jav.txt().bmindam = 1; + jav.txt().bmaxdam = 14; + jav.txt().bminmisdam = 6; + jav.txt().bmaxmisdam = 22; + CHECK(Matches(L"BASEMINONEH=1", jav)); + CHECK(Matches(L"BASEMAXONEH=14", jav)); + CHECK(Matches(L"BASEMINTHROW=6", jav)); + CHECK(Matches(L"BASEMAXTHROW=22", jav)); + CHECK(Matches(L"BASEMAXTHROW~20-22", jav)); +} + +TEST_CASE("smite damage comes only from shields and kick damage only from boots") { + TestItem shield("xpk", ITEM_QUALITY_NORMAL); + shield.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_SHIELD; + shield.txt().bmindam = 12; + shield.txt().bmaxdam = 16; + CHECK(Matches(L"BASEMINSMITE=12", shield)); + CHECK(Matches(L"BASEMAXSMITE=16", shield)); + CHECK(Matches(L"BASEMINKICK=0", shield)); + // A shield's damage columns are smite damage, not one-handed weapon damage. + CHECK(Matches(L"BASEMINONEH=0", shield)); + CHECK(Matches(L"BASEMAXONEH=0", shield)); + + TestItem boots("xvb", ITEM_QUALITY_NORMAL); + boots.attrs().armorFlags = ITEM_GROUP_ALLARMOR | ITEM_GROUP_BOOTS; + boots.txt().bmindam = 28; + boots.txt().bmaxdam = 35; + CHECK(Matches(L"BASEMINKICK=28", boots)); + CHECK(Matches(L"BASEMAXKICK=35", boots)); + CHECK(Matches(L"BASEMAXSMITE=0", boots)); + CHECK(Matches(L"BASEMAXONEH=0", boots)); + + TestItem sword("7cr", ITEM_QUALITY_NORMAL); + sword.txt().bmindam = 10; + sword.txt().bmaxdam = 20; + CHECK(Matches(L"BASEMINSMITE=0", sword)); + CHECK(Matches(L"BASEMAXKICK=0", sword)); +} + +TEST_CASE("ethereal weapons get PD2's x1.25 base damage bonus") { + // PD2 wiki (Throwing/Swords pages): ethereal weapons gain a x1.25 damage bonus instead of + // x1.5. The bases are chosen so that 1.25 * d is a whole number and needs no rounding rule. + TestItem axe("7ga", ITEM_QUALITY_NORMAL); + axe.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + axe.txt().b2handmindam = 24; // 30 + axe.txt().b2handmaxdam = 40; // 50 + CHECK(Matches(L"BASEMINTWOH=30", axe)); + CHECK(Matches(L"BASEMAXTWOH=50", axe)); + + TestItem jav("jav", ITEM_QUALITY_NORMAL); + jav.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + jav.txt().bmindam = 4; // 5 + jav.txt().bmaxdam = 16; // 20 + jav.txt().bminmisdam = 8; // 10 + jav.txt().bmaxmisdam = 24; // 30 + CHECK(Matches(L"BASEMINONEH=5", jav)); + CHECK(Matches(L"BASEMAXONEH=20", jav)); + CHECK(Matches(L"BASEMINTHROW=10", jav)); + CHECK(Matches(L"BASEMAXTHROW=30", jav)); + + // Non-ethereal items keep the base damage. + TestItem plain("jav", ITEM_QUALITY_NORMAL); + plain.txt().bmaxmisdam = 24; + CHECK(Matches(L"BASEMAXTHROW=24", plain)); +} + +TEST_CASE("ethereal base damage is 1.25x for odd damage values too" * doctest::should_fail()) { + // BUG: BaseWeaponDamageCondition computes ethereal damage as (d + d/2) * 5 / 6, truncating + // d/2 before scaling, so odd bases lose up to a point against PD2's x1.25 (wiki). A Flail's + // minimum damage 1 becomes (1 + 0) * 5 / 6 = 0, although 1 * 1.25 = 1.25 is 1 under any + // rounding; base 5 gives 35 / 6 = 5 instead of 6 (6.25). + TestItem flail("fla", ITEM_QUALITY_NORMAL); + flail.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + flail.txt().bmindam = 1; + flail.txt().bmaxdam = 24; + CHECK(Matches(L"BASEMINONEH=1", flail)); + CHECK(Matches(L"BASEMAXONEH=30", flail)); + + TestItem twoHander("7ga", ITEM_QUALITY_NORMAL); + twoHander.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + twoHander.txt().b2handmindam = 5; + CHECK(Matches(L"BASEMINTWOH=6", twoHander)); +} + +TEST_CASE("BASEBLOCK reads the base item's block chance") { + TestItem shield("xpk", ITEM_QUALITY_NORMAL); + shield.txt().bblock = 30; + CHECK(Matches(L"BASEBLOCK=30", shield)); + CHECK(Matches(L"BASEBLOCK>29", shield)); + CHECK_FALSE(Matches(L"BASEBLOCK>30", shield)); + CHECK(Matches(L"BASEBLOCK~25-30", shield)); +} + +TEST_CASE("MAXSOCKETS is capped by the number of inventory cells the item occupies") { + TestItem armor("xtp", ITEM_QUALITY_NORMAL); // body armor: 2x3 = 6 cells + armor.txt().binvwidth = 2; + armor.txt().binvheight = 3; + fake::SetMaxSockets(armor.unit(), 4); + CHECK(Matches(L"MAXSOCKETS=4", armor)); + CHECK_FALSE(Matches(L"MAXSOCKETS>4", armor)); + + TestItem shield("xpk", ITEM_QUALITY_NORMAL); // 2x2 = 4 cells + shield.txt().binvwidth = 2; + shield.txt().binvheight = 2; + fake::SetMaxSockets(shield.unit(), 6); + CHECK(Matches(L"MAXSOCKETS=4", shield)); + + TestItem wand("wnd", ITEM_QUALITY_NORMAL); // 1x2 = 2 cells + wand.txt().binvwidth = 1; + wand.txt().binvheight = 2; + fake::SetMaxSockets(wand.unit(), 2); + CHECK(Matches(L"MAXSOCKETS=2", wand)); + CHECK(Matches(L"MAXSOCKETS~1-2", wand)); +} + +// ---- Requirements ---------------------------------------------------------------------------- + +TEST_CASE("REQSTR and REQDEX apply the item's requirement reduction") { + TestItem armor("xtp", ITEM_QUALITY_MAGIC); + armor.txt().wreqstr = 100; + armor.txt().wreqdex = 50; + CHECK(Matches(L"REQSTR=100", armor)); + CHECK(Matches(L"REQDEX=50", armor)); + + armor.Stat(STAT_REDUCEDREQUIREMENTS, -20); // Requirements -20% + CHECK(Matches(L"REQSTR=80", armor)); + CHECK(Matches(L"REQDEX=40", armor)); + CHECK_FALSE(Matches(L"REQSTR>80", armor)); + CHECK(Matches(L"REQSTR<81", armor)); + + TestItem heavy("xtp", ITEM_QUALITY_MAGIC); + heavy.txt().wreqstr = 100; + heavy.Stat(STAT_REDUCEDREQUIREMENTS, 50); // Requirements +50% + CHECK(Matches(L"REQSTR=150", heavy)); +} + +TEST_CASE("ethereal items need 10 less strength and dexterity, never below zero") { + TestItem armor("xtp", ITEM_QUALITY_NORMAL); + armor.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + armor.txt().wreqstr = 100; + armor.txt().wreqdex = 0; + CHECK(Matches(L"REQSTR=90", armor)); + CHECK(Matches(L"REQDEX=0", armor)); + CHECK_FALSE(Matches(L"REQDEX<0", armor)); + + armor.Stat(STAT_REDUCEDREQUIREMENTS, -20); + CHECK(Matches(L"REQSTR=70", armor)); + + TestItem light("cap", ITEM_QUALITY_NORMAL); + light.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + light.txt().wreqstr = 5; + CHECK(Matches(L"REQSTR=0", light)); +} + +TEST_CASE("REQLVL is the item's level requirement") { + TestItem ring("rin", ITEM_QUALITY_UNIQUE); + fake::SetLevelRequirement(ring.unit(), 29); + CHECK(Matches(L"REQLVL=29", ring)); + CHECK(Matches(L"REQLVL>28", ring)); + CHECK_FALSE(Matches(L"REQLVL>29", ring)); + CHECK(Matches(L"REQLVL~1-29", ring)); +} + +TEST_CASE("REQLVL is the requirement for the viewing character's class") { + // A class-specific affix (e.g. +skills of one class) lowers the level requirement for that + // class only; "required character level" is what the current character needs. + TestItem amulet("amu", ITEM_QUALITY_MAGIC); + fake::SetLevelRequirement(amulet.unit(), 30); + fake::SetLevelRequirement(amulet.unit(), 24, 1); // Sorceress + + fake::Player().dwTxtFileNo = 1; + CHECK(Matches(L"REQLVL=24", amulet)); + fake::Player().dwTxtFileNo = 0; // Amazon + CHECK(Matches(L"REQLVL=30", amulet)); +} + +// ---- Upgrade requirements ---------------------------------------------------------------------- + +TEST_CASE("UPSTR, UPDEX and UPLVL give the exceptional version's requirements for a normal item") { + AddHandAxeUpgrades(); + TestItem axe("hax", ITEM_QUALITY_MAGIC); + LinkHandAxeCodes(axe.txt()); + fake::SetLevelRequirement(axe.unit(), 12); + + CHECK(Matches(L"UPSTR=25", axe)); + CHECK(Matches(L"UPDEX=25", axe)); + CHECK(Matches(L"UPLVL=19", axe)); + CHECK_FALSE(Matches(L"UPLVL>19", axe)); +} + +TEST_CASE("UPSTR, UPDEX and UPLVL give the elite version's requirements for an exceptional item") { + AddHandAxeUpgrades(); + TestItem axe("9ha", ITEM_QUALITY_RARE); + LinkHandAxeCodes(axe.txt()); + fake::SetLevelRequirement(axe.unit(), 30); + + CHECK(Matches(L"UPSTR=125", axe)); + CHECK(Matches(L"UPDEX=67", axe)); + CHECK(Matches(L"UPLVL=40", axe)); +} + +TEST_CASE("UPLVL keeps the item's own level requirement when it is higher") { + AddHandAxeUpgrades(); + TestItem unique("hax", ITEM_QUALITY_UNIQUE); + LinkHandAxeCodes(unique.txt()); + fake::SetLevelRequirement(unique.unit(), 30); + CHECK(Matches(L"UPLVL=30", unique)); +} + +TEST_CASE("UPLVL uses the viewing character's own requirement for the current item") { + AddHandAxeUpgrades(); // the exceptional Hatchet needs level 19 + TestItem axe("hax", ITEM_QUALITY_MAGIC); + LinkHandAxeCodes(axe.txt()); + fake::SetLevelRequirement(axe.unit(), 30); + fake::SetLevelRequirement(axe.unit(), 22, 4); // Barbarian + + fake::Player().dwTxtFileNo = 4; + CHECK(Matches(L"UPLVL=22", axe)); + fake::Player().dwTxtFileNo = 6; // Assassin + CHECK(Matches(L"UPLVL=30", axe)); +} + +TEST_CASE("upgrade requirements keep the item's requirement reduction and ethereal bonus") { + AddHandAxeUpgrades(); + TestItem axe("9ha", ITEM_QUALITY_RARE); + LinkHandAxeCodes(axe.txt()); + axe.Stat(STAT_REDUCEDREQUIREMENTS, -20); + CHECK(Matches(L"UPSTR=100", axe)); // 125 - 25 + + axe.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + CHECK(Matches(L"UPSTR=90", axe)); // 125 - 25 - 10 +} + +TEST_CASE("UPSTR, UPDEX and UPLVL are 0 when no upgrade exists") { + AddHandAxeUpgrades(); + TestItem elite("7ha", ITEM_QUALITY_RARE); + LinkHandAxeCodes(elite.txt()); + fake::SetLevelRequirement(elite.unit(), 40); + CHECK(Matches(L"UPSTR=0", elite)); + CHECK(Matches(L"UPDEX=0", elite)); + CHECK(Matches(L"UPLVL=0", elite)); + + TestItem ring("rin", ITEM_QUALITY_RARE); // misc items have no upgrade codes + fake::SetLevelRequirement(ring.unit(), 20); + CHECK(Matches(L"UPLVL=0", ring)); + CHECK_FALSE(Matches(L"UPSTR>0", ring)); +} + +// ---- "+" sums -------------------------------------------------------------------------------- + +TEST_CASE("codes joined with + compare the sum of their values") { + // Wiki example: FRES+CRES+LRES+PRES>79 marks rares with 80+ total resistance. + TestItem ring("rin", ITEM_QUALITY_RARE); + ring.Stat(STAT_FIRERESIST, 30); + ring.Stat(STAT_COLDRESIST, 20); + ring.Stat(STAT_LIGHTNINGRESIST, 20); + ring.Stat(STAT_POISONRESIST, 10); + CHECK(Matches(L"FRES+CRES+LRES+PRES>79", ring)); + CHECK_FALSE(Matches(L"FRES+CRES+LRES+PRES>80", ring)); + CHECK(Matches(L"FRES+CRES+LRES+PRES=80", ring)); + CHECK(Matches(L"FRES+CRES<51", ring)); + CHECK_FALSE(Matches(L"FRES+CRES<50", ring)); +} + +TEST_CASE("+ sums accept STAT and MULTI terms") { + // Wiki example: STAT60+STAT62>10 (total life and mana leech). + TestItem ring("rin", ITEM_QUALITY_RARE); + ring.Stat(STAT_LIFELEECH, 6); + ring.Stat(STAT_MANALEECH, 5); + ring.Stat(STAT_SINGLESKILL, 2, 54); + ring.Stat(STAT_SINGLESKILL, 3, 56); + CHECK(Matches(L"STAT60+STAT62>10", ring)); + CHECK_FALSE(Matches(L"STAT60+STAT62>11", ring)); + CHECK(Matches(L"MULTI107,54+MULTI107,56=5", ring)); + CHECK(Matches(L"STAT60+MULTI107,56=9", ring)); +} + +TEST_CASE("+ sums use whole life/mana points and the MINDMG/MAXDMG and EDEF/EDAM semantics") { + TestItem amulet("amu", ITEM_QUALITY_RARE); + amulet.Stat(STAT_MAXHP, 40 * 256); + amulet.Stat(STAT_MAXMANA, 30 * 256); + amulet.Stat(STAT_STRENGTH, 10); + CHECK(Matches(L"LIFE+MANA=70", amulet)); + CHECK(Matches(L"LIFE+STR>49", amulet)); + CHECK_FALSE(Matches(L"LIFE+STR>50", amulet)); + + TestItem axe("7ga", ITEM_QUALITY_RARE); + axe.Stat(STAT_MAXIMUMDAMAGE, 4); + axe.Stat(STAT_SECONDARYMAXIMUMDAMAGE, 9); + axe.Stat(STAT_SECONDARYMINIMUMDAMAGE, 3); + // MAXDMG is the largest of the max damage bonuses (9), MINDMG likewise (3). + CHECK(Matches(L"MINDMG+MAXDMG=12", axe)); + + TestItem armor("xtp", ITEM_QUALITY_SUPERIOR); + armor.Flags(ITEM_IDENTIFIED | ITEM_RUNEWORD); + armor.Stat(STAT_ENHANCEDDEFENSE, 10); + armor.Stat(STAT_STRENGTH, 5); + fake::SetRunewordStat(armor.unit(), STAT_ENHANCEDDEFENSE, 100); + CHECK(Matches(L"EDEF+STR=115", armor)); +} + +TEST_CASE("+ sums support the ~ range operator like every other value condition" + * doctest::should_fail()) { + // BUG: BuildConditions parses both bounds of "~50-100" but passes only the first to + // AddCondition, whose IntegerCompare then sees the range 50..0, so a "+" sum with '~' never + // matches. The wiki documents '~' for value conditions and "+" for STAT/MULTI codes with no + // exception for sums. + TestItem ring("rin", ITEM_QUALITY_RARE); + ring.Stat(STAT_FIRERESIST, 30); + ring.Stat(STAT_COLDRESIST, 30); + CHECK(Matches(L"FRES+CRES~50-100", ring)); + CHECK(Matches(L"FRES+CRES~60-60", ring)); +} + +} // TEST_SUITE From 025ce143e6cfcdf83305061dbf9c615cefc3829e Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:06 -0400 Subject: [PATCH 6/8] Test loot filter actions and rule lists --- tests/BH.Tests.vcxproj | 2 + tests/LootFilterActionTests.cpp | 581 +++++++++++++++++++++++++++++ tests/LootFilterRulesTests.cpp | 637 ++++++++++++++++++++++++++++++++ 3 files changed, 1220 insertions(+) create mode 100644 tests/LootFilterActionTests.cpp create mode 100644 tests/LootFilterRulesTests.cpp diff --git a/tests/BH.Tests.vcxproj b/tests/BH.Tests.vcxproj index a71191a2..12fcb890 100644 --- a/tests/BH.Tests.vcxproj +++ b/tests/BH.Tests.vcxproj @@ -121,6 +121,8 @@ + + diff --git a/tests/LootFilterActionTests.cpp b/tests/LootFilterActionTests.cpp new file mode 100644 index 00000000..6d21f8fd --- /dev/null +++ b/tests/LootFilterActionTests.cpp @@ -0,0 +1,581 @@ +// What one loot filter rule's output ("action", the text after `ItemDisplay[...]:`) produces: +// how BuildAction splits it into name, description and notification settings, how the %KEYWORD% +// replacements render for an item, and how the finished name/description is trimmed. +// Expected values come from the PD2 item filtering wiki +// (https://wiki.projectdiablo2.com/wiki/Item_Filtering), Diablo II's colour codes and data ids +// (BH/Constants.h), and hand computation of the D2 formulas involved. +#include "doctest/doctest.h" + +#include + +#include "BH.h" +#include "LootFilter.h" + +using support::TestItem; + +namespace { + +// A D2 text colour code: U+00FF, 'c', then the colour character. +std::wstring Color(const wchar_t* code) { + return std::wstring(L"\xFF" L"c") + code; +} + +// The name `item` gets from a filter whose only rule matches every item and has `action` as output. +// Each call reloads the filter, which also clears BH's name/description caches, so one item can be +// looked at with several actions in a row. +std::wstring NameWith(const std::string& action, TestItem& item, const std::wstring& baseName = L"Hand Axe") { + support::LoadFilter("ItemDisplay[]: " + action + "\n"); + return support::NameOf(item, baseName); +} + +// The description `item` gets from a filter whose only rule matches every item (reloads like NameWith). +std::wstring DescriptionWith(const std::string& action, TestItem& item) { + support::LoadFilter("ItemDisplay[]: " + action + "\n"); + return support::DescriptionOf(item); +} + +// The parsed action (name/description split, notification settings) of a rule with this output. +const Action& ActionOf(const std::wstring& action) { + return support::ParseRule(L"", action)->action; +} + +} // namespace + +TEST_SUITE("LootFilterActions") { + +// ---- Colour keywords ------------------------------------------------------------------------- + +TEST_CASE("colour keywords become the D2 text colour codes") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + // D2's colour characters: 0 white, 1 red, 2 green (set), 3 blue (magic), 4 gold (unique), 5 gray, + // 7 tan, 8 orange, 9 yellow (rare), ':' dark green, ';' purple. + CHECK(NameWith("%WHITE%a%RED%b%GREEN%c%BLUE%d", item) == + Color(L"0") + L"a" + Color(L"1") + L"b" + Color(L"2") + L"c" + Color(L"3") + L"d"); + CHECK(NameWith("%GOLD%e%GRAY%f%TAN%g%ORANGE%h", item) == + Color(L"4") + L"e" + Color(L"5") + L"f" + Color(L"7") + L"g" + Color(L"8") + L"h"); + CHECK(NameWith("%YELLOW%i%DARK_GREEN%j%PURPLE%k", item) == + Color(L"9") + L"i" + Color(L":") + L"j" + Color(L";") + L"k"); +} + +TEST_CASE("colour keywords also colour the description") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(DescriptionWith("%NAME%{%GOLD%Unique axe}", item) == Color(L"4") + L"Unique axe"); +} + +TEST_CASE("custom colours fall back to the closest standard colour without Glide") { + // Wiki: custom colours only work with Glide/HD text and otherwise revert to the most similar + // non-Glide colour. Black is the standard colour 6 outside Glide. + fake::Var(Var_D2GFX_RenderMode) = 3; // DirectDraw + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith("%CORAL%a%SAGE%b%TEAL%c%LIGHT_GRAY%d%BLACK%e", item) == + Color(L"1") + L"a" + Color(L"2") + L"b" + Color(L"3") + L"c" + Color(L"5") + L"d" + Color(L"6") + L"e"); +} + +TEST_CASE("under Glide custom colours are colour codes that do not use up the 56-character name budget") { + fake::Var(Var_D2GFX_RenderMode) = 4; // Glide + TestItem item("hax", ITEM_QUALITY_UNIQUE); + std::wstring name = NameWith("%CORAL%" + std::string(56, 'x'), item); + REQUIRE(name.size() == 59); + CHECK(name.substr(0, 2) == L"\xFF" L"c"); + CHECK(name.substr(0, 3) != Color(L"1")); // not the non-Glide fallback + CHECK(name.substr(3) == std::wstring(56, L'x')); +} + +TEST_CASE("transparency keywords render nothing without D2GL or HD text") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith("%FULL_TRANS%a%THREE_FOURTHS_TRANS%b%HALF_TRANS%c%QUARTER_TRANS%d", item) == L"abcd"); +} + +// ---- Keyword syntax -------------------------------------------------------------------------- + +TEST_CASE("keywords are case-insensitive") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.ItemLevel(85); + CHECK(NameWith("%red%%name% [%ilvl%]", item, L"The Gnasher") == Color(L"1") + L"The Gnasher [85]"); + // text outside %...% keeps its case + CHECK(NameWith("lower %Name% Text", item, L"The Gnasher") == L"lower The Gnasher Text"); +} + +TEST_CASE("unknown keywords are shown as typed and do not swallow a following keyword") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith("%NAME% %FOO%", item, L"Axe") == L"Axe %FOO%"); + // "%FOO%NAME%": FOO is no keyword, so its closing % can still open %NAME%. + CHECK(NameWith("%FOO%NAME%", item, L"Axe") == L"%FOOAxe"); +} + +TEST_CASE("percent and brace keywords produce literal characters") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith("%LBRACE%%NAME%%RBRACE% 100%PERCENT%", item, L"Axe") == L"{Axe} 100%"); + CHECK(NameWith("50% %NAME%", item, L"Axe") == L"50% Axe"); +} + +// ---- Name / description split ---------------------------------------------------------------- + +TEST_CASE("text in braces is the description, the rest is the name") { + const Action& a = ActionOf(L"%NAME%{Item Level: %ILVL%}"); + CHECK(a.name == L"%NAME%"); + CHECK(a.description == L"Item Level: %ILVL%"); + + const Action& middle = ActionOf(L"before {desc} after"); + CHECK(middle.name == L"before after"); + CHECK(middle.description == L"desc"); + + const Action& none = ActionOf(L"%NAME%"); + CHECK(none.name == L"%NAME%"); + CHECK(none.description == L""); + + const Action& descOnly = ActionOf(L"{%NAME%}"); + CHECK(descOnly.name == L""); + CHECK(descOnly.description == L"%NAME%"); + + // a closing brace before the opening one is no description + const Action& reversed = ActionOf(L"a}b{c"); + CHECK(reversed.name == L"a}b{c"); + CHECK(reversed.description == L""); +} + +TEST_CASE("description keywords are replaced for the item") { + TestItem item("hax", ITEM_QUALITY_MAGIC); + item.ItemLevel(42); + CHECK(DescriptionWith("%NAME%{Item Level: %ILVL%}", item) == L"Item Level: 42"); + CHECK(NameWith("%NAME%{Item Level: %ILVL%}", item, L"Axe") == L"Axe"); +} + +TEST_CASE("an unclosed brace is shown as text and gives no description") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith("%NAME% {", item, L"Axe") == L"Axe {"); + CHECK(support::DescriptionOf(item) == L""); +} + +// ---- %CONTINUE% ------------------------------------------------------------------------------ + +TEST_CASE("%CONTINUE% outside braces makes the rule continue and is not shown") { + const Action& stop = ActionOf(L"%NAME%"); + CHECK(stop.stopProcessing); + + const Action& go = ActionOf(L"%NAME% [%SOCKETS%]%CONTINUE%"); + CHECK_FALSE(go.stopProcessing); + CHECK(go.name == L"%NAME% [%SOCKETS%]"); + + const Action& lower = ActionOf(L"%NAME%%continue%"); + CHECK_FALSE(lower.stopProcessing); + CHECK(lower.name == L"%NAME%"); +} + +TEST_CASE("%CONTINUE% inside braces does not make the rule continue") { + // Wiki: %CONTINUE% only functions when used outside the braces. + const Action& a = ActionOf(L"%NAME%{text%CONTINUE%}"); + CHECK(a.stopProcessing); + CHECK(a.name == L"%NAME%"); +} + +// ---- Notification keywords ------------------------------------------------------------------- + +TEST_CASE("minimap icon keywords take a hex colour and are removed from the name") { + const Action& a = ActionOf(L"%NAME%%BORDER-0A%%MAP-9B%%DOT-1f%%PX-20%%LINE-84%"); + CHECK(a.borderColor == 0x0A); + CHECK(a.colorOnMap == 0x9B); + CHECK(a.dotColor == 0x1F); + CHECK(a.pxColor == 0x20); + CHECK(a.lineColor == 0x84); + CHECK(a.name == L"%NAME%"); + CHECK(a.description == L""); +} + +TEST_CASE("minimap icon keywords are case-insensitive and can sit anywhere in the output") { + const Action& a = ActionOf(L"%map-9b%%NAME%{desc}%dot-ff%"); + CHECK(a.colorOnMap == 0x9B); + CHECK(a.dotColor == 0xFF); + CHECK(a.name == L"%NAME%"); + CHECK(a.description == L"desc"); +} + +TEST_CASE("without notification keywords no icon colours, tier or sound are set") { + const Action& a = ActionOf(L"%RED%%NAME%{%GOLD%desc}"); + CHECK(a.borderColor == UNDEFINED_COLOR); + CHECK(a.colorOnMap == UNDEFINED_COLOR); + CHECK(a.dotColor == UNDEFINED_COLOR); + CHECK(a.pxColor == UNDEFINED_COLOR); + CHECK(a.lineColor == UNDEFINED_COLOR); + CHECK(a.notifyColor == UNDEFINED_COLOR); + CHECK(a.pingLevel == -1); + CHECK(a.soundID == 0); +} + +TEST_CASE("%NOTIFY-x% sets the text notification colour and %NOTIFY-DEAD% disables it") { + // Wiki: NOTIFY takes a 1-digit colour code 0-F, or DEAD to disable text notifications. + const Action& white = ActionOf(L"%NAME%%NOTIFY-0%"); + CHECK(white.notifyColor == 0); + CHECK(white.name == L"%NAME%"); + CHECK(ActionOf(L"%NAME%%NOTIFY-F%").notifyColor == 0xF); + const Action& dead = ActionOf(L"%NAME%%NOTIFY-DEAD%"); + CHECK(dead.notifyColor == DEAD_COLOR); + CHECK(dead.name == L"%NAME%"); +} + +TEST_CASE("legacy %MAP% uses the last colour keyword before it, white by default") { + // MAP_COLOR_* palette values that the wiki's minimap colour table confirms (Hex Average column): + // white 20, green 84, blue 97, tan 5A, orange 60, purple 9B. + const Action& plain = ActionOf(L"%NAME%%MAP%"); + CHECK(plain.colorOnMap == 0x20); + CHECK(plain.name == L"%NAME%"); + + CHECK(ActionOf(L"%GREEN%%NAME%%MAP%").colorOnMap == 0x84); + CHECK(ActionOf(L"%BLUE%%NAME%%MAP%").colorOnMap == 0x97); + CHECK(ActionOf(L"%TAN%%NAME%%MAP%").colorOnMap == 0x5A); + CHECK(ActionOf(L"%ORANGE%%NAME%%MAP%").colorOnMap == 0x60); + CHECK(ActionOf(L"%PURPLE%%NAME%%MAP%").colorOnMap == 0x9B); + // the later of two colours wins, whatever their order in the palette + CHECK(ActionOf(L"%PURPLE%x %GREEN%%NAME%%MAP%").colorOnMap == 0x84); + CHECK(ActionOf(L"%GREEN%x %PURPLE%%NAME%%MAP%").colorOnMap == 0x9B); + // a colour after %MAP% does not count + CHECK(ActionOf(L"%GREEN%%NAME%%MAP%%BLUE%!").colorOnMap == 0x84); + // DARK_GREEN is not GREEN + CHECK(ActionOf(L"%DARK_GREEN%x%BLUE%%NAME%%MAP%").colorOnMap == 0x97); +} + +TEST_CASE("legacy %MAP% also sets the border unless the rule sets one") { + const Action& a = ActionOf(L"%BLUE%%NAME%%MAP%"); + CHECK(a.borderColor == 0x97); + const Action& b = ActionOf(L"%BLUE%%NAME%%MAP%%BORDER-0A%"); + CHECK(b.borderColor == 0x0A); + CHECK(b.colorOnMap == 0x97); +} + +// BUG: BuildAction's legacy %MAP% handling picks "the last colour keyword before %MAP%" (the colour +// the text is in at that point, as the test above shows), but it only looks at the FIRST occurrence +// of each colour keyword (name.find). When a colour is used again after another colour +// (%BLUE% .. %GREEN% .. %BLUE%%MAP%), the blue in effect at %MAP% loses to green and the minimap icon +// gets the wrong colour. Searching for the last occurrence before %MAP% (rfind) fixes it. +TEST_CASE("legacy %MAP% uses a colour keyword repeated just before it" * doctest::should_fail()) { + CHECK(ActionOf(L"%BLUE%x %GREEN%y %BLUE%%NAME%%MAP%").colorOnMap == 0x97); +} + +TEST_CASE("%TIER-n% sets the notification tier and is removed from the name") { + const Action& zero = ActionOf(L"%NAME%%TIER-0%"); + CHECK(zero.pingLevel == 0); + CHECK(zero.name == L"%NAME%"); + const Action& nine = ActionOf(L"%NAME%%tier-9%%DOT-97%"); + CHECK(nine.pingLevel == 9); + CHECK(nine.dotColor == 0x97); + CHECK(nine.name == L"%NAME%"); +} + +// BUG: ParsePingLevel only accepts one digit (%TIER-([0-9])%). The wiki's keyword table says of +// %TIER-0% "(value can be 0-12)", and the Filter Strictness section says up to 12 custom levels can be +// enabled, referenced as FILTLVL=n / %TIER-n%. %TIER-10%..%TIER-12% are ignored (the rule then +// notifies at every level) and the raw "%TIER-12%" text is shown in the item's name. Accepting +// [0-9]{1,2} fixes it. +TEST_CASE("%TIER-n% accepts the two-digit tiers 10 to 12" * doctest::should_fail()) { + const Action& a = ActionOf(L"%NAME%%TIER-12%"); + CHECK(a.pingLevel == 12); + CHECK(a.name == L"%NAME%"); +} + +TEST_CASE("%SOUNDID-n% plays only sounds that exist in sounds.txt") { + // Wiki: indices 4714-4729 are the PoE drop sounds, so a sounds.txt with 4730 records has them. + fake::Var(Var_D2CLIENT_SoundRecords) = 4730; + const Action& last = ActionOf(L"%NAME%%SOUNDID-4729%"); + CHECK(last.soundID == 4729); + CHECK(last.name == L"%NAME%"); + CHECK(ActionOf(L"%NAME%%soundid-4714%").soundID == 4714); + // one past the last record: no sound, keyword still removed + const Action& past = ActionOf(L"%NAME%%SOUNDID-4730%"); + CHECK(past.soundID == 0); + CHECK(past.name == L"%NAME%"); + CHECK(ActionOf(L"%NAME%%SOUNDID-9999%").soundID == 0); +} + +TEST_CASE("%SOUNDID-n% plays nothing before sounds.txt is loaded") { + fake::Var(Var_D2CLIENT_SoundRecords) = 0; + CHECK(ActionOf(L"%NAME%%SOUNDID-1%").soundID == 0); +} + +// ---- Value keywords -------------------------------------------------------------------------- + +TEST_CASE("%BASENAME% is the base item name without its colour prefix") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.attrs().name = L"Hand Axe"; + CHECK(NameWith("%NAME% (%BASENAME%)", item, L"The Gnasher") == L"The Gnasher (Hand Axe)"); + TestItem rune("r30", ITEM_QUALITY_NORMAL); + rune.attrs().name = Color(L"8") + L"Ber Rune"; + CHECK(NameWith("%BASENAME%", rune, L"Ber Rune") == L"Ber Rune"); +} + +TEST_CASE("item property keywords show the item's values") { + TestItem item("hax", ITEM_QUALITY_NORMAL); + item.ItemLevel(87).Stat(STAT_SOCKETS, 4); + item.txt().dwspeed = static_cast(-10); + item.txt().brangeadder = 2; + CHECK(NameWith("%CODE% ilvl%ILVL% os%SOCKETS% spd%WPNSPD% rng%RANGE%", item) == L"hax ilvl87 os4 spd-10 rng2"); + + TestItem arrows("aqv", ITEM_QUALITY_NORMAL); + arrows.Stat(STAT_AMMOQUANTITY, 350); + CHECK(NameWith("%NAME% x%QTY%", arrows, L"Arrows") == L"Arrows x350"); +} + +TEST_CASE("%RUNENUM% and %RUNENAME% describe runes; other items show 0 and nothing") { + TestItem vex("r26", ITEM_QUALITY_NORMAL); + vex.attrs().miscFlags = ITEM_GROUP_RUNE; + vex.attrs().name = L"Vex Rune"; + CHECK(NameWith("%RUNENAME% (#%RUNENUM%)", vex, L"Vex Rune") == L"Vex (#26)"); + + TestItem el("r01", ITEM_QUALITY_NORMAL); + el.attrs().miscFlags = ITEM_GROUP_RUNE; + el.attrs().name = L"El Rune"; + CHECK(NameWith("%RUNENAME% %RUNENUM%", el, L"El Rune") == L"El 1"); + + TestItem axe("hax", ITEM_QUALITY_NORMAL); + axe.attrs().name = L"Hand Axe"; + CHECK(NameWith("[%RUNENAME%][%RUNENUM%]", axe) == L"[][0]"); +} + +TEST_CASE("%GEMLEVEL% and %GEMTYPE% describe gems and are empty for other items") { + TestItem gem("glr", ITEM_QUALITY_NORMAL); + gem.attrs().miscFlags = ITEM_GROUP_RUBY | ITEM_GROUP_FLAWLESS; + CHECK(NameWith("%GEMLEVEL% %GEMTYPE%", gem) == L"Flawless Ruby"); + gem.attrs().miscFlags = ITEM_GROUP_SKULL | ITEM_GROUP_CHIPPED; + CHECK(NameWith("%GEMLEVEL% %GEMTYPE%", gem) == L"Chipped Skull"); + gem.attrs().miscFlags = ITEM_GROUP_AMETHYST | ITEM_GROUP_PERFECT; + CHECK(NameWith("%GEMLEVEL% %GEMTYPE%", gem) == L"Perfect Amethyst"); + + TestItem axe("hax", ITEM_QUALITY_NORMAL); + CHECK(NameWith("[%GEMLEVEL%][%GEMTYPE%]", axe) == L"[][]"); +} + +TEST_CASE("%ALVL% follows the D2 affix level formula") { + // alvl = ilvl - qlvl/2 if ilvl < 99 - qlvl/2, else 2*ilvl - 99; magic level adds to ilvl (max 99). + TestItem item("wnd", ITEM_QUALITY_MAGIC); + item.ItemLevel(40); + item.attrs().qualityLevel = 20; + CHECK(NameWith("%ALVL%", item) == L"30"); + + item.ItemLevel(85); + item.attrs().qualityLevel = 60; // 85 >= 99 - 30 + CHECK(NameWith("%ALVL%", item) == L"71"); + + item.ItemLevel(90); + item.attrs().qualityLevel = 10; + item.attrs().magicLevel = 3; + CHECK(NameWith("%ALVL%", item) == L"93"); + + item.ItemLevel(98); + item.attrs().qualityLevel = 0; + CHECK(NameWith("%ALVL%", item) == L"99"); +} + +TEST_CASE("%CRAFTALVL% uses half the character level plus half the item level") { + // PD2 crafting: crafted ilvl = floor(clvl/2) + floor(ilvl/2), then the usual affix level formula. + fake::SetStat(&fake::Player(), STAT_LEVEL, 91); + TestItem item("hbl", ITEM_QUALITY_MAGIC); + item.ItemLevel(85); + // 45 + 42 = 87, qlvl 0 -> alvl 87 + CHECK(NameWith("%CRAFTALVL%", item) == L"87"); + + item.attrs().qualityLevel = 40; + // crafted ilvl 87 >= 99 - 20 -> 2*87 - 99 = 75 + CHECK(NameWith("%CRAFTALVL%", item) == L"75"); +} + +TEST_CASE("%LVLREQ% of a magic or rare item is the lowest requirement over all classes") { + // Charges of a class skill raise the requirement for the other classes; BH shows the lowest one + // (GetRequiredLevel: "the (lowest) level requirement (for any class)") whatever the player's class. + fake::Player().dwTxtFileNo = 4; // Barbarian + TestItem rare("hax", ITEM_QUALITY_RARE); + fake::SetLevelRequirement(rare.unit(), 30); + fake::SetLevelRequirement(rare.unit(), 24, 2); // Necromancer + CHECK(NameWith("L%LVLREQ%", rare) == L"L24"); + CHECK(fake::Player().dwTxtFileNo == 4); // the player is a Barbarian again afterwards + + TestItem magic("wnd", ITEM_QUALITY_MAGIC); + fake::SetLevelRequirement(magic.unit(), 18); + fake::SetLevelRequirement(magic.unit(), 12, 6); // Assassin, the last class + CHECK(NameWith("L%LVLREQ%", magic) == L"L12"); + CHECK(fake::Player().dwTxtFileNo == 4); +} + +TEST_CASE("%PRICE% and %SELLPRICE% are the vendor sell price, %BUYPRICE% the buy price") { + // Wiki: %PRICE% is the price when selling to a vendor, %SELLPRICE% is identical, %BUYPRICE% is + // the price when buying from a vendor. + TestItem item("hax", ITEM_QUALITY_RARE); + fake::SetPrice(item.unit(), TRANSACTIONTYPE_SELL, 5000); + fake::SetPrice(item.unit(), TRANSACTIONTYPE_BUY, 20000); + CHECK(NameWith("%NAME% $%PRICE% $%SELLPRICE% $%BUYPRICE%", item, L"Axe") == L"Axe $5000 $5000 $20000"); +} + +TEST_CASE("quest items have no %PRICE%") { + TestItem key("hdm", ITEM_QUALITY_NORMAL); + key.txt().bquest = 1; + fake::SetPrice(key.unit(), TRANSACTIONTYPE_SELL, 1); + CHECK(NameWith("%NAME%[%PRICE%]", key, L"Horadric Malus") == L"Horadric Malus[]"); +} + +TEST_CASE("%RES% is the lowest of the four resistances, 0 unless all four are present") { + TestItem ring("rin", ITEM_QUALITY_RARE); + ring.Stat(STAT_FIRERESIST, 30).Stat(STAT_LIGHTNINGRESIST, 25).Stat(STAT_COLDRESIST, 40); + CHECK(NameWith("@%RES%", ring) == L"@0"); + ring.Stat(STAT_POISONRESIST, 35); + CHECK(NameWith("@%RES%", ring) == L"@25"); +} + +TEST_CASE("%ED% is the item's own enhanced defense or damage, %EDEF%/%EDAM% include runeword bonuses") { + // Wiki: ED refers to defense for armor and damage for weapons, and does not include bonuses from + // runewords or sockets; EDEF/EDAM do. + TestItem weapon("hax", ITEM_QUALITY_NORMAL); + weapon.Flags(ITEM_IDENTIFIED | ITEM_RUNEWORD); + weapon.Stat(STAT_ENHANCEDMAXIMUMDAMAGE, 50).Stat(STAT_ENHANCEDDEFENSE, 9); + fake::SetRunewordStat(weapon.unit(), STAT_ENHANCEDMAXIMUMDAMAGE, 200); + CHECK(NameWith("%ED% %EDAM%", weapon) == L"50 250"); + + TestItem armor("xtp", ITEM_QUALITY_SUPERIOR); + armor.attrs().armorFlags = ITEM_GROUP_ALLARMOR; + armor.Stat(STAT_ENHANCEDDEFENSE, 15).Stat(STAT_ENHANCEDMAXIMUMDAMAGE, 7); + CHECK(NameWith("%ED% %EDEF%", armor) == L"15 15"); +} + +TEST_CASE("stat keywords show the item's stat values") { + TestItem item("rin", ITEM_QUALITY_RARE); + item.Stat(STAT_LIFELEECH, 7).Stat(STAT_STRENGTH, 15).Stat(STAT_FASTERCAST, 10); + item.Stat(STAT_MAXHP, 25 * 256); // life is stored in 1/256 points + CHECK(NameWith("%STAT60% %STR% %FCR% %LIFE% %DEX%", item) == L"7 15 10 25 0"); +} + +TEST_CASE("skill keywords read the skill's layer of the skill stats") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(STAT_SINGLESKILL, 3, 48); // +3 Nova + item.Stat(STAT_NONCLASSSKILL, 20, 74); // +20 Corpse Explosion (oskill) + item.Stat(STAT_CLASSSKILLS, 2, 2); // +2 Necromancer skills + item.Stat(STAT_SKILLTAB, 4, 25); // +4 skill tab 25 + CHECK(NameWith("%SK48% %OS74% %CLSK2% %TABSK25% %SK49%", item) == L"3 20 2 4 0"); +} + +TEST_CASE("%MULTIstat,layer% reads any layered stat and %CHARSTATn% reads the character") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(195, 15, 3028); // 15% chance to cast (skill 47 level 20) on attack + fake::SetStat(&fake::Player(), STAT_STRENGTH, 150); + CHECK(NameWith("%MULTI195,3028% %MULTI195,3029% %CHARSTAT0%", item) == L"15 0 150"); +} + +TEST_CASE("stat keywords read the last stat of the table and nothing past it") { + // STAT_MAX / SKILL_MAX are the ItemStatCost.txt / Skills.txt row counts (359 / 357 by default). + TestItem item("hax", ITEM_QUALITY_UNIQUE); + item.Stat(358, 5); + fake::SetStat(&fake::Player(), 358, 6); + item.Stat(358, 7, 1); + CHECK(NameWith("[%STAT358%][%CHARSTAT358%][%MULTI358,1%]", item) == L"[5][6][7]"); + CHECK(NameWith("[%STAT360%][%CHARSTAT360%][%MULTI360,1%]", item) == L"[][][]"); + CHECK(NameWith("[%SK358%][%OS358%][%CLSK358%][%TABSK358%]", item) == L"[][][][]"); +} + +// ---- New lines, conditional spaces and lines ------------------------------------------------- + +TEST_CASE("%NL% breaks item names only for identified magic+, runeword and superior/staffmod items") { + // Wiki: %NL% works within descriptions, or for ID !NMAG, RW or SHOP items. + TestItem magic("rin", ITEM_QUALITY_MAGIC); + CHECK(NameWith("A%NL%B", magic) == L"A\nB"); + + TestItem plain("hax", ITEM_QUALITY_NORMAL); + CHECK(NameWith("A%NL%B", plain) == L"AB"); + + TestItem unid("rin", ITEM_QUALITY_RARE); + unid.Flags(0); + CHECK(NameWith("A%NL%B", unid) == L"AB"); + + TestItem runeword("hax", ITEM_QUALITY_NORMAL); + runeword.Flags(ITEM_IDENTIFIED | ITEM_RUNEWORD); + CHECK(NameWith("A%NL%B%NL%C", runeword) == L"A\nB\nC"); +} + +TEST_CASE("non-magic items that can carry staffmods or superior mods get a single %NL% in the name") { + // ReplaceContext: "non-mag item capable of having staffmods or similar mods" may show one line. + TestItem superior("hax", ITEM_QUALITY_SUPERIOR); + CHECK(NameWith("A%NL%B%NL%C", superior) == L"A\nBC"); + + TestItem staff("sst", ITEM_QUALITY_NORMAL); + staff.attrs().staffmodClass = CLASS_SOR; + CHECK(NameWith("A%NL%B%CL%C", staff) == L"A\nBC"); +} + +TEST_CASE("%NL% always works in descriptions") { + TestItem plain("hax", ITEM_QUALITY_NORMAL); + plain.ItemLevel(30); + CHECK(DescriptionWith("{Affix Level: %ALVL%%NL%Item Level: %ILVL%}", plain) == L"Affix Level: 30\nItem Level: 30"); +} + +TEST_CASE("%CL% starts a new line only between non-empty lines") { + // Wiki: like %NL% but never creates blank lines or consecutive new lines. + TestItem item("hax", ITEM_QUALITY_NORMAL); + CHECK(DescriptionWith("{A%CL%B}", item) == L"A\nB"); + CHECK(DescriptionWith("{A%CL%%CL%%CL%B}", item) == L"A\nB"); + CHECK(DescriptionWith("{%CL%A%CL%}", item) == L"A"); + CHECK(DescriptionWith("{A%NL%%CL%B%CL%%NL%C}", item) == L"A\nB\nC"); +} + +TEST_CASE("%CS% adds a space only between two non-whitespace characters") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith("A%CS%B|A %CS%B|A%CS% B|A%CS%%CS%B", item) == L"A B|A B|A B|A B"); + CHECK(NameWith("%CS%A%CS%", item) == L"A"); + // an empty keyword between two conditional spaces leaves just one space + CHECK(NameWith("%NAME%%CS%%GEMTYPE%%CS%!", item, L"Axe") == L"Axe !"); +} + +// ---- Length limits --------------------------------------------------------------------------- + +TEST_CASE("item names are capped at 56 displayed characters") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith(std::string(56, 'x'), item) == std::wstring(56, L'x')); + CHECK(NameWith(std::string(57, 'x'), item) == std::wstring(56, L'x')); + CHECK(NameWith("%NAME%%NAME%", item, std::wstring(40, L'y')) == std::wstring(56, L'y')); +} + +TEST_CASE("colour codes do not count toward the 56-character name cap") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(NameWith("%RED%" + std::string(28, 'a') + "%BLUE%" + std::string(30, 'b'), item) == + Color(L"1") + std::wstring(28, L'a') + Color(L"3") + std::wstring(28, L'b')); + // a colour right after the 56th character is kept (it colours a shop item's price) + CHECK(NameWith("%GOLD%" + std::string(56, 'x') + "%WHITE%", item) == + Color(L"4") + std::wstring(56, L'x') + Color(L"0")); + // a colour beyond the cap is dropped with the text it would colour + CHECK(NameWith(std::string(58, 'x') + "%WHITE%y", item) == std::wstring(56, L'x')); +} + +TEST_CASE("descriptions are not limited to the name cap") { + TestItem item("hax", ITEM_QUALITY_UNIQUE); + CHECK(DescriptionWith("{" + std::string(300, 'd') + "}", item) == std::wstring(300, L'd')); +} + +TEST_CASE("tome names and descriptions are capped at 126 characters") { + // Constants.h: TP & ID tomes have a smaller text limit (127 including the terminator). + TestItem tome("tbk", ITEM_QUALITY_NORMAL); + CHECK(DescriptionWith("{" + std::string(200, 'd') + "}", tome) == std::wstring(126, L'd')); + TestItem idTome("ibk", ITEM_QUALITY_NORMAL); + CHECK(DescriptionWith("{" + std::string(127, 'd') + "}", idTome) == std::wstring(126, L'd')); + + // Outside a shop any name stops at 56; a tome in a shop gets the long shop limit, cut to 126. + UnitAny akara = {}; + akara.dwTxtFileNo = NPCID_Akara; + Inventory shop = {}; + shop.pOwner = &akara; + tome.data().pOwnerInventory = &shop; + CHECK(NameWith(std::string(200, 'n'), tome) == std::wstring(126, L'n')); +} + +TEST_CASE("items in a shop allow longer names and %NL% in the name") { + UnitAny charsi = {}; + charsi.dwTxtFileNo = NPCID_Charsi; + Inventory shop = {}; + shop.pOwner = &charsi; + + TestItem item("hax", ITEM_QUALITY_NORMAL); + item.data().pOwnerInventory = &shop; + CHECK(NameWith(std::string(60, 'x') + "%NL%y", item) == std::wstring(60, L'x') + L"\ny"); + + // the same item outside a shop + UnitAny player = {}; + Inventory own = {}; + own.pOwner = &player; + item.data().pOwnerInventory = &own; + CHECK(NameWith(std::string(60, 'x') + "%NL%y", item) == std::wstring(56, L'x')); +} + +} // TEST_SUITE diff --git a/tests/LootFilterRulesTests.cpp b/tests/LootFilterRulesTests.cpp new file mode 100644 index 00000000..b30757d4 --- /dev/null +++ b/tests/LootFilterRulesTests.cpp @@ -0,0 +1,637 @@ +// Loot filter rule-list behaviour, end to end through a real filter file (Config::Parse + +// ItemDisplay::InitializeItemRules): rule order, %CONTINUE%, hiding, filter levels, aliases, +// formulas, filter level names, notification (map) rules and the per-item lookup caches. +// Expected behaviour follows the PD2 filter documentation +// (https://wiki.projectdiablo2.com/wiki/Item_Filtering) unless a test says otherwise. +#include "doctest/doctest.h" + +#include +#include + +#include "BH.h" +#include "BHGlobals.h" +#include "FakeEngine.h" +#include "LootFilter.h" + +using support::DescriptionOf; +using support::LoadFilter; +using support::NameOf; +using support::TestItem; + +namespace { + +void SetFilterLevel(unsigned int level) { + App.lootfilter.filterLevel.uValue = level; +} + +bool Contains(const std::vector& list, const Rule* rule) { + for (const Rule* r : list) { + if (r == rule) { + return true; + } + } + return false; +} + +// Loads a one-rule filter and reports whether the rule is a notification rule (MapRuleList, which +// MapNotify and Item.cpp's drop handling walk). +bool IsNotificationRule(const std::string& action) { + LoadFilter("ItemDisplay[hax]: " + action + "\n"); + REQUIRE(RuleList.size() == 1); + return Contains(MapRuleList, RuleList[0]); +} + +} // namespace + +TEST_SUITE("LootFilterRules") { + +// ---- Rule order ---------------------------------------------------------------------------- + +TEST_CASE("The first matching rule decides the name and later matching rules are not applied") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + TestItem sword("ssd", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: First %NAME%\n" + "ItemDisplay[UNI]: Second %NAME%\n" + "ItemDisplay[]: Third %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"First The Gnasher"); + // The first rule does not match the sword, so the next matching one applies. + CHECK(NameOf(sword, L"Rixot's Keen") == L"Second Rixot's Keen"); +} + +TEST_CASE("An item no rule matches keeps its default name") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[SET]: Set %NAME%\n" + "ItemDisplay[ssd]: Sword %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); +} + +TEST_CASE("An empty filter leaves names unchanged") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter(""); + CHECK(RuleList.empty()); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); +} + +TEST_CASE("A hiding rule above a showing rule hides the item, and below it does not") { + SUBCASE("hide first") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]:\n" + "ItemDisplay[]: Shown %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L""); + } + SUBCASE("show first") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[]: Shown %NAME%\n" + "ItemDisplay[hax]:\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"Shown The Gnasher"); + } +} + +TEST_CASE("A rule without conditions applies to every item") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + TestItem charm("cm1", ITEM_QUALITY_MAGIC); + LoadFilter("ItemDisplay[]: Any %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"Any The Gnasher"); + CHECK(NameOf(charm, L"Small Charm") == L"Any Small Charm"); +} + +TEST_CASE("One leading and one trailing space of the game's name are dropped before rules apply") { + // Magic items without a prefix/suffix come with a stray space (comment in make_cached_T). + TestItem ring("rin", ITEM_QUALITY_MAGIC); + LoadFilter("ItemDisplay[]: [%NAME%]\n"); + CHECK(NameOf(ring, L" Ring of Ice ") == L"[Ring of Ice]"); + TestItem ring2("rin", ITEM_QUALITY_MAGIC); + CHECK(NameOf(ring2, L" Ring ") == L"[ Ring ]"); +} + +// ---- %CONTINUE% ------------------------------------------------------------------------------- + +TEST_CASE("%CONTINUE% feeds a rule's output into %NAME% of the next matching rule") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: %NAME% [axe]%CONTINUE%\n" + "ItemDisplay[SET]: never %NAME%\n" + "ItemDisplay[UNI]: unique %NAME%%CONTINUE%\n" + "ItemDisplay[]: <%NAME%>\n" + "ItemDisplay[]: too late %NAME%\n"); + // Wiki: each %CONTINUE% output replaces %NAME%; the chain ends at the first matching rule + // without %CONTINUE%; non-matching rules in between are skipped. + CHECK(NameOf(axe, L"The Gnasher") == L""); +} + +TEST_CASE("A %CONTINUE% chain that no later rule ends shows the last continued output") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: %NAME% [axe]%CONTINUE%\n" + "ItemDisplay[SET]: never %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher [axe]"); +} + +TEST_CASE("%CONTINUE% does not apply when its rule does not match") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[ETH]: eth %NAME%%CONTINUE%\n" + "ItemDisplay[]: %NAME%!\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher!"); + TestItem eth("hax", ITEM_QUALITY_UNIQUE); + eth.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + CHECK(NameOf(eth, L"The Gnasher") == L"eth The Gnasher!"); +} + +TEST_CASE("A hiding rule after a %CONTINUE% rule still hides the item") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[]: %NAME% [x]%CONTINUE%\n" + "ItemDisplay[hax]:\n" + "ItemDisplay[]: %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L""); +} + +TEST_CASE("Descriptions follow the same first-match and %CONTINUE% chaining as names") { + SUBCASE("first match wins") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: %NAME%{first}\n" + "ItemDisplay[]: %NAME%{second}\n"); + CHECK(DescriptionOf(axe) == L"first"); + } + SUBCASE("continued description is %NAME% inside the next rule's braces") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: %NAME%{A}%CONTINUE%\n" + "ItemDisplay[]: %NAME%{%NAME% B}\n"); + CHECK(DescriptionOf(axe) == L"A B"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); + } + SUBCASE("a final rule without braces leaves no description") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: %NAME%{A}%CONTINUE%\n" + "ItemDisplay[]: %NAME%\n"); + CHECK(DescriptionOf(axe) == L""); + } + SUBCASE("a hidden item still shows its description") { + // Wiki: "ItemDisplay[]: {%NAME%} items hidden but their descriptions still shown". + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter("ItemDisplay[hax]: {Hidden but described}\n"); + CHECK(NameOf(axe, L"The Gnasher") == L""); + CHECK(DescriptionOf(axe) == L"Hidden but described"); + } +} + +// ---- Hiding and filter levels ------------------------------------------------------------- + +TEST_CASE("A rule with an empty output hides the item") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + TestItem sword("ssd", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]:\n" + "ItemDisplay[ssd]: %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L""); + CHECK(NameOf(sword, L"Rixot's Keen") == L"Rixot's Keen"); +} + +TEST_CASE("Filter level 0 (Show All Items) shows hidden items with their default name") { + SetFilterLevel(0); + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + TestItem charm("cm1", ITEM_QUALITY_MAGIC); + LoadFilter( + "ItemDisplay[hax]:\n" + "ItemDisplay[cm1]: Charm %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); + // Level 0 only unhides; rules that rename still rename. + CHECK(NameOf(charm, L"Small Charm") == L"Charm Small Charm"); +} + +TEST_CASE("Switching to filter level 0 reveals hidden items, switching back hides them") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter("ItemDisplay[hax]:\n"); + SetFilterLevel(1); + CHECK(NameOf(axe, L"The Gnasher") == L""); + SetFilterLevel(0); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); + SetFilterLevel(1); + CHECK(NameOf(axe, L"The Gnasher") == L""); +} + +TEST_CASE("FILTLVL conditions select rules by the chosen filter level") { + const char* filter = + "ItemDisplayFilterName[]: Relaxed\n" + "ItemDisplayFilterName[]: Strict\n" + "ItemDisplay[FILTLVL>1 hax]:\n" + "ItemDisplay[FILTLVL=1 hax]: Relaxed %NAME%\n"; + SUBCASE("level 2 hides") { + SetFilterLevel(2); + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter(filter); + CHECK(NameOf(axe, L"The Gnasher") == L""); + } + SUBCASE("level 1 renames") { + SetFilterLevel(1); + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter(filter); + CHECK(NameOf(axe, L"The Gnasher") == L"Relaxed The Gnasher"); + } + SUBCASE("level 0 matches neither") { + SetFilterLevel(0); + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter(filter); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); + } +} + +// ---- Filter level names ------------------------------------------------------------------- + +TEST_CASE("ItemDisplayFilterName entries are numbered after the built-in level 0") { + LoadFilter( + "ItemDisplayFilterName[]: Low Strictness\n" + "ItemDisplay[]: %NAME%\n" + "ItemDisplayFilterName[ignored]: High Strictness\n"); + std::vector expected = {"0 - Show All Items", "1 - Low Strictness", "2 - High Strictness"}; + CHECK(fake::ItemModule().ItemFilterNames == expected); +} + +TEST_CASE("Without ItemDisplayFilterName entries the levels are Show All Items and Standard") { + LoadFilter("ItemDisplay[]: %NAME%\n"); + std::vector expected = {"0 - Show All Items", "1 - Standard"}; + CHECK(fake::ItemModule().ItemFilterNames == expected); +} + +TEST_CASE("At most 12 custom filter levels are taken") { + std::string filter; + for (int i = 1; i <= 15; i++) { + filter += "ItemDisplayFilterName[]: L" + std::to_string(i) + "\n"; + } + LoadFilter(filter); + const auto& names = fake::ItemModule().ItemFilterNames; + REQUIRE(names.size() == 13); // level 0 + 12 custom levels (wiki: "up to 12 other levels") + CHECK(names[1] == "1 - L1"); + CHECK(names[12] == "12 - L12"); +} + +TEST_CASE("A selected filter level the loaded filter does not define falls back to level 1") { + const char* twoLevels = + "ItemDisplayFilterName[]: A\n" + "ItemDisplayFilterName[]: B\n"; + SUBCASE("one past the last level") { + SetFilterLevel(3); + LoadFilter(twoLevels); + CHECK(App.lootfilter.filterLevel.uValue == 1); + } + SUBCASE("the last level is kept") { + SetFilterLevel(2); + LoadFilter(twoLevels); + CHECK(App.lootfilter.filterLevel.uValue == 2); + } + SUBCASE("level 0 is always kept") { + SetFilterLevel(0); + LoadFilter(""); + CHECK(App.lootfilter.filterLevel.uValue == 0); + } + SUBCASE("a filter without names only has level 1 besides 0") { + SetFilterLevel(2); + LoadFilter("ItemDisplay[]: %NAME%\n"); + CHECK(App.lootfilter.filterLevel.uValue == 1); + } +} + +// ---- Filter file syntax -------------------------------------------------------------------- + +TEST_CASE("Comments and blank lines in a filter file are ignored") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + TestItem sword("ssd", ITEM_QUALITY_UNIQUE); + TestItem charm("cm1", ITEM_QUALITY_MAGIC); + LoadFilter( + "// a header comment\n" + "\n" + " \n" + "//ItemDisplay[hax]: disabled rule\n" + "ItemDisplay[hax]: Axe %NAME% // trailing note\n" + "\t\n" + "ItemDisplay[ssd]: //%NAME%\n" + "ItemDisplay[cm1]: Charm\n"); + REQUIRE(RuleList.size() == 3); + CHECK(NameOf(axe, L"The Gnasher") == L"Axe The Gnasher"); + // Wiki: "ItemDisplay[tsc]: //%NAME% this rule hides TP scrolls". + CHECK(NameOf(sword, L"Rixot's Keen") == L""); + CHECK(NameOf(charm, L"Small Charm") == L"Charm"); +} + +TEST_CASE("Filter files with Windows line endings work") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplayFilterName[]: Only\r\n" + "ItemDisplay[hax]: Found %NAME%%CONTINUE%\r\n" + "ItemDisplay[UNI]: %NAME%!\r\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"Found The Gnasher!"); + CHECK(fake::ItemModule().ItemFilterNames.back() == "1 - Only"); +} + +TEST_CASE("Spaces then tabs around a rule's output are trimmed, so tabs protect highlight spaces") { + // Wiki: "Whitespace surrounding the Output of each rule gets removed prior to evaluation + // (spaces first, followed by tabs), so tabs are often the best non-space character to use." + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter("ItemDisplay[hax]: \t %NAME% \t \n"); + CHECK(NameOf(axe, L"The Gnasher") == L" The Gnasher "); +} + +TEST_CASE("Lines that are not ItemDisplay rules do not become rules") { + LoadFilter( + "Alias[X]: UNI\n" + "Formula[F]: 1\n" + "ItemDisplayFilterName[]: Level\n" + "NotARule[hax]: x\n" + "some text without a colon\n" + "ItemDisplay[hax]: one\n"); + CHECK(RuleList.size() == 1); +} + +// ---- Reload -------------------------------------------------------------------------------- + +TEST_CASE("Reloading a filter replaces the old rules, aliases and level names") { + LoadFilter( + "Alias[TAG]: [old]\n" + "ItemDisplayFilterName[]: Old A\n" + "ItemDisplayFilterName[]: Old B\n" + "ItemDisplay[hax]: Old %NAME%%TAG%\n" + "ItemDisplay[hax]: %NAME%%MAP-55%\n" + "ItemDisplay[ssd]:\n"); + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + TestItem sword("ssd", ITEM_QUALITY_UNIQUE); + CHECK(NameOf(axe, L"The Gnasher") == L"Old The Gnasher[old]"); + CHECK(NameOf(sword, L"Rixot's Keen") == L""); + + LoadFilter( + "ItemDisplayFilterName[]: New\n" + "ItemDisplay[hax]: New %NAME%%TAG%\n"); + CHECK(RuleList.size() == 1); + CHECK(MapRuleList.empty()); + // Same unit: the reload must not serve the name cached from the old filter. TAG is no longer + // an alias, so it is an unknown keyword, shown as typed. + CHECK(NameOf(axe, L"The Gnasher") == L"New The Gnasher%TAG%"); + CHECK(NameOf(sword, L"Rixot's Keen") == L"Rixot's Keen"); + std::vector expected = {"0 - Show All Items", "1 - New"}; + CHECK(fake::ItemModule().ItemFilterNames == expected); +} + +TEST_CASE("Initializing again without uninitializing keeps the loaded rules unchanged") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: %NAME% +%CONTINUE%\n" + "ItemDisplay[]: %NAME%\n"); + ItemDisplay::InitializeItemRules(); + CHECK(RuleList.size() == 2); + CHECK(NameOf(axe, L"Axe") == L"Axe +"); +} + +TEST_CASE("Uninitializing removes every rule") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]:\n" + "ItemDisplay[hax]: %NAME%%MAP-55%\n"); + ItemDisplay::UninitializeItemRules(); + CHECK(RuleList.empty()); + CHECK(MapRuleList.empty()); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); +} + +// ---- Aliases ------------------------------------------------------------------------------- + +TEST_CASE("An alias in conditions is replaced by its text before the rule is parsed") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + TestItem sword("ssd", ITEM_QUALITY_UNIQUE); + TestItem setAxe("hax", ITEM_QUALITY_SET); + LoadFilter( + "Alias[GOODBASE]: (hax OR axe)\n" + "ItemDisplay[GOODBASE UNI]: Good %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"Good The Gnasher"); + CHECK(NameOf(sword, L"Rixot's Keen") == L"Rixot's Keen"); + // The parentheses from the alias keep "hax OR axe" grouped before the implicit AND. + CHECK(NameOf(setAxe, L"Set Axe") == L"Set Axe"); +} + +TEST_CASE("Aliases can be defined after the rules that use them") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[MYAXE]: %NAME%%TAG%\n" + "Alias[MYAXE]: hax\n" + "Alias[TAG]: !\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher!"); +} + +TEST_CASE("An alias in output is written as %KEY% and every use is replaced") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "Alias[TAG]: [T]\n" + "ItemDisplay[hax]: %TAG%%NAME%%TAG%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"[T]The Gnasher[T]"); +} + +TEST_CASE("An alias may expand to output keywords") { + // Wiki example: Alias[SOCKETCOUNT]: %WHITE% [%GRAY%%SOCK%%WHITE%] + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "Alias[MARK]: %CONTINUE%\n" + "ItemDisplay[hax]: %NAME% marked%MARK%\n" + "ItemDisplay[]: <%NAME%>\n"); + CHECK(NameOf(axe, L"The Gnasher") == L""); +} + +TEST_CASE("An alias defined in lower case is used as an upper-case output keyword") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "Alias[tag]: [T]\n" + "ItemDisplay[hax]: %NAME%%TAG%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher[T]"); +} + +TEST_CASE("An alias key is trimmed and limited to its first word") { + TestItem low("hax", ITEM_QUALITY_UNIQUE); + TestItem high("hax", ITEM_QUALITY_UNIQUE); + high.ItemLevel(60); + LoadFilter( + "Alias[ HIGH level items ]: ILVL>50\n" + "ItemDisplay[HIGH]: High %NAME%\n"); + CHECK(NameOf(high, L"Axe") == L"High Axe"); + CHECK(NameOf(low, L"Axe") == L"Axe"); +} + +TEST_CASE("An alias with an empty key is ignored") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "Alias[]: SET\n" + "Alias[ ]: SET\n" + "ItemDisplay[UNI]: Unique %NAME%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"Unique The Gnasher"); +} + +// ---- Formulas ------------------------------------------------------------------------------ + +TEST_CASE("A Formula is referenced from conditions as FORMULA") { + TestItem low("hax", ITEM_QUALITY_UNIQUE); + TestItem high("hax", ITEM_QUALITY_UNIQUE); + high.ItemLevel(60); + LoadFilter( + "ItemDisplay[FORMULAHIGH]: High %NAME%\n" + "Formula[HIGH]: ilvl>50\n"); + CHECK(NameOf(high, L"Axe") == L"High Axe"); + CHECK(NameOf(low, L"Axe") == L"Axe"); +} + +TEST_CASE("A Formula is referenced from output as %FORMULA%") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + axe.ItemLevel(21); + LoadFilter( + "Formula[_TWICE]: ilvl*2\n" + "ItemDisplay[hax]: %NAME% %FORMULA_TWICE%\n"); + CHECK(NameOf(axe, L"Axe") == L"Axe 42"); +} + +TEST_CASE("Formulas do not survive a reload of a filter that no longer defines them") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + axe.ItemLevel(21); + LoadFilter( + "Formula[_TWICE]: ilvl*2\n" + "ItemDisplay[hax]: %NAME% %FORMULA_TWICE%\n"); + LoadFilter("ItemDisplay[hax]: %NAME% %FORMULA_TWICE%\n"); + // An unknown keyword is shown as typed. + CHECK(NameOf(axe, L"Axe") == L"Axe %FORMULA_TWICE%"); +} + +// ---- Notification (map) rules --------------------------------------------------------------- + +TEST_CASE("Rules with a map, border, dot, px, line or sound keyword are notification rules") { + fake::Var(Var_D2CLIENT_SoundRecords) = 5000; + SUBCASE("MAP") { + CHECK(IsNotificationRule("%NAME%%MAP-55%")); + CHECK(RuleList[0]->action.colorOnMap == 0x55); + } + SUBCASE("BORDER") { + CHECK(IsNotificationRule("%NAME%%BORDER-9B%")); + CHECK(RuleList[0]->action.borderColor == 0x9B); + } + SUBCASE("DOT") { + CHECK(IsNotificationRule("%NAME%%DOT-20%")); + } + SUBCASE("PX") { + CHECK(IsNotificationRule("%NAME%%PX-0B%")); + } + SUBCASE("LINE") { + CHECK(IsNotificationRule("%NAME%%LINE-62%")); + } + SUBCASE("SOUNDID within the game's sound table") { + CHECK(IsNotificationRule("%NAME%%SOUNDID-4714%")); + CHECK(RuleList[0]->action.soundID == 4714); + } + SUBCASE("legacy %MAP%") { + CHECK(IsNotificationRule("%NAME%%MAP%")); + } + SUBCASE("a map keyword with no visible name") { + CHECK(IsNotificationRule("%MAP-55%")); + } +} + +TEST_CASE("Rules without map keywords are not notification rules") { + fake::Var(Var_D2CLIENT_SoundRecords) = 100; + SUBCASE("plain output") { + CHECK_FALSE(IsNotificationRule("%NAME%")); + } + SUBCASE("tier only") { + CHECK_FALSE(IsNotificationRule("%NAME%%TIER-2%")); + } + SUBCASE("a sound id past the end of the sound table plays nothing") { + CHECK_FALSE(IsNotificationRule("%NAME%%SOUNDID-100%")); + CHECK(RuleList[0]->action.soundID == 0); + } + SUBCASE("hiding rule") { + CHECK_FALSE(IsNotificationRule("")); + } +} + +TEST_CASE("Map keywords are removed from the displayed name") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter("ItemDisplay[hax]: %NAME%%MAP-55%%BORDER-20%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"The Gnasher"); +} + +TEST_CASE("Notification rules apply even after an earlier rule has decided the name") { + // Wiki: "All notification keywords bypass the normal rule-handling procedure ... can apply + // even after the process has halted." + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: Plain %NAME%\n" + "ItemDisplay[hax]: %NAME%%MAP-55%\n" + "ItemDisplay[SET]: %NAME%%DOT-20%\n" + "ItemDisplay[UNI]: %NAME%%BORDER-9B%\n"); + CHECK(NameOf(axe, L"The Gnasher") == L"Plain The Gnasher"); + std::vector actions = map_action_cache.Get(axe.info()); + REQUIRE(actions.size() == 2); + CHECK(actions[0].colorOnMap == 0x55); + CHECK(actions[1].borderColor == 0x9B); +} + +TEST_CASE("An item matching no notification rule has no map actions") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter( + "ItemDisplay[hax]: %NAME%\n" + "ItemDisplay[SET]: %NAME%%MAP-55%\n"); + CHECK(map_action_cache.Get(axe.info()).empty()); +} + +// ---- Lookup caches ------------------------------------------------------------------------- + +TEST_CASE("Changing an item's flags, mode or location recomputes its name") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter("ItemDisplay[ILVL>50]: High %NAME%\n"); + CHECK(NameOf(axe, L"Axe") == L"Axe"); + axe.ItemLevel(60); + SUBCASE("flags") { + axe.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + CHECK(NameOf(axe, L"Axe") == L"High Axe"); + } + SUBCASE("mode") { + axe.unit()->dwMode = 3; // e.g. on the ground + CHECK(NameOf(axe, L"Axe") == L"High Axe"); + } + SUBCASE("location") { + axe.data().ItemLocation = 4; + CHECK(NameOf(axe, L"Axe") == L"High Axe"); + } +} + +TEST_CASE("The caches are keyed per item") { + TestItem first("hax", ITEM_QUALITY_UNIQUE); + TestItem second("hax", ITEM_QUALITY_UNIQUE); + second.ItemLevel(60); + LoadFilter("ItemDisplay[ILVL>50]: High %NAME%\n"); + CHECK(NameOf(first, L"Axe") == L"Axe"); + CHECK(NameOf(second, L"Axe") == L"High Axe"); +} + +TEST_CASE("Clearing an item's cache entry recomputes its name") { + // Item.cpp clears the entry of the hovered item when its flags change. + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter("ItemDisplay[ILVL>50]: High %NAME%\n"); + CHECK(NameOf(axe, L"Axe") == L"Axe"); + axe.ItemLevel(60); + item_name_cache.Clear(axe.info()); + CHECK(NameOf(axe, L"Axe") == L"High Axe"); +} + +TEST_CASE("Description and map actions are recomputed when an item's flags change") { + TestItem axe("hax", ITEM_QUALITY_UNIQUE); + LoadFilter("ItemDisplay[ILVL>50]: %NAME%{high}%MAP-55%\n"); + CHECK(DescriptionOf(axe) == L""); + CHECK(map_action_cache.Get(axe.info()).empty()); + // Identifying, socketing or picking up changes the item's flags; that must refresh both. + axe.ItemLevel(60); + axe.Flags(ITEM_IDENTIFIED | ITEM_ETHEREAL); + CHECK(DescriptionOf(axe) == L"high"); + CHECK(map_action_cache.Get(axe.info()).size() == 1); +} + +} // TEST_SUITE From ecc466d6df8adcd0b37976aeeef701d79517ac0c Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:07 -0400 Subject: [PATCH 7/8] Test game list filter, stats panel, automap info and other modules --- tests/AsyncDrawBufferTests.cpp | 125 ++++++++ tests/BH.Tests.vcxproj | 12 + tests/BnetTests.cpp | 110 +++++++ tests/ChatColorTests.cpp | 118 ++++++++ tests/CheckhookTests.cpp | 103 +++++++ tests/CombohookTests.cpp | 119 ++++++++ tests/D2HelpersTests.cpp | 229 ++++++++++++++ tests/GameFilterTests.cpp | 537 +++++++++++++++++++++++++++++++++ tests/KeyhookTests.cpp | 140 +++++++++ tests/MapNotifyTests.cpp | 265 ++++++++++++++++ tests/PartyTests.cpp | 222 ++++++++++++++ tests/ScreenInfoTests.cpp | 264 ++++++++++++++++ tests/StatsDisplayTests.cpp | 373 +++++++++++++++++++++++ 13 files changed, 2617 insertions(+) create mode 100644 tests/AsyncDrawBufferTests.cpp create mode 100644 tests/BnetTests.cpp create mode 100644 tests/ChatColorTests.cpp create mode 100644 tests/CheckhookTests.cpp create mode 100644 tests/CombohookTests.cpp create mode 100644 tests/D2HelpersTests.cpp create mode 100644 tests/GameFilterTests.cpp create mode 100644 tests/KeyhookTests.cpp create mode 100644 tests/MapNotifyTests.cpp create mode 100644 tests/PartyTests.cpp create mode 100644 tests/ScreenInfoTests.cpp create mode 100644 tests/StatsDisplayTests.cpp diff --git a/tests/AsyncDrawBufferTests.cpp b/tests/AsyncDrawBufferTests.cpp new file mode 100644 index 00000000..6e27f246 --- /dev/null +++ b/tests/AsyncDrawBufferTests.cpp @@ -0,0 +1,125 @@ +// AsyncDrawBuffer / DrawDirective (AsyncDrawBuffer.cpp): the double-buffered draw list the automap +// item markers are drawn from (MapNotify: a synchronous DrawDirective with maxGhost 5, whose director +// pushes one top-layer draw call per marker). A frame is built in the back buffer and shown only after +// a swap; the directive rebuilds its frame only every few game frames (redrawing the last one in +// between, at most `maxGhost` times), or straight away after forceUpdate() (MapNotify forces one when +// the player changes act). +#include +#include +#include +#include + +#include "doctest/doctest.h" + +#include "AsyncDrawBuffer.h" + +namespace { + +// Draw calls that record what they drew. +struct Canvas { + std::vector drawn; + std::function Draw(const std::string& what) { + return [this, what]() { drawn.push_back(what); }; + } +}; + +// Runs `frames` game frames of a synchronous DrawDirective whose director builds frame number +// 1, 2, 3... each time it runs. Returns what was on screen each frame ("" = nothing). +std::vector Frames(DrawDirective& directive, int frames, std::vector forceBeforeFrame = {}) { + Canvas canvas; + int built = 0; + std::vector shown; + for (int frame = 1; frame <= frames; frame++) { + if (std::find(forceBeforeFrame.begin(), forceBeforeFrame.end(), frame) != forceBeforeFrame.end()) { + directive.forceUpdate(); + } + canvas.drawn.clear(); + directive.draw([&](AsyncDrawBuffer& buffer) { + built++; + buffer.push_top_layer(canvas.Draw(std::to_string(built))); + }); + shown.push_back(canvas.drawn.empty() ? "" : canvas.drawn[0]); + } + return shown; +} + +std::vector S(std::initializer_list values) { + return std::vector(values.begin(), values.end()); +} + +} // namespace + +TEST_SUITE("AsyncDrawBuffer") { + TEST_CASE("draw calls appear only after the buffers are swapped, in the order they were pushed") { + Canvas canvas; + AsyncDrawBuffer buffer; + buffer.push_top_layer(canvas.Draw("a")); + buffer.push_top_layer(canvas.Draw("b")); + buffer.drawAll(); + CHECK(canvas.drawn.empty()); + + buffer.swapBuffers(); + buffer.drawAll(); + CHECK(canvas.drawn == S({ "a", "b" })); + } + + TEST_CASE("the shown frame is drawn again every time until the next swap") { + Canvas canvas; + AsyncDrawBuffer buffer; + buffer.push_top_layer(canvas.Draw("a")); + buffer.swapBuffers(); + buffer.push_top_layer(canvas.Draw("b")); // the next frame, still being built + buffer.drawAll(); + buffer.drawAll(); + CHECK(canvas.drawn == S({ "a", "a" })); + } + + TEST_CASE("clear discards the frame being built, not the one shown") { + Canvas canvas; + AsyncDrawBuffer buffer; + buffer.push_top_layer(canvas.Draw("shown")); + buffer.swapBuffers(); + buffer.push_top_layer(canvas.Draw("discarded")); + buffer.clear(); + buffer.drawAll(); + CHECK(canvas.drawn == S({ "shown" })); + + canvas.drawn.clear(); + buffer.swapBuffers(); + buffer.drawAll(); + CHECK(canvas.drawn.empty()); + } + + TEST_CASE("each rebuild replaces the previous frame instead of adding to it") { + DrawDirective directive(true, 5); + Canvas canvas; + for (int frame = 0; frame < 3; frame++) { + canvas.drawn.clear(); + directive.forceUpdate(); + directive.draw([&](AsyncDrawBuffer& buffer) { + buffer.push_top_layer(canvas.Draw("marker")); + }); + } + CHECK(canvas.drawn == S({ "marker" })); + } + + TEST_CASE("the automap directive rebuilds its frame every 6 frames and redraws it in between") { + DrawDirective directive(true, 5); // as MapNotify's automap markers + std::vector shown = Frames(directive, 25); + // The first frame is built within maxGhost + 2 frames... + auto first = std::find(shown.begin(), shown.end(), "1"); + REQUIRE(first != shown.end()); + CHECK(first - shown.begin() <= 6); + // ...and from then on each frame is shown 6 times (built once, redrawn maxGhost times). + REQUIRE(shown.end() - first >= 18); + std::vector steady(first, first + 18); + CHECK(steady == S({ "1", "1", "1", "1", "1", "1", "2", "2", "2", "2", "2", "2", "3", "3", "3", "3", "3", "3" })); + } + + TEST_CASE("forceUpdate rebuilds on the very next frame") { + DrawDirective directive(true, 5); + // Force the first frame and again on frame 3, well before the 6-frame refresh. + std::vector shown = Frames(directive, 5, { 1, 3 }); + CHECK(shown == S({ "1", "1", "2", "2", "2" })); + } +} diff --git a/tests/BH.Tests.vcxproj b/tests/BH.Tests.vcxproj index 12fcb890..fbb1935d 100644 --- a/tests/BH.Tests.vcxproj +++ b/tests/BH.Tests.vcxproj @@ -115,15 +115,27 @@ + + + + + + + + + + + + diff --git a/tests/BnetTests.cpp b/tests/BnetTests.cpp new file mode 100644 index 00000000..edfca3e4 --- /dev/null +++ b/tests/BnetTests.cpp @@ -0,0 +1,110 @@ +// Modules/Bnet: remembering the last game's name, password and description on joining, and, with +// "autofill next game", proposing the next game name on leaving by counting up the trailing number +// (Battle.net game names are at most 15 characters). +#include +#include + +#include "doctest/doctest.h" + +#include "BH.h" +#include "D2Ptrs.h" +#include "FakeEngine.h" +#include "Modules/Bnet/Bnet.h" + +namespace { + +std::string NextGameAfter(const std::string& lastGame) { + App.bnet.autofillNextGame.value = true; + App.bnet.saveLastGame.value = lastGame; + Bnet bnet; + bnet.OnGameExit(); + return App.bnet.saveLastGame.value; +} + +struct BnetGame { + BnetData data; + BnetGame(const char* name, const char* pass, const char* desc) { + std::memset(&data, 0, sizeof(data)); + strcpy_s(data.szGameName, name); + strcpy_s(data.szGamePass, pass); + strcpy_s(data.szGameDesc, desc); + fake::Var(Var_D2LAUNCH_BnData) = &data; + } +}; + +} // namespace + +TEST_SUITE("Bnet") { + TEST_CASE("next game name counts up the trailing number") { + CHECK(NextGameAfter("Baal-1") == "Baal-2"); + CHECK(NextGameAfter("cows 41") == "cows 42"); + CHECK(NextGameAfter("run9") == "run10"); + CHECK(NextGameAfter("run99") == "run100"); + } + + TEST_CASE("next game name only counts the digits at the end") { + CHECK(NextGameAfter("a1b2") == "a1b3"); + CHECK(NextGameAfter("2baal19") == "2baal20"); + } + + TEST_CASE("a game name without a trailing number is kept") { + CHECK(NextGameAfter("baalruns") == "baalruns"); + CHECK(NextGameAfter("run1 x") == "run1 x"); + CHECK(NextGameAfter("") == ""); + } + + TEST_CASE("next game name stays within the 15-character limit") { + // 14 characters: room for one more digit. + CHECK(NextGameAfter("abcdefghijkl99") == "abcdefghijkl100"); + // 15 characters: the next number still fits. + CHECK(NextGameAfter("abcdefghijklm98") == "abcdefghijklm99"); + CHECK(NextGameAfter("abcdefghijk1234") == "abcdefghijk1235"); + // 15 characters and the next number would make it 16: start again at 1. + CHECK(NextGameAfter("abcdefghijklm99") == "abcdefghijklm1"); + CHECK(NextGameAfter("abcdefghijklmn9") == "abcdefghijklmn1"); + } + + TEST_CASE("next game name is left alone when autofill next game is off") { + App.bnet.autofillNextGame.value = false; + App.bnet.saveLastGame.value = "Baal-1"; + Bnet bnet; + bnet.OnGameExit(); + CHECK(App.bnet.saveLastGame.value == "Baal-1"); + } + + TEST_CASE("joining a game remembers its name, password and description") { + BnetGame game("Baal-7", "pw", "lvl 90+"); + Bnet bnet; + bnet.OnGameJoin(); + CHECK(App.bnet.saveLastGame.value == "Baal-7"); + CHECK(App.bnet.saveLastPass.value == "pw"); + CHECK(App.bnet.saveLastDesc.value == "lvl 90+"); + } + + TEST_CASE("joining a game without password or description forgets the previous ones") { + App.bnet.saveLastPass.value = "old"; + App.bnet.saveLastDesc.value = "old desc"; + BnetGame game("Baal-7", "", ""); + Bnet bnet; + bnet.OnGameJoin(); + CHECK(App.bnet.saveLastPass.value == ""); + CHECK(App.bnet.saveLastDesc.value == ""); + } + + TEST_CASE("joining a game with no name keeps the previous game name") { + App.bnet.saveLastGame.value = "Baal-7"; + BnetGame game("", "", ""); + Bnet bnet; + bnet.OnGameJoin(); + CHECK(App.bnet.saveLastGame.value == "Baal-7"); + } + + TEST_CASE("leaving a joined game proposes the next one in the series") { + App.bnet.autofillNextGame.value = true; + BnetGame game("Chaos-12", "", ""); + Bnet bnet; + bnet.OnGameJoin(); + bnet.OnGameExit(); + CHECK(App.bnet.saveLastGame.value == "Chaos-13"); + } +} diff --git a/tests/ChatColorTests.cpp b/tests/ChatColorTests.cpp new file mode 100644 index 00000000..f4685790 --- /dev/null +++ b/tests/ChatColorTests.cpp @@ -0,0 +1,118 @@ +// Modules/ChatColor: in game, whispers from players listed under "whisperColor" in BH.json are shown +// in that player's colour instead of the game's whisper style. Battle.net delivers chat as +// SID_CHATEVENT (0x0F) packets: event id at offset 4, the sender's name at offset 28, then the text. +#include +#include +#include +#include + +#include "doctest/doctest.h" + +#include "BH.h" +#include "FakeEngine.h" +#include "Modules/ChatColor/ChatColor.h" + +namespace { + +const DWORD kEventWhisperReceived = 0x04; +const DWORD kEventTalk = 0x05; + +std::vector ChatEvent(DWORD eventId, const std::string& from, const std::string& text, BYTE packetId = 0x0F) { + std::vector packet(28, 0); + packet[0] = 0xFF; + packet[1] = packetId; + std::memcpy(&packet[4], &eventId, sizeof(eventId)); + packet.insert(packet.end(), from.begin(), from.end()); + packet.push_back(0); + packet.insert(packet.end(), text.begin(), text.end()); + packet.push_back(0); + WORD length = static_cast(packet.size()); + std::memcpy(&packet[2], &length, sizeof(length)); + return packet; +} + +// A ChatColor module that has seen the player join a game. +struct InGame { + ChatColor module; + InGame() { + module.OnGameJoin(); + } + bool Receive(std::vector packet) { + bool block = false; + module.OnChatPacketRecv(packet.data(), &block); + return block; + } +}; + +} // namespace + +TEST_SUITE("ChatColor") { + TEST_CASE("a whisper from a listed player is replaced by the same text in that player's colour") { + App.bnet.whisperColor.values["Bob"] = "1"; + InGame chat; + CHECK(chat.Receive(ChatEvent(kEventWhisperReceived, "Bob", "trade?"))); + REQUIRE(fake::Printed().size() == 1); + CHECK(fake::Printed()[0].first == L"Bob | trade?"); + CHECK(fake::Printed()[0].second == 1); + } + + TEST_CASE("each listed player gets their own colour") { + App.bnet.whisperColor.values["Bob"] = "1"; + App.bnet.whisperColor.values["*Ann"] = "9"; + InGame chat; + CHECK(chat.Receive(ChatEvent(kEventWhisperReceived, "*Ann", "hi"))); + CHECK(chat.Receive(ChatEvent(kEventWhisperReceived, "Bob", "yo"))); + REQUIRE(fake::Printed().size() == 2); + CHECK(fake::Printed()[0].first == L"*Ann | hi"); + CHECK(fake::Printed()[0].second == 9); + CHECK(fake::Printed()[1].first == L"Bob | yo"); + CHECK(fake::Printed()[1].second == 1); + } + + TEST_CASE("text with printf directives is shown literally") { + App.bnet.whisperColor.values["Bob"] = "2"; + InGame chat; + CHECK(chat.Receive(ChatEvent(kEventWhisperReceived, "Bob", "100% %s %d"))); + REQUIRE(fake::Printed().size() == 1); + CHECK(fake::Printed()[0].first == L"Bob | 100% %s %d"); + } + + TEST_CASE("whispers from players who are not listed are left to the game") { + App.bnet.whisperColor.values["Bob"] = "1"; + InGame chat; + CHECK_FALSE(chat.Receive(ChatEvent(kEventWhisperReceived, "Bobby", "hi"))); + CHECK_FALSE(chat.Receive(ChatEvent(kEventWhisperReceived, "Bo", "hi"))); + CHECK(fake::Printed().empty()); + } + + TEST_CASE("only received whispers are recoloured, not channel talk or other packets") { + App.bnet.whisperColor.values["Bob"] = "1"; + InGame chat; + CHECK_FALSE(chat.Receive(ChatEvent(kEventTalk, "Bob", "hi"))); + CHECK_FALSE(chat.Receive(ChatEvent(kEventWhisperReceived, "Bob", "hi", 0x0E))); + CHECK(fake::Printed().empty()); + } + + TEST_CASE("whispers are left to the game outside a game") { + App.bnet.whisperColor.values["Bob"] = "1"; + InGame chat; + chat.module.OnGameExit(); + CHECK_FALSE(chat.Receive(ChatEvent(kEventWhisperReceived, "Bob", "hi"))); + CHECK(fake::Printed().empty()); + + chat.module.OnGameJoin(); + CHECK(chat.Receive(ChatEvent(kEventWhisperReceived, "Bob", "hi"))); + } + + // BUG: the colour is parsed with std::stoi and the exception is not caught, so a non-numeric colour + // in BH.json ("whisperColor": {"Bob": "red"}) throws out of the chat packet handler (crashing the + // game) the first time that player whispers. A bad colour should never cost the whisper. + TEST_CASE("a non-numeric colour in the config does not lose the whisper" * doctest::should_fail()) { + App.bnet.whisperColor.values["Bob"] = "red"; + InGame chat; + bool block = false; + std::vector packet = ChatEvent(kEventWhisperReceived, "Bob", "hi"); + CHECK_NOTHROW(chat.module.OnChatPacketRecv(packet.data(), &block)); + CHECK((!block || fake::Printed().size() == 1)); + } +} diff --git a/tests/CheckhookTests.cpp b/tests/CheckhookTests.cpp new file mode 100644 index 00000000..5055c0a4 --- /dev/null +++ b/tests/CheckhookTests.cpp @@ -0,0 +1,103 @@ +// Drawing::Checkhook (Drawing/Advanced/Checkhook): the checkboxes in BH's windows (e.g. "Autoparty +// Enabled" on the party screen). Clicking the box or its label flips the bound setting when the +// mouse button comes up; the click is swallowed so the game does not see it. +#include +#include + +#include "doctest/doctest.h" + +#include "Drawing.h" +#include "FakeEngine.h" + +using Drawing::Checkhook; + +namespace { + +// Hooks register themselves with Hook for the life of the process and cannot be deleted, so +// everything a checkbox points at (its setting, its group) is given storage that is never freed +// (`Leaked()`), and every checkbox is hidden, or its group closed, when its test ends: hidden +// checkboxes are neither drawn nor clicked by Hook::Draw/LeftClick. +template +T& Leaked() { + return *new T(); +} + +// A settings panel the checkbox can sit in. +struct Panel : Drawing::HookGroup { + unsigned int x = 200, y = 100; + bool active = true; + unsigned int GetX() override { return x; } + unsigned int GetY() override { return y; } + unsigned int GetXSize() override { return 300; } + unsigned int GetYSize() override { return 200; } + bool IsActive() override { return active; } +}; + +bool Click(Checkhook* box, unsigned int x, unsigned int y) { + bool down = box->OnLeftClick(false, x, y); + bool up = box->OnLeftClick(true, x, y); + CHECK(down == up); + return up; +} + +} // namespace + +TEST_SUITE("Checkhook") { + TEST_CASE("clicking the box flips the setting when the button comes up, and swallows the click") { + bool& setting = Leaked(); + Checkhook* box = new Checkhook(Drawing::InGame, 100, 100, &setting, "Autoparty Enabled"); + CHECK(box->OnLeftClick(false, 105, 105)); + CHECK_FALSE(setting); + CHECK(box->OnLeftClick(true, 105, 105)); + CHECK(setting); + CHECK(box->IsChecked()); + + Click(box, 105, 105); + CHECK_FALSE(setting); + box->SetActive(false); + } + + TEST_CASE("the label is part of the checkbox; clicks beside it are not taken") { + bool& setting = Leaked(); + // 20px for the box and the gap, then the label ("Loot" is 4 characters of 8px). + Checkhook* box = new Checkhook(Drawing::InGame, 100, 100, &setting, "%s", "Loot"); + CHECK(Click(box, 100 + 20 + 32, 112)); + CHECK(setting); + + CHECK_FALSE(Click(box, 100 + 20 + 33, 105)); + CHECK_FALSE(Click(box, 99, 105)); + CHECK_FALSE(Click(box, 105, 99)); + CHECK_FALSE(Click(box, 105, 113)); + CHECK(setting); + box->SetActive(false); + } + + TEST_CASE("a hidden checkbox ignores clicks") { + bool& setting = Leaked(); + Checkhook* box = new Checkhook(Drawing::InGame, 100, 100, &setting, "Loot"); + box->SetActive(false); + CHECK_FALSE(Click(box, 105, 105)); + CHECK_FALSE(setting); + } + + TEST_CASE("a checkbox in a panel is placed relative to the panel and follows it") { + bool& setting = Leaked(); + Panel& panel = Leaked(); + Checkhook* box = new Checkhook(&panel, 10, 20, &setting, "Loot"); + CHECK(box->GetX() == 210); + CHECK(box->GetY() == 120); + CHECK_FALSE(Click(box, 15, 25)); + CHECK(Click(box, 215, 125)); + CHECK(setting); + + panel.x = 400; + CHECK_FALSE(Click(box, 215, 125)); + CHECK(Click(box, 415, 125)); + CHECK_FALSE(setting); + + // A closed panel's checkboxes take no clicks. + panel.active = false; + CHECK_FALSE(Click(box, 415, 125)); + CHECK_FALSE(setting); + } +} diff --git a/tests/CombohookTests.cpp b/tests/CombohookTests.cpp new file mode 100644 index 00000000..da2d286b --- /dev/null +++ b/tests/CombohookTests.cpp @@ -0,0 +1,119 @@ +// Drawing::Combohook (Drawing/Advanced/Combohook): the drop-down lists in BH's settings, such as the +// loot filter level. Clicking the closed box opens the list below it; clicking an entry selects it +// and closes the list; clicking anywhere else closes it without changing the selection. +#include +#include + +#include "doctest/doctest.h" + +#include "Drawing.h" +#include "FakeEngine.h" + +using Drawing::Combohook; + +namespace { + +// The settings window a drop-down sits in, at the screen origin. Closed when its test ends. +struct Window : Drawing::HookGroup { + bool open = true; + unsigned int GetX() override { return 0; } + unsigned int GetY() override { return 0; } + unsigned int GetXSize() override { return 800; } + unsigned int GetYSize() override { return 600; } + bool IsActive() override { return open; } +}; + +// A drop-down at (100, 100), 200px wide, in font 0 (10px high): the closed box covers y 100..113 +// and entry n of the open list covers y 114 + 14n .. 128 + 14n. +// +// Hooks register themselves with Hook for the life of the process and cannot be deleted, so the +// window, the hook and the bound index are deliberately leaked: nothing a hook points at is ever +// freed. When the test ends the window is closed, which makes the hook inert for Hook::LeftClick, +// and window members are never drawn by Hook::Draw. +struct Dropdown { + Window* window; + unsigned int& selected; + Combohook* hook; + explicit Dropdown(unsigned int initial = 1) : window(new Window()), selected(*new unsigned int(initial)) { + hook = new Combohook(window, 100, 100, 200, &selected, { "0 - Show All", "1 - Standard", "2 - Strict" }); + } + ~Dropdown() { + window->open = false; + } + // Press and release at (x, y); returns whether the release was taken. + bool Click(unsigned int x, unsigned int y) { + hook->OnLeftClick(false, x, y); + return hook->OnLeftClick(true, x, y); + } + bool Open() { + return Click(150, 105); + } + static unsigned int EntryY(unsigned int n) { + return 114 + 14 * n + 7; + } +}; + +} // namespace + +TEST_SUITE("Combohook") { + TEST_CASE("clicking the closed box opens the list; picking an entry selects it and closes the list") { + Dropdown list(1); + CHECK(list.Open()); + CHECK(list.Click(150, Dropdown::EntryY(2))); + CHECK(list.selected == 2); + + // Closed again: the same spot is below the box now, so the click is not taken. + CHECK_FALSE(list.Click(150, Dropdown::EntryY(2))); + CHECK(list.selected == 2); + } + + TEST_CASE("each entry of the open list selects its own index") { + for (unsigned int n = 0; n < 3; n++) { + CAPTURE(n); + Dropdown list(1); + list.Open(); + CHECK(list.Click(150, Dropdown::EntryY(n))); + CHECK(list.selected == n); + } + } + + TEST_CASE("the list opens and the entry is chosen when the mouse button comes up") { + Dropdown list(1); + CHECK(list.hook->OnLeftClick(false, 150, 105)); + CHECK(list.hook->OnLeftClick(true, 150, 105)); + CHECK(list.hook->OnLeftClick(false, 150, Dropdown::EntryY(0))); + CHECK(list.selected == 1); + CHECK(list.hook->OnLeftClick(true, 150, Dropdown::EntryY(0))); + CHECK(list.selected == 0); + } + + TEST_CASE("clicking outside the open list closes it and leaves the selection") { + Dropdown list(1); + list.Open(); + CHECK_FALSE(list.Click(400, Dropdown::EntryY(0))); + CHECK(list.selected == 1); + // Closed: the entries no longer react. + CHECK_FALSE(list.Click(150, Dropdown::EntryY(0))); + CHECK(list.selected == 1); + } + + TEST_CASE("clicking the box of the open list closes it and leaves the selection") { + Dropdown list(1); + list.Open(); + CHECK(list.Click(150, 105)); + CHECK(list.selected == 1); + CHECK_FALSE(list.Click(150, Dropdown::EntryY(0))); + CHECK(list.selected == 1); + } + + TEST_CASE("clicks around the closed box are not taken") { + Dropdown list(1); + CHECK_FALSE(list.Click(99, 105)); + CHECK_FALSE(list.Click(150, 99)); + CHECK_FALSE(list.Click(150, 114)); + CHECK_FALSE(list.Click(306, 105)); + // ...and do not open the list. + CHECK_FALSE(list.Click(150, Dropdown::EntryY(0))); + CHECK(list.selected == 1); + } +} diff --git a/tests/D2HelpersTests.cpp b/tests/D2HelpersTests.cpp new file mode 100644 index 00000000..63451cbc --- /dev/null +++ b/tests/D2HelpersTests.cpp @@ -0,0 +1,229 @@ +// D2Helpers.cpp, the helpers BH calls: item quality colours (drop notifications), percentage maths +// (requirements and elemental masteries), the party roster (auto-party), finding the player's +// mercenary (merc item checks in the loot filter), chat printing (only once the game is ready) and +// Skills.txt lookups (stats panel). +#include +#include +#include +#include + +#include "doctest/doctest.h" + +#include "Constants.h" +#include "D2DataTables.h" +#include "D2Helpers.h" +#include "D2Ptrs.h" +#include "FakeEngine.h" + +namespace { + +UnitAny Unit(DWORD type, DWORD id) { + UnitAny unit; + std::memset(&unit, 0, sizeof(unit)); + unit.dwType = type; + unit.dwUnitId = id; + return unit; +} + +RosterUnit Roster(const char* name, DWORD id, WORD partyId) { + RosterUnit roster; + std::memset(&roster, 0, sizeof(roster)); + strcpy_s(roster.szName, name); + roster.dwUnitId = id; + roster.wPartyId = partyId; + roster.wLevel = 1; + return roster; +} + +ClientPetData Pet(int petTypeId, int petUnitId, int ownerId, int ownerType) { + ClientPetData pet; + std::memset(&pet, 0, sizeof(pet)); + pet.nPetTypeId = petTypeId; + pet.nPetUnitId = petUnitId; + pet.nOwnerId = ownerId; + pet.nOwnerType = ownerType; + return pet; +} + +// The player standing in a loaded level: what IsGameReady checks for. +struct LoadedGame { + Path path{}; + Room1 room1{}; + Room2 room2{}; + Level level{}; + Act act{}; + char inventory[64] = {}; + + explicit LoadedGame(DWORD levelNo = MAP_A1_ROGUE_ENCAMPMENT) { + UnitAny& player = fake::Player(); + player.pPath = &path; + path.pRoom1 = &room1; + path.xPos = 5000; + path.yPos = 6000; + room1.pRoom2 = &room2; + room2.pLevel = &level; + level.dwLevelNo = levelNo; + player.pAct = &act; + act.pRoom1 = &room1; + player.pInventory = reinterpret_cast(inventory); + } +}; + +} // namespace + +TEST_SUITE("D2Helpers") { + TEST_CASE("item quality colours follow the game's item name colours") { + CHECK(ItemColorFromQuality(ITEM_QUALITY_INFERIOR) == White); + CHECK(ItemColorFromQuality(ITEM_QUALITY_NORMAL) == White); + CHECK(ItemColorFromQuality(ITEM_QUALITY_SUPERIOR) == White); + CHECK(ItemColorFromQuality(ITEM_QUALITY_MAGIC) == Blue); + CHECK(ItemColorFromQuality(ITEM_QUALITY_SET) == Green); + CHECK(ItemColorFromQuality(ITEM_QUALITY_RARE) == Yellow); + CHECK(ItemColorFromQuality(ITEM_QUALITY_UNIQUE) == Gold); + CHECK(ItemColorFromQuality(ITEM_QUALITY_CRAFT) == Orange); + } + + TEST_CASE("UTILITY_CalcPercent takes a percentage of a value, truncating toward zero") { + // -20% requirements on a 125 strength item: 25 less. + CHECK(UTILITY_CalcPercent(125, -20, 100) == -25); + // 51 * -20% = -10.2: the requirement drops by 10, not 11. + CHECK(UTILITY_CalcPercent(51, -20, 100) == -10); + // +37% fire mastery on 99 damage = 36.63: 36 more. + CHECK(UTILITY_CalcPercent(99, 37, 100) == 36); + CHECK(UTILITY_CalcPercent(0, 50, 100) == 0); + CHECK(UTILITY_CalcPercent(200, 0, 100) == 0); + } + + TEST_CASE("UTILITY_CalcPercent treats a zero base as 1 and caps at INT_MAX") { + CHECK(UTILITY_CalcPercent(10, 50, 0) == 500); + CHECK(UTILITY_CalcPercent(INT_MAX, 200, 100) == INT_MAX); + CHECK(UTILITY_CalcPercent(2000000000, 2000000000.0, 1) == INT_MAX); + } + + TEST_CASE("FindPlayerRoster finds a player in the roster by unit id") { + RosterUnit me = Roster("Me", 1, INVALID_PARTY_ID); + RosterUnit other = Roster("Other", 7, INVALID_PARTY_ID); + RosterUnit last = Roster("Last", 9, INVALID_PARTY_ID); + me.pNext = &other; + other.pNext = &last; + fake::Var(Var_D2CLIENT_PlayerUnitList) = &me; + + CHECK(FindPlayerRoster(1) == &me); + CHECK(FindPlayerRoster(7) == &other); + CHECK(FindPlayerRoster(9) == &last); + CHECK(FindPlayerRoster(8) == nullptr); + } + + TEST_CASE("FindPlayerRoster finds nobody when the roster is empty") { + fake::Var(Var_D2CLIENT_PlayerUnitList) = nullptr; + CHECK(FindPlayerRoster(1) == nullptr); + } + + TEST_CASE("GetClientMercUnit finds the player's own hireling among the pets") { + UnitAny merc = Unit(UNIT_MONSTER, 50); + fake::AddServerUnit(&merc); + UnitAny othersMerc = Unit(UNIT_MONSTER, 51); + fake::AddServerUnit(&othersMerc); + + ClientPetData golem = Pet(3, 49, 1, UNIT_PLAYER); + ClientPetData theirs = Pet(PETTYPE_HIREABLE, 51, 2, UNIT_PLAYER); + ClientPetData mine = Pet(PETTYPE_HIREABLE, 50, 1, UNIT_PLAYER); + golem.pNext = &theirs; + theirs.pNext = &mine; + fake::Var(Var_D2CLIENT_ClientPetData) = &golem; + + CHECK(GetClientMercUnit() == &merc); + } + + TEST_CASE("GetClientMercUnit finds nothing without a hireling of the player's") { + UnitAny othersMerc = Unit(UNIT_MONSTER, 51); + fake::AddServerUnit(&othersMerc); + + SUBCASE("no pets") { + fake::Var(Var_D2CLIENT_ClientPetData) = nullptr; + CHECK(GetClientMercUnit() == nullptr); + } + SUBCASE("another player's hireling") { + ClientPetData theirs = Pet(PETTYPE_HIREABLE, 51, 2, UNIT_PLAYER); + fake::Var(Var_D2CLIENT_ClientPetData) = &theirs; + CHECK(GetClientMercUnit() == nullptr); + } + SUBCASE("a hireling whose owner id is the player's but whose owner is not a player") { + ClientPetData theirs = Pet(PETTYPE_HIREABLE, 51, 1, UNIT_MONSTER); + fake::Var(Var_D2CLIENT_ClientPetData) = &theirs; + CHECK(GetClientMercUnit() == nullptr); + } + SUBCASE("a dead hireling (no unit)") { + ClientPetData mine = Pet(PETTYPE_HIREABLE, -1, 1, UNIT_PLAYER); + fake::Var(Var_D2CLIENT_ClientPetData) = &mine; + CHECK(GetClientMercUnit() == nullptr); + } + SUBCASE("no player") { + ClientPetData mine = Pet(PETTYPE_HIREABLE, 51, 1, UNIT_PLAYER); + fake::Var(Var_D2CLIENT_ClientPetData) = &mine; + fake::Var(Var_D2CLIENT_PlayerUnit) = nullptr; + CHECK(GetClientMercUnit() == nullptr); + } + } + + TEST_CASE("Print says nothing while any part of the player's position is missing") { + LoadedGame game(MAP_A2_LUT_GHOLEIN); + UnitAny& player = fake::Player(); + + SUBCASE("no path") { player.pPath = nullptr; } + SUBCASE("no room") { game.path.pRoom1 = nullptr; } + SUBCASE("no room data") { game.room1.pRoom2 = nullptr; } + SUBCASE("no level") { game.room2.pLevel = nullptr; } + SUBCASE("level 0") { game.level.dwLevelNo = 0; } + SUBCASE("no act") { player.pAct = nullptr; } + SUBCASE("act without rooms") { game.act.pRoom1 = nullptr; } + SUBCASE("no inventory") { player.pInventory = nullptr; } + SUBCASE("x = 0") { game.path.xPos = 0; } + SUBCASE("y = 0") { game.path.yPos = 0; } + + char format[] = "hello"; + Print(format); + CHECK(fake::Printed().empty()); + } + + TEST_CASE("Print formats a message into the chat once the game is ready") { + LoadedGame game; + char format[] = "%d items for %s"; + Print(format, 3, "you"); + REQUIRE(fake::Printed().size() == 1); + CHECK(fake::Printed()[0].first == L"3 items for you"); + CHECK(fake::Printed()[0].second == 0); + } + + TEST_CASE("Print converts UTF-8 text, including colour codes") { + LoadedGame game; + char format[] = "ÿc1Red ÿc0Über"; + Print(format); + REQUIRE(fake::Printed().size() == 1); + CHECK(fake::Printed()[0].first == L"\u00FF" L"c1Red \u00FF" L"c0\u00DCber"); + } + + TEST_CASE("Print says nothing before the game is ready") { + char format[] = "hello"; + Print(format); + CHECK(fake::Printed().empty()); + } + + TEST_CASE("GetSkillRecord returns Skills.txt rows within the table only") { + std::unique_ptr table(new sgptDataTable()); + std::memset(table.get(), 0, sizeof(sgptDataTable)); + SkillsTxt skills[3]; + std::memset(skills, 0, sizeof(skills)); + table->pSkillsTxt = skills; + table->dwSkillsRecs = 3; + fake::Var(Var_D2COMMON_sgptDataTable) = table.get(); + + CHECK(GetSkillRecord(0) == &skills[0]); + CHECK(GetSkillRecord(2) == &skills[2]); + CHECK(GetSkillRecord(3) == nullptr); + CHECK(GetSkillRecord(-1) == nullptr); + + table->pSkillsTxt = nullptr; + CHECK(GetSkillRecord(0) == nullptr); + } +} diff --git a/tests/GameFilterTests.cpp b/tests/GameFilterTests.cpp new file mode 100644 index 00000000..3fc4a106 --- /dev/null +++ b/tests/GameFilterTests.cpp @@ -0,0 +1,537 @@ +#include "doctest/doctest.h" + +#include +#include +#include + +#include "BH.h" +#include "FakeEngine.h" +#include "Modules/Gamefilter/Gamefilter.h" +#include "Modules/Gamefilter/ParsedFilterString.h" + +namespace { + +// Status bits of an MCP_GAMELIST entry (see Gamefilter.cpp): neither bit = normal difficulty. +const DWORD STATUS_NIGHTMARE = 0x1000; +const DWORD STATUS_HELL = 0x2000; + +// `gs` is the zero-based game server index the realm sends; players see it as server gs + 1. +GameListEntry Game(const char* name, BYTE gs = 0, const char* desc = "") { + GameListEntry e = {}; + e.bPlayers = 1; + e.gs = gs; + e.sGameName = name; + e.sGameDesc = desc; + return e; +} + +bool Shows(const char* filter, const GameListEntry& entry) { + ParsedFilterString parsed(filter); + return parsed.IsIncluded(&entry); +} + +// S>C 0x05 MCP_GAMELIST as PD2's realm sends it: +// id(2) index(4) players(1) status(4) gs(1) lootAlloc(1) name\0 desc\0 +std::vector GameListPacket(const char* name, BYTE players, DWORD status, BYTE gs, BYTE lootAlloc = 0, + const char* desc = "") { + std::vector p(14, 0); + p[0] = 0x05; + // Request id and list index (bytes 1-6) are never used by BH; left zero. + p[7] = players; + memcpy(&p[8], &status, sizeof(status)); + p[12] = gs; + p[13] = lootAlloc; + p.insert(p.end(), name, name + strlen(name) + 1); + p.insert(p.end(), desc, desc + strlen(desc) + 1); + return p; +} + +// The join-game screen: the game list text box (with its scroll bar) and the filter edit box +// BH creates next to it. Everything Gamefilter allocated is released when the test ends. +struct JoinScreen { + TextBox list; + ScrollBar scroll; + EditBox filter; + + JoinScreen() { + memset(&list, 0, sizeof(list)); + memset(&scroll, 0, sizeof(scroll)); + memset(&filter, 0, sizeof(filter)); + list.dwType = 4; + list.ptScrollBar = &scroll; + scroll.dwType = 5; + fake::Var(Var_D2MULTI_GameListControl) = &list; + Gamefilter::filterBox = &filter; + // Every difficulty shown unless a test hides one (independent of the settings' defaults). + App.bnet.showNormalDiff.value = true; + App.bnet.showNightmareDiff.value = true; + App.bnet.showHellDiff.value = true; + } + + ~JoinScreen() { + for (auto it = Gamefilter::gameList.begin(); it != Gamefilter::gameList.end(); ++it) + delete *it; + Gamefilter::gameList.clear(); + Gamefilter::filterVector.clear(); + Gamefilter::gServerVector.clear(); + FreeLines(); + Gamefilter::filterBox = NULL; + fake::Var(Var_D2MULTI_GameListControl) = NULL; + } + + void FreeLines() { + ControlText* t = list.pFirstText; + while (t) { + ControlText* next = t->pNext; + delete[] t->wText[0]; + delete[] t->wText[1]; + delete t; + t = next; + } + list.pFirstText = list.pLastText = list.pSelectedText = NULL; + } + + void SetFilterText(const wchar_t* text) { wcscpy_s(filter.wText, 256, text); } + + // Returns whether BH blocked the packet. + bool Receive(std::vector packet) { + bool blocked = false; + module.OnRealmPacketRecv(packet.data(), &blocked); + return blocked; + } + + // The user typing `key` into the filter box (whose text is what was typed so far). + void Type(char key) { + Gamefilter::Filterbox_InputHandler(&filter, (DWORD)wcslen(filter.wText), &key); + } + + // Game names of the visible lines, top to bottom. + std::vector Names() const { + std::vector names; + for (ControlText* t = list.pFirstText; t; t = t->pNext) + names.push_back(t->wText[0]); + return names; + } + + Gamefilter module; +}; + +std::vector Lines(std::initializer_list names) { + return std::vector(names.begin(), names.end()); +} + +} // namespace + +TEST_SUITE("GameFilter") { + +// ---- ParsedFilterString: game name ---------------------------------------------------------- + +TEST_CASE("An empty filter shows every game on every server") { + GameListEntry g = Game("Baal Run 12", 3); + CHECK(Shows("", g)); + CHECK(Shows(" ", g)); + CHECK(Shows(" \t\r\n", g)); +} + +TEST_CASE("A word matches the game name as a case-insensitive substring anywhere in it") { + CHECK(Shows("baal", Game("baal"))); + CHECK(Shows("baal", Game("BAAL-RUN"))); + CHECK(Shows("baal", Game("fastBaal3"))); + CHECK(Shows("baal", Game("run-baal"))); + CHECK(Shows("BaAl", Game("bAaL"))); + CHECK(Shows("bk5", Game("Shop BK5 ring"))); + CHECK_FALSE(Shows("baal", Game("bal run"))); + CHECK_FALSE(Shows("baal", Game("ba al"))); + CHECK_FALSE(Shows("baal", Game(""))); + // Digits and punctuation are compared as typed. + CHECK(Shows("cs-1", Game("CS-12"))); + CHECK_FALSE(Shows("cs-1", Game("cs_12"))); +} + +TEST_CASE("A filter longer than the game name does not match it") { + CHECK_FALSE(Shows("baals", Game("baal"))); + CHECK_FALSE(Shows("xbaal", Game("baal"))); +} + +TEST_CASE("Only the game name is searched, not the game description") { + CHECK_FALSE(Shows("baal", Game("chaos12", 0, "baal runs"))); + CHECK(Shows("chaos", Game("chaos12", 0, "baal runs"))); +} + +TEST_CASE("Several words must appear together, in order, as one phrase") { + CHECK(Shows("baal run", Game("Baal Run 5"))); + CHECK(Shows("baal run", Game("fast baal runs"))); + CHECK_FALSE(Shows("baal run", Game("Run Baal 5"))); + CHECK_FALSE(Shows("baal run", Game("BaalRun5"))); + CHECK_FALSE(Shows("baal run", Game("baal-run"))); + CHECK(Shows("a b c", Game("xa b cx"))); + CHECK_FALSE(Shows("a b c", Game("a b"))); +} + +TEST_CASE("Whitespace around the filter is ignored but whitespace inside a phrase is kept") { + CHECK(Shows(" baal ", Game("baal"))); + CHECK(Shows("\tbaal\n", Game("BAAL"))); + CHECK(Shows("baal run", Game("baal run"))); + CHECK_FALSE(Shows("baal run", Game("baal run"))); +} + +// ---- ParsedFilterString: game servers -------------------------------------------------------- + +TEST_CASE("gs:N shows only games on server N, where server N is the realm's zero-based index N-1") { + CHECK(Shows("gs:1", Game("a", 0))); + CHECK_FALSE(Shows("gs:1", Game("a", 1))); + CHECK(Shows("gs:2", Game("a", 1))); + CHECK_FALSE(Shows("gs:2", Game("a", 0))); + CHECK_FALSE(Shows("gs:2", Game("a", 2))); +} + +TEST_CASE("gs: accepts a comma separated list of servers") { + CHECK(Shows("gs:1,2", Game("a", 0))); + CHECK(Shows("gs:1,2", Game("a", 1))); + CHECK_FALSE(Shows("gs:1,2", Game("a", 2))); + CHECK(Shows("gs:3,1,7", Game("a", 6))); + CHECK_FALSE(Shows("gs:3,1,7", Game("a", 4))); +} + +TEST_CASE("Server numbers are compared whole, never as a prefix") { + CHECK(Shows("gs:10", Game("a", 9))); + CHECK_FALSE(Shows("gs:10", Game("a", 0))); + CHECK_FALSE(Shows("gs:1", Game("a", 9))); + CHECK_FALSE(Shows("gs:1", Game("a", 10))); +} + +TEST_CASE("The highest server index the realm can send is server 256, not server 0") { + CHECK(Shows("gs:256", Game("a", 255))); + CHECK_FALSE(Shows("gs:0", Game("a", 255))); + CHECK_FALSE(Shows("gs:255", Game("a", 255))); +} + +TEST_CASE("The gs: prefix is case-insensitive") { + CHECK(Shows("GS:2", Game("a", 1))); + CHECK(Shows("Gs:2", Game("a", 1))); + CHECK(Shows("gS:2", Game("a", 1))); + CHECK_FALSE(Shows("GS:2", Game("a", 0))); +} + +TEST_CASE("Empty entries in a gs: list are skipped and a bare gs: allows every server") { + CHECK(Shows("gs:", Game("a", 0))); + CHECK(Shows("gs:", Game("a", 5))); + CHECK(Shows("gs:,", Game("a", 5))); + CHECK(Shows("gs:,1,,", Game("a", 0))); + CHECK_FALSE(Shows("gs:,1,,", Game("a", 1))); + CHECK(Shows("gs:1,,3", Game("a", 2))); + CHECK_FALSE(Shows("gs:1,,3", Game("a", 1))); +} + +TEST_CASE("A gs: list naming no real server shows nothing") { + CHECK_FALSE(Shows("gs:x", Game("a", 0))); + CHECK_FALSE(Shows("gs:one", Game("one", 0))); +} + +TEST_CASE("Repeated gs: words add up to one set of servers") { + CHECK(Shows("gs:1 gs:3", Game("a", 0))); + CHECK(Shows("gs:1 gs:3", Game("a", 2))); + CHECK_FALSE(Shows("gs:1 gs:3", Game("a", 1))); +} + +TEST_CASE("Words that only look like a server parameter are part of the game name") { + // No colon, or the prefix not at the start of the word: searched for in the name, no server limit. + CHECK(Shows("gs1", Game("GS1 baal", 4))); + CHECK_FALSE(Shows("gs1", Game("baal", 0))); + CHECK(Shows("xgs:1", Game("xgs:1", 4))); + CHECK_FALSE(Shows("xgs:1", Game("x", 0))); + CHECK(Shows("gs", Game("gs", 4))); + // "gs:" inside a word is not a parameter either. + CHECK(Shows("a,gs:1", Game("A,GS:1", 3))); +} + +// ---- ParsedFilterString: name and server together -------------------------------------------- + +TEST_CASE("A name and a server list must both match, in any order") { + // The examples documented in ParsedFilterString.h. + CHECK(Shows("gs:1 baal", Game("Baal-5", 0))); + CHECK_FALSE(Shows("gs:1 baal", Game("Baal-5", 1))); + CHECK_FALSE(Shows("gs:1 baal", Game("Chaos-5", 0))); + CHECK(Shows("trist gs:1,2", Game("trist12", 0))); + CHECK(Shows("trist gs:1,2", Game("trist12", 1))); + CHECK_FALSE(Shows("trist gs:1,2", Game("trist12", 2))); + CHECK_FALSE(Shows("trist gs:1,2", Game("cows", 1))); + CHECK(Shows("gs:1,2", Game("anything", 1))); +} + +TEST_CASE("A server parameter between words is removed from the phrase") { + CHECK(Shows("baal gs:1 run", Game("baal run", 0))); + CHECK_FALSE(Shows("baal gs:1 run", Game("baal run", 1))); + // The parameter's own text is not part of the phrase searched for. + CHECK_FALSE(Shows("baal gs:1 run", Game("baal gs:1 run", 0))); +} + +// ---- Gamefilter: the join-game list ----------------------------------------------------------- + +TEST_CASE("A game list entry becomes a line with its name, player count and server label") { + JoinScreen screen; + CHECK_FALSE(screen.Receive(GameListPacket("Baal-12", 3, 0, 1, 1, "fast baal"))); + + REQUIRE(screen.list.pFirstText != NULL); + ControlText* line = screen.list.pFirstText; + CHECK(std::wstring(line->wText[0]) == L"Baal-12"); + CHECK(std::wstring(line->wText[1]) == L"3"); + CHECK(screen.list.pLastText == line); + CHECK(screen.list.pSelectedText == line); + CHECK(screen.list.dwMaxLines == 1); + + REQUIRE(Gamefilter::gameList.size() == 1); + const GameListEntry* entry = Gamefilter::gameList.front(); + // The fields the list, server label and loot "A" marker are drawn from. + CHECK(entry->bPlayers == 3); + CHECK(entry->gs == 1); + CHECK(entry->lootAlloc == 1); + CHECK(entry->sGameName == "Baal-12"); + + REQUIRE(Gamefilter::filterVector.size() == 1); + CHECK(Gamefilter::filterVector[0] == entry); + REQUIRE(Gamefilter::gServerVector.size() == 1); + CHECK(std::wstring(Gamefilter::gServerVector[0]) == L"gs2"); +} + +TEST_CASE("Games are listed in the order the realm sends them") { + JoinScreen screen; + screen.Receive(GameListPacket("first", 1, 0, 0)); + screen.Receive(GameListPacket("second", 2, 0, 8)); + screen.Receive(GameListPacket("third", 8, 0, 4)); + + CHECK(screen.Names() == Lines({ L"first", L"second", L"third" })); + CHECK(screen.list.pSelectedText == screen.list.pFirstText); + CHECK(screen.list.dwMaxLines == 3); + REQUIRE(Gamefilter::gServerVector.size() == 3); + CHECK(std::wstring(Gamefilter::gServerVector[0]) == L"gs1"); + CHECK(std::wstring(Gamefilter::gServerVector[1]) == L"gs9"); + CHECK(std::wstring(Gamefilter::gServerVector[2]) == L"gs5"); + REQUIRE(screen.list.pLastText != NULL); + CHECK(std::wstring(screen.list.pLastText->wText[1]) == L"8"); +} + +TEST_CASE("Empty games are dropped and the packet is blocked") { + JoinScreen screen; + CHECK(screen.Receive(GameListPacket("ghost", 0, 0, 0))); + CHECK(Gamefilter::gameList.empty()); + CHECK(screen.list.pFirstText == NULL); + CHECK(screen.list.dwMaxLines == 0); +} + +TEST_CASE("A game the list already has is not listed twice") { + JoinScreen screen; + CHECK_FALSE(screen.Receive(GameListPacket("baal-1", 2, 0, 0))); + CHECK(screen.Receive(GameListPacket("baal-1", 5, 0, 3))); + CHECK(Gamefilter::gameList.size() == 1); + CHECK(screen.Names() == Lines({ L"baal-1" })); + CHECK(std::wstring(screen.list.pFirstText->wText[1]) == L"2"); + // A different name is a different game. + CHECK_FALSE(screen.Receive(GameListPacket("baal-10", 1, 0, 0))); + CHECK(screen.Names() == Lines({ L"baal-1", L"baal-10" })); +} + +TEST_CASE("Game list packets are ignored until the filter box exists") { + JoinScreen screen; + Gamefilter::filterBox = NULL; + CHECK_FALSE(screen.Receive(GameListPacket("baal", 1, 0, 0))); + CHECK(Gamefilter::gameList.empty()); + CHECK(screen.list.pFirstText == NULL); +} + +TEST_CASE("Hidden difficulties are kept and come back when shown again") { + JoinScreen screen; + App.bnet.showNightmareDiff.value = false; + screen.Receive(GameListPacket("norm", 1, 0, 0)); + screen.Receive(GameListPacket("night", 1, STATUS_NIGHTMARE, 0)); + screen.Receive(GameListPacket("hell", 1, STATUS_HELL, 0)); + CHECK(screen.Names() == Lines({ L"norm", L"hell" })); + // "Games: listed/total" counts the hidden game in the total. + CHECK(Gamefilter::gameList.size() == 3); + CHECK(Gamefilter::filterVector.size() == 2); + CHECK(Gamefilter::gServerVector.size() == 2); + + // Relisting (as the nightmare button does) with nightmare shown again. + App.bnet.showNightmareDiff.value = true; + Gamefilter::BuildGameList(""); + CHECK(screen.Names() == Lines({ L"norm", L"night", L"hell" })); + CHECK(Gamefilter::filterVector.size() == 3); +} + +TEST_CASE("Each difficulty button hides only its own difficulty") { + SUBCASE("normal hidden") { + JoinScreen screen; + App.bnet.showNormalDiff.value = false; + screen.Receive(GameListPacket("norm", 1, 0, 0)); + screen.Receive(GameListPacket("night", 1, STATUS_NIGHTMARE, 0)); + screen.Receive(GameListPacket("hell", 1, STATUS_HELL, 0)); + CHECK(screen.Names() == Lines({ L"night", L"hell" })); + } + SUBCASE("hell hidden") { + JoinScreen screen; + App.bnet.showHellDiff.value = false; + screen.Receive(GameListPacket("norm", 1, 0, 0)); + screen.Receive(GameListPacket("night", 1, STATUS_NIGHTMARE, 0)); + screen.Receive(GameListPacket("hell", 1, STATUS_HELL, 0)); + CHECK(screen.Names() == Lines({ L"norm", L"night" })); + } + SUBCASE("other status bits do not change the difficulty") { + JoinScreen screen; + App.bnet.showNormalDiff.value = false; + screen.Receive(GameListPacket("norm-ladder", 1, 0x0004, 0)); + screen.Receive(GameListPacket("hell-ladder", 1, STATUS_HELL | 0x0004, 0)); + CHECK(screen.Names() == Lines({ L"hell-ladder" })); + } +} + +TEST_CASE("Incoming games are matched against the text in the filter box, whatever its case") { + JoinScreen screen; + screen.SetFilterText(L"baal gs:2"); + screen.Receive(GameListPacket("BAAL-1", 1, 0, 1)); + screen.Receive(GameListPacket("baal-2", 1, 0, 0)); + screen.Receive(GameListPacket("cows-3", 1, 0, 1)); + screen.Receive(GameListPacket("Baal-4", 1, 0, 1)); + CHECK(screen.Names() == Lines({ L"BAAL-1", L"Baal-4" })); + CHECK(Gamefilter::gameList.size() == 4); +} + +TEST_CASE("The scroll bar scrolls once more than nine lines are listed") { + JoinScreen screen; + char name[8]; + for (int i = 0; i < 9; i++) { + sprintf_s(name, "g%d", i); + screen.Receive(GameListPacket(name, 1, 0, 0)); + } + CHECK(screen.list.dwMaxLines == 9); + CHECK(screen.scroll.dwScrollEntries == 0); + screen.Receive(GameListPacket("g9", 1, 0, 0)); + CHECK(screen.list.dwMaxLines == 10); + CHECK(screen.scroll.dwScrollEntries == 1); + screen.Receive(GameListPacket("g10", 1, 0, 0)); + CHECK(screen.scroll.dwScrollEntries == 2); +} + +// ---- Gamefilter: typing in the filter box ----------------------------------------------------- + +TEST_CASE("Typing in the filter box relists the known games that match the new text") { + JoinScreen screen; + screen.Receive(GameListPacket("baal-1", 1, 0, 0)); + screen.Receive(GameListPacket("cows-2", 1, 0, 0)); + screen.Receive(GameListPacket("bas-3", 1, 0, 2)); + + // The box holds what was typed before this key. + screen.SetFilterText(L"b"); + screen.Type('a'); + CHECK(screen.Names() == Lines({ L"baal-1", L"bas-3" })); + CHECK(screen.list.dwMaxLines == 2); + REQUIRE(Gamefilter::gServerVector.size() == 2); + CHECK(std::wstring(Gamefilter::gServerVector[1]) == L"gs3"); + CHECK(Gamefilter::filterVector.size() == 2); + + screen.SetFilterText(L"ba"); + screen.Type('A'); + CHECK(screen.Names() == Lines({ L"baal-1" })); + CHECK(screen.list.pSelectedText == screen.list.pFirstText); + CHECK(screen.list.pLastText == screen.list.pFirstText); + CHECK(Gamefilter::gameList.size() == 3); +} + +TEST_CASE("Typing a server filter relists only that server's games") { + JoinScreen screen; + screen.Receive(GameListPacket("a", 1, 0, 0)); + screen.Receive(GameListPacket("b", 1, 0, 1)); + screen.Receive(GameListPacket("c", 1, 0, 2)); + screen.SetFilterText(L"GS:1,"); + screen.Type('3'); + CHECK(screen.Names() == Lines({ L"a", L"c" })); +} + +TEST_CASE("Relisting resets the scroll position and keeps difficulties hidden") { + JoinScreen screen; + App.bnet.showHellDiff.value = false; + char name[8]; + for (int i = 0; i < 12; i++) { + sprintf_s(name, "run%d", i); + screen.Receive(GameListPacket(name, 1, i % 2 ? STATUS_HELL : 0, 0)); + } + CHECK(screen.list.dwMaxLines == 6); + screen.list.dwCurrentLine = 4; + screen.list.dwTopOffset = 3; + screen.scroll.dwScrollPosition = 4; + screen.scroll.bMovedDown = 1; + + screen.SetFilterText(L"run"); + screen.Type('1'); // "run1", "run10", "run11"; run11 is hell + CHECK(screen.Names() == Lines({ L"run10" })); + CHECK(screen.list.dwMaxLines == 1); + CHECK(screen.list.dwCurrentLine == 0); + CHECK(screen.list.dwTopOffset == 0); + CHECK(screen.scroll.dwScrollPosition == 0); + CHECK(screen.scroll.bMovedDown == 0); + CHECK(screen.scroll.dwScrollEntries == 0); +} + +TEST_CASE("Relisting with a filter that matches everything shows every game again") { + JoinScreen screen; + screen.SetFilterText(L"zz"); + char name[8]; + for (int i = 0; i < 11; i++) { + sprintf_s(name, "g%d", i); + screen.Receive(GameListPacket(name, 1, 0, 0)); + } + CHECK(screen.list.pFirstText == NULL); + screen.SetFilterText(L""); + screen.Type('g'); + CHECK(screen.list.dwMaxLines == 11); + CHECK(screen.scroll.dwScrollEntries == 2); + CHECK(screen.Names().front() == L"g0"); + CHECK(screen.Names().back() == L"g10"); +} + +// ---- Gamefilter: character list --------------------------------------------------------------- + +TEST_CASE("The character list scrolls by rows of two once more than eight characters exist") { + struct Case { + BYTE packetId; + unsigned int chars; + DWORD scrollRows; + }; + const Case cases[] = { + { 0x19, 0, 0 }, { 0x19, 8, 0 }, { 0x19, 9, 1 }, { 0x19, 10, 1 }, { 0x19, 11, 2 }, { 0x17, 18, 5 }, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const Case& c = cases[i]; + CAPTURE(c.chars); + TextBox charList; + ScrollBar scroll; + TextBox otherText; // a text box without a scroll bar is not the character list + memset(&charList, 0, sizeof(charList)); + memset(&scroll, 0, sizeof(scroll)); + memset(&otherText, 0, sizeof(otherText)); + charList.dwType = 4; + charList.ptScrollBar = &scroll; + charList.dwCurrentLine = 6; + scroll.dwType = 5; + scroll.dwScrollPosition = 3; + otherText.dwType = 4; + otherText.dwMaxLines = 99; + otherText.pNext = &charList; + fake::Var(Var_D2WIN_FirstControl) = &otherText; + + BYTE packet[7] = { c.packetId, 0, 0 }; + memcpy(packet + 3, &c.chars, sizeof(c.chars)); + bool blocked = false; + Gamefilter module; + module.OnRealmPacketRecv(packet, &blocked); + + CHECK_FALSE(blocked); + CHECK(charList.dwMaxLines == c.chars); + CHECK(charList.dwCurrentLine == 0); + CHECK(scroll.dwScrollPosition == 0); + CHECK(scroll.dwScrollEntries == c.scrollRows); + CHECK(otherText.dwMaxLines == 99); + fake::Var(Var_D2WIN_FirstControl) = NULL; + } +} + +} // TEST_SUITE diff --git a/tests/KeyhookTests.cpp b/tests/KeyhookTests.cpp new file mode 100644 index 00000000..cea3f3ca --- /dev/null +++ b/tests/KeyhookTests.cpp @@ -0,0 +1,140 @@ +// Drawing::Keyhook (Drawing/Advanced/Keyhook): a hotkey setting in BH's settings window. It shows +// " "; clicking it starts listening, and the next key released becomes the hotkey (Escape +// clears it). +#include + +#include "doctest/doctest.h" + +#include "Drawing.h" +#include "FakeEngine.h" + +using Drawing::Keyhook; + +namespace { + +// The settings window a hotkey setting sits in, at the screen origin. Closed when its test ends. +struct Window : Drawing::HookGroup { + bool open = true; + unsigned int GetX() override { return 0; } + unsigned int GetY() override { return 0; } + unsigned int GetXSize() override { return 800; } + unsigned int GetYSize() override { return 600; } + bool IsActive() override { return open; } +}; + +// A hotkey setting at (100, 50) bound to `key`, in its own settings window. Like every hotkey +// setting BH creates (the settings tabs in Item.cpp), it has no name: it shows just the key, 8px per +// character with the fake font, so "K" covers x 100..108, y 50..60. +// +// Hooks register themselves with Hook for the life of the process and cannot be deleted, so the +// window, the hook and the bound key are deliberately leaked: nothing a hook points at is ever freed. +// When the test ends the window is closed, which makes the hook inert for Hook::LeftClick/KeyClick, +// and window members are never drawn by Hook::Draw. +struct Setting { + Window* window; + unsigned int& key; + Keyhook* hook; + explicit Setting(unsigned int initialKey) : window(new Window()), key(*new unsigned int(initialKey)) { + hook = new Keyhook(window, 100, 50, &key, ""); + } + ~Setting() { + if (Listening()) { + hook->OnKey(true, static_cast(key), 0); + } + window->open = false; + } + Keyhook* operator->() { + return hook; + } + bool Click(unsigned int x, unsigned int y) { + bool down = hook->OnLeftClick(false, x, y); + hook->OnLeftClick(true, x, y); + return down; + } + // Whether a key press would be taken as the new hotkey (without taking it). + bool Listening() { + return hook->OnKey(false, 0, 0); + } + // What the setting shows while it is not listening. + std::wstring Shown() { + size_t before = fake::Drawn().size(); + hook->OnDraw(); + REQUIRE(fake::Drawn().size() == before + 1); + return fake::Drawn().back().text; + } +}; + +} // namespace + +TEST_SUITE("Keyhook") { + TEST_CASE("shows the bound key by its name") { + Setting letter('K'); + CHECK(letter.Shown() == L"K"); + Setting function(VK_F11); + CHECK(function.Shown() == L"F11"); + Setting numpad(VK_NUMPAD0); + CHECK(numpad.Shown() == L"Numpad 0"); + } + + TEST_CASE("an unbound hotkey shows as Not Set") { + Setting setting(0); + CHECK(setting.Shown() == L"Not Set"); + } + + TEST_CASE("the key is highlighted while the mouse is over the setting") { + Setting setting('K'); + fake::Var(Var_D2CLIENT_MouseX) = 104; + fake::Var(Var_D2CLIENT_MouseY) = 55; + setting.Shown(); + CHECK(fake::Drawn().back().color == Tan); + + fake::Var(Var_D2CLIENT_MouseX) = 10; + setting.Shown(); + CHECK(fake::Drawn().back().color == Gold); + } + + TEST_CASE("clicking the setting starts listening; the next key released becomes the hotkey") { + Setting setting('K'); + CHECK_FALSE(setting.Listening()); + CHECK(setting.Click(104, 55)); + CHECK(setting.Listening()); + + // The key press is swallowed; the hotkey changes when the key comes up. + CHECK(setting->OnKey(false, 'J', 0)); + CHECK(setting.key == 'K'); + CHECK(setting->OnKey(true, 'J', 0)); + CHECK(setting.key == 'J'); + + CHECK_FALSE(setting.Listening()); + CHECK_FALSE(setting->OnKey(true, 'L', 0)); + CHECK(setting.key == 'J'); + } + + TEST_CASE("Escape while listening clears the hotkey") { + Setting setting('K'); + setting.Click(104, 55); + setting->OnKey(true, VK_ESCAPE, 0); + CHECK(setting.key == 0); + CHECK_FALSE(setting.Listening()); + } + + TEST_CASE("clicking again while listening stops listening and keeps the hotkey") { + Setting setting('K'); + setting.Click(104, 55); + setting.Click(104, 55); + CHECK_FALSE(setting.Listening()); + CHECK_FALSE(setting->OnKey(true, 'J', 0)); + CHECK(setting.key == 'K'); + } + + TEST_CASE("clicks outside the shown key are not taken and do not start listening") { + Setting setting('K'); + CHECK(setting.Click(108, 60)); // the far corner is still the setting... + setting.Click(108, 60); // (stop listening again) + CHECK_FALSE(setting.Click(99, 55)); + CHECK_FALSE(setting.Click(109, 55)); + CHECK_FALSE(setting.Click(104, 49)); + CHECK_FALSE(setting.Click(104, 61)); + CHECK_FALSE(setting.Listening()); + } +} diff --git a/tests/MapNotifyTests.cpp b/tests/MapNotifyTests.cpp new file mode 100644 index 00000000..982f32b5 --- /dev/null +++ b/tests/MapNotifyTests.cpp @@ -0,0 +1,265 @@ +// Modules/MapNotify: drop notifications. Each frame BH looks at the items lying in the act; the first +// time it sees an item that matches a loot filter rule with a map/notification keyword (%MAP-xx%, +// %BORDER-xx%, %SOUNDID-n%...), it prints the item's name in the chat in the item's quality colour +// (subject to "detailed notifications": 0 off, 1 all, 2 only new drops, and to %TIER-n% against the +// filter level), and plays the rule's sound. +#include +#include +#include + +#include "doctest/doctest.h" + +#include "BH.h" +#include "D2DataTables.h" +#include "D2Ptrs.h" +#include "FakeEngine.h" +#include "LootFilter.h" +#include "Modules/Item/Item.h" +#include "Modules/MapNotify/MapNotify.h" + +namespace { + +using support::TestItem; + +// Items lying in the player's current act. Every item's code is registered in ItemAttributeMap +// (as Item.cpp does for every Weapons/Armor/Misc.txt row at game join). +struct Ground { + Path path{}; + Room1 room1{}; + Room2 room2{}; + Level level{}; + Act act{}; + std::vector items; + MapNotify module; + + Ground() { + UnitAny& player = fake::Player(); + player.pPath = &path; + path.pRoom1 = &room1; + room1.pRoom2 = &room2; + room2.pLevel = &level; + level.dwLevelNo = 4; + player.pAct = &act; + act.pRoom1 = &room1; + } + ~Ground() { + for (TestItem* item : items) { + delete item; + } + ItemAttributeMap.clear(); + } + + // Drops an item whose in-game name (D2CLIENT_GetItemName) is `name`. + TestItem& Drop(const char* code, DWORD quality, const std::wstring& name, DWORD flags = ITEM_IDENTIFIED) { + TestItem* item = new TestItem(code, quality); + item->Flags(flags); + fake::SetItemName(item->unit(), name); + ItemAttributeMap[code] = &item->attrs(); + item->unit()->pListNext = room1.pUnitFirst; + room1.pUnitFirst = item->unit(); + items.push_back(item); + return *item; + } + + // Runs one frame; returns the chat lines it printed. + std::vector> Frame() { + size_t before = fake::Printed().size(); + module.OnDraw(); + return std::vector>(fake::Printed().begin() + before, fake::Printed().end()); + } +}; + +using Lines = std::vector>; + +Lines Line(const std::wstring& text, int color) { + return { std::make_pair(text, color) }; +} + +struct PlayedSound { + int sound; + int volume; +}; +std::vector playedSounds; + +BOOL __stdcall RecordSound(UnitAny* pUnit, int nSound, int nVolume, int nPriority, BOOL bDropSound) { + playedSounds.push_back(PlayedSound{ nSound, nVolume }); + return TRUE; +} + +// sounds.txt with 10 records; record `id` is a one-shot sound at volume 100 unless changed. +struct SoundTable { + std::vector sounds; + SoundTable() : sounds(10) { + std::memset(sounds.data(), 0, sounds.size() * sizeof(SoundsTxt)); + for (auto& sound : sounds) { + sound.volume = 100; + } + fake::Var(Var_D2CLIENT_SoundsTxt) = sounds.data(); + fake::Var(Var_D2CLIENT_SoundRecords) = static_cast(sounds.size()); + App.pd2.pd2PlaySoundImpl = &RecordSound; + playedSounds.clear(); + } + ~SoundTable() { + playedSounds.clear(); + } +}; + +} // namespace + +TEST_SUITE("MapNotify") { + TEST_CASE("an item matching a map rule is announced once, in its quality colour") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%MAP-97%\n"); + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + CHECK(ground.Frame() == Line(L"Shako", Gold)); + CHECK(ground.Frame().empty()); + } + + TEST_CASE("each announced item gets the colour of its own quality") { + support::LoadFilter("ItemDisplay[]: %NAME%%BORDER-62%\n"); + Ground ground; + ground.Drop("rin", ITEM_QUALITY_RARE, L"Doom Loop"); + ground.Drop("amu", ITEM_QUALITY_SET, L"Tal Rasha's Adjudication"); + ground.Drop("jew", ITEM_QUALITY_MAGIC, L"Ruby Jewel"); + ground.Drop("r33", ITEM_QUALITY_NORMAL, L"Zod Rune"); + Lines lines = ground.Frame(); + REQUIRE(lines.size() == 4); + // The room's unit list holds the last drop first. + CHECK(lines[0] == std::make_pair(std::wstring(L"Zod Rune"), static_cast(White))); + CHECK(lines[1] == std::make_pair(std::wstring(L"Ruby Jewel"), static_cast(Blue))); + CHECK(lines[2] == std::make_pair(std::wstring(L"Tal Rasha's Adjudication"), static_cast(Green))); + CHECK(lines[3] == std::make_pair(std::wstring(L"Doom Loop"), static_cast(Yellow))); + } + + TEST_CASE("items matching no map rule are not announced") { + support::LoadFilter( + "ItemDisplay[uap]: %NAME%%MAP-97%\n" + "ItemDisplay[hax]: %RED%%NAME%\n"); + Ground ground; + ground.Drop("hax", ITEM_QUALITY_NORMAL, L"Hand Axe"); + CHECK(ground.Frame().empty()); + } + + TEST_CASE("an item that appears later is announced when it appears") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%MAP-97%\n"); + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + ground.Frame(); + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Peasant Crown"); + CHECK(ground.Frame() == Line(L"Peasant Crown", Gold)); + } + + TEST_CASE("detailed notifications set to 2 announce only new drops") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%MAP-97%\n"); + App.lootfilter.detailedNotifications.value = 2; + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Old Shako"); + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"New Shako", ITEM_IDENTIFIED | ITEM_NEW); + CHECK(ground.Frame() == Line(L"New Shako", Gold)); + } + + TEST_CASE("no announcements with detailed notifications off or the loot filter disabled") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%MAP-97%\n"); + SUBCASE("notifications off") { + App.lootfilter.detailedNotifications.value = 0; + } + SUBCASE("filter disabled") { + App.lootfilter.enableFilter.value = false; + } + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako", ITEM_IDENTIFIED | ITEM_NEW); + CHECK(ground.Frame().empty()); + } + + TEST_CASE("%TIER-n% rules are announced up to filter level n, and always at filter level 0") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%MAP-97%%TIER-2%\n"); + SUBCASE("level 2") { + App.lootfilter.filterLevel.value = 2; + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + CHECK(ground.Frame() == Line(L"Shako", Gold)); + } + SUBCASE("level 3") { + App.lootfilter.filterLevel.value = 3; + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + CHECK(ground.Frame().empty()); + } + SUBCASE("level 0") { + App.lootfilter.filterLevel.value = 0; + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + CHECK(ground.Frame() == Line(L"Shako", Gold)); + } + } + + TEST_CASE("a later map rule still announces an item whose first map rule is above the filter level") { + support::LoadFilter( + "ItemDisplay[uap]: %NAME%%MAP-97%%TIER-1%%CONTINUE%\n" + "ItemDisplay[uap]: %NAME%%BORDER-62%\n"); + App.lootfilter.filterLevel.value = 3; + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + CHECK(ground.Frame() == Line(L"Shako", Gold)); + } + + TEST_CASE("announced names lose their padding but keep their leading colour") { + support::LoadFilter("ItemDisplay[]: %NAME%%MAP-97%\n"); + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L" Shako "); + CHECK(ground.Frame() == Line(L"Shako", Gold)); + ground.Drop("rin", ITEM_QUALITY_RARE, L" \u00FF" L"c9 Doom Loop \u00FF" L"c0 "); + CHECK(ground.Frame() == Line(L"\u00FF" L"c9Doom Loop", Yellow)); + } + + TEST_CASE("multi-line names are announced on one line, joined by ' - '") { + support::LoadFilter("ItemDisplay[]: %NAME%%MAP-97%\n"); + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Harlequin Crest\nShako"); + CHECK(ground.Frame() == Line(L"Harlequin Crest - Shako", Gold)); + ground.Drop("r33", ITEM_QUALITY_NORMAL, L"a\nb\nc"); + CHECK(ground.Frame() == Line(L"a - b - c", White)); + } + + TEST_CASE("multi-line names lose their padding too") { + support::LoadFilter("ItemDisplay[]: %NAME%%MAP-97%\n"); + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L" Harlequin Crest\nShako "); + CHECK(ground.Frame() == Line(L"Harlequin Crest - Shako", Gold)); + } + + TEST_CASE("an announced item plays its rule's sound at the sound's volume") { + SoundTable table; + table.sounds[5].volume = 80; + support::LoadFilter("ItemDisplay[uap]: %NAME%%SOUNDID-5%\n"); + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + CHECK(ground.Frame() == Line(L"Shako", Gold)); + REQUIRE(playedSounds.size() == 1); + CHECK(playedSounds[0].sound == 5); + CHECK(playedSounds[0].volume == 80); + + ground.Frame(); + CHECK(playedSounds.size() == 1); + } + + TEST_CASE("looping sounds, sound group 2 and drop sounds turned off play nothing") { + SoundTable table; + table.sounds[5].loop = 1; + table.sounds[6].nSoundGroup = 2; + SUBCASE("looping") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%SOUNDID-5%\n"); + } + SUBCASE("group 2") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%SOUNDID-6%\n"); + } + SUBCASE("drop sounds off") { + support::LoadFilter("ItemDisplay[uap]: %NAME%%SOUNDID-7%\n"); + App.lootfilter.dropSounds.value = false; + } + Ground ground; + ground.Drop("uap", ITEM_QUALITY_UNIQUE, L"Shako"); + CHECK(ground.Frame() == Line(L"Shako", Gold)); + CHECK(playedSounds.empty()); + } +} diff --git a/tests/PartyTests.cpp b/tests/PartyTests.cpp new file mode 100644 index 00000000..da4e0353 --- /dev/null +++ b/tests/PartyTests.cpp @@ -0,0 +1,222 @@ +// Modules/Party: auto-party and (hardcore) auto corpse-loot permission. Every 25 game loops BH looks at +// the party roster and does at most one party action: accept an invitation into the game's main +// party (the one with the lowest party id), invite unpartied players when the player is in that +// party (or nobody has a party yet), or leave a different party. In hardcore it also grants every +// other player permission to loot the player's corpse, once each. +#include +#include + +#include "doctest/doctest.h" + +#include "BH.h" +#include "D2Helpers.h" +#include "D2Ptrs.h" +#include "FakeEngine.h" +#include "Modules/Party/Party.h" + +namespace { + +const WORD kNoParty = INVALID_PARTY_ID; +const DWORD kAccept = 2; // D2CLIENT_ClickParty mode used for both invite and accept + +RosterUnit Player(const char* name, DWORD id, WORD partyId, DWORD flags = PARTY_NOT_IN_PARTY) { + RosterUnit roster; + std::memset(&roster, 0, sizeof(roster)); + strcpy_s(roster.szName, name); + roster.dwUnitId = id; + roster.wPartyId = partyId; + roster.dwPartyFlags = flags != PARTY_NOT_IN_PARTY ? flags : (partyId == kNoParty ? PARTY_NOT_IN_PARTY : PARTY_IN_PARTY); + roster.wLevel = 90; + return roster; +} + +// The game's roster: the player (unit id 1, fake::Player()) first, then the others. +struct Game { + std::vector roster; + BnetData bnet; + Party module; + + explicit Game(std::vector players, bool hardcore = false) : roster(players) { + for (size_t i = 0; i + 1 < roster.size(); i++) { + roster[i].pNext = &roster[i + 1]; + } + fake::Var(Var_D2CLIENT_PlayerUnitList) = &roster[0]; + std::memset(&bnet, 0, sizeof(bnet)); + bnet.nCharFlags = hardcore ? PLAYER_TYPE_HARDCORE : 0; + fake::Var(Var_D2LAUNCH_BnData) = &bnet; + App.party.autoParty.toggle.isEnabled = true; + App.party.autoCorpseLoot.toggle.isEnabled = true; + module.OnLoad(); + } + + RosterUnit& operator[](size_t i) { + return roster[i]; + } + + // Runs one game loop; returns the party clicks it made. + std::vector> Loop() { + size_t before = fake::PartyClicks().size(); + module.OnLoop(); + return std::vector>(fake::PartyClicks().begin() + before, fake::PartyClicks().end()); + } + + std::vector> Click(size_t i) { + return { std::make_pair(&roster[i], kAccept) }; + } +}; + +using Clicks = std::vector>; + +std::vector LootPermission(DWORD unitId) { + std::vector packet = { 0x5d, 1, 1, 0, 0, 0, 0 }; + std::memcpy(&packet[3], &unitId, sizeof(unitId)); + return packet; +} + +} // namespace + +TEST_SUITE("Party") { + TEST_CASE("accepts an invitation when nobody has a party yet") { + Game game({ Player("Me", 1, kNoParty), Player("Host", 2, kNoParty, PARTY_INVITED_YOU) }); + CHECK(game.Loop() == game.Click(1)); + } + + TEST_CASE("accepts an invitation from a member of the game's party") { + Game game({ Player("Me", 1, kNoParty), Player("Lead", 2, 5, PARTY_IN_PARTY | PARTY_INVITED_YOU) }); + CHECK(game.Loop() == game.Click(1)); + } + + TEST_CASE("with two parties, only accepts the invitation into the one with the lowest id") { + Game game({ + Player("Me", 1, kNoParty), + Player("Other", 2, 7, PARTY_IN_PARTY | PARTY_INVITED_YOU), + Player("Leader", 3, 5, PARTY_IN_PARTY | PARTY_INVITED_YOU), + }); + CHECK(game.Loop() == game.Click(2)); + } + + TEST_CASE("does not accept an invitation into a party other than the lowest-id one") { + Game game({ + Player("Me", 1, kNoParty), + Player("Other", 2, 7, PARTY_IN_PARTY | PARTY_INVITED_YOU), + Player("Leader", 3, 5), + }); + CHECK(game.Loop().empty()); + CHECK(fake::PartyLeaves() == 0); + } + + TEST_CASE("invites an unpartied player when nobody has a party yet") { + Game game({ Player("Me", 1, kNoParty), Player("Solo", 2, kNoParty) }); + CHECK(game.Loop() == game.Click(1)); + } + + TEST_CASE("does not invite a player again who already has an invitation from the player") { + Game game({ Player("Me", 1, kNoParty), Player("Solo", 2, kNoParty, PARTY_INVITED_BY_YOU) }); + CHECK(game.Loop().empty()); + } + + TEST_CASE("invites unpartied players into the game's party when the player is in it") { + Game game({ Player("Me", 1, 5), Player("Mate", 2, 5), Player("Solo", 3, kNoParty) }); + CHECK(game.Loop() == game.Click(2)); + } + + TEST_CASE("waits for an invitation when others have a party and the player has none") { + Game game({ Player("Me", 1, kNoParty), Player("Lead", 2, 5), Player("Solo", 3, kNoParty) }); + CHECK(game.Loop().empty()); + CHECK(fake::PartyLeaves() == 0); + } + + TEST_CASE("leaves a party that is not the game's main party") { + Game game({ Player("Me", 1, 7), Player("Lead", 2, 5), Player("Mate", 3, 7) }); + CHECK(game.Loop().empty()); + CHECK(fake::PartyLeaves() == 1); + } + + TEST_CASE("stays in the game's main party") { + Game game({ Player("Me", 1, 5), Player("Lead", 2, 5), Player("Other", 3, 7) }); + CHECK(game.Loop().empty()); + CHECK(fake::PartyLeaves() == 0); + } + + TEST_CASE("does nothing with auto-party turned off") { + Game game({ Player("Me", 1, 7), Player("Host", 2, kNoParty, PARTY_INVITED_YOU), Player("Lead", 3, 5) }); + App.party.autoParty.toggle.isEnabled = false; + App.party.autoCorpseLoot.toggle.isEnabled = false; + CHECK(game.Loop().empty()); + CHECK(fake::PartyLeaves() == 0); + } + + TEST_CASE("does nothing while a player's roster entry is not loaded yet") { + Game game({ Player("Me", 1, kNoParty), Player("Host", 2, kNoParty, PARTY_INVITED_YOU), Player("New", 3, kNoParty) }); + game[2].wLevel = 0; + CHECK(game.Loop().empty()); + } + + TEST_CASE("does nothing while a player has no party id but is flagged as in a party") { + Game game({ Player("Me", 1, kNoParty), Player("Host", 2, kNoParty, PARTY_INVITED_YOU), Player("Odd", 3, kNoParty, PARTY_IN_PARTY) }); + CHECK(game.Loop().empty()); + } + + // BUG: the sanity check for "a party id but not in a party" tests `dwPartyFlags & PARTY_NOT_IN_PARTY`, + // and PARTY_NOT_IN_PARTY is 0, so it never fires. With that half-updated roster entry (the window + // the code comment describes) the player then leaves their party for a party that does not exist. + TEST_CASE("does nothing while a player has a party id but is flagged as not in a party" * doctest::should_fail()) { + Game game({ Player("Me", 1, 7), Player("Mate", 2, 7), Player("Odd", 3, 5) }); + game[2].dwPartyFlags = PARTY_NOT_IN_PARTY; + CHECK(game.Loop().empty()); + CHECK(fake::PartyLeaves() == 0); + } + + TEST_CASE("acts at most once every 25 game loops") { + Game game({ Player("Me", 1, kNoParty), Player("Solo", 2, kNoParty) }); + CHECK(game.Loop().size() == 1); + for (int loop = 2; loop <= 25; loop++) { + CAPTURE(loop); + CHECK(game.Loop().empty()); + } + CHECK(game.Loop().size() == 1); // loop 26 + } + + TEST_CASE("in hardcore, grants each other player corpse-loot permission once") { + Game game({ Player("Me", 1, 5), Player("Mate", 2, 5), Player("Other", 3, 5) }, true); + game.Loop(); + std::vector> expected = { LootPermission(2), LootPermission(3) }; + CHECK(fake::SentPackets() == expected); + + for (int loop = 2; loop <= 26; loop++) { + game.Loop(); + } + CHECK(fake::SentPackets() == expected); + } + + TEST_CASE("in hardcore, grants permission again to a player who left and came back") { + Game game({ Player("Me", 1, 5), Player("Mate", 2, 5) }, true); + game.Loop(); + REQUIRE(fake::SentPackets().size() == 1); + + game[0].pNext = nullptr; // Mate leaves + for (int loop = 2; loop <= 26; loop++) { + game.Loop(); + } + game[0].pNext = &game[1]; // and comes back + for (int loop = 27; loop <= 51; loop++) { + game.Loop(); + } + std::vector> expected = { LootPermission(2), LootPermission(2) }; + CHECK(fake::SentPackets() == expected); + } + + TEST_CASE("no corpse-loot permission in softcore or with auto corpse-loot off") { + SUBCASE("softcore") { + Game game({ Player("Me", 1, 5), Player("Mate", 2, 5) }, false); + game.Loop(); + CHECK(fake::SentPackets().empty()); + } + SUBCASE("turned off") { + Game game({ Player("Me", 1, 5), Player("Mate", 2, 5) }, true); + App.party.autoCorpseLoot.toggle.isEnabled = false; + game.Loop(); + CHECK(fake::SentPackets().empty()); + } + } +} diff --git a/tests/ScreenInfoTests.cpp b/tests/ScreenInfoTests.cpp new file mode 100644 index 00000000..987dfe25 --- /dev/null +++ b/tests/ScreenInfoTests.cpp @@ -0,0 +1,264 @@ +// Modules/ScreenInfo: the lines shown under the automap ("automap info" in BH.json, with %GAMENAME%, +// %GAMEPASS%, %LEVEL%, %AREALEVEL%, %PING%... filled in, and lines whose value is empty left out), +// and the experience meter (progress through the current level and gained since joining). +#include +#include +#include +#include + +#include "doctest/doctest.h" + +#include "BH.h" +#include "Constants.h" +#include "D2DataTables.h" +#include "D2Ptrs.h" +#include "FakeEngine.h" +#include "Modules/ScreenInfo/ScreenInfo.h" + +namespace { + +const DWORD kWorldstoneKeep2 = 129; +const DWORD kBloodMoor = 2; +const DWORD kColdPlains = 3; + +// A player standing in `levelNo` of a Battle.net game, with the automap info lines set to `lines`. +struct Automap { + GameStructInfo gameInfo{}; + BnetData bnet; + Path path{}; + Room1 room1{}; + Room2 room2{}; + Level level{}; + std::unique_ptr tables; + std::vector levels; + ScreenInfo module; + + Automap(DWORD levelNo, std::vector lines) : tables(new sgptDataTable()), levels(200) { + std::memset(&bnet, 0, sizeof(bnet)); + strcpy_s(bnet.szGameName, "Baal-7"); + strcpy_s(bnet.szGamePass, "pw"); + strcpy_s(bnet.szGameIP, "10.0.0.5"); + strcpy_s(bnet.szAccountName, "acct"); + bnet.nCharFlags = PLAYER_TYPE_EXPANSION; + fake::Var(Var_D2CLIENT_GameInfo) = &gameInfo; + fake::Var(Var_D2LAUNCH_BnData) = &bnet; + + UnitAny& player = fake::Player(); + strcpy_s(player.pPlayerData->szName, "Hero"); + player.pPath = &path; + path.pRoom1 = &room1; + room1.pRoom2 = &room2; + room2.pLevel = &level; + level.dwLevelNo = levelNo; + + std::memset(tables.get(), 0, sizeof(sgptDataTable)); + std::memset(levels.data(), 0, levels.size() * sizeof(LevelsTxt)); + tables->pLevelsTxt = levels.data(); + fake::Var(Var_D2COMMON_sgptDataTable) = tables.get(); + + fake::Var(Var_D2CLIENT_Ping) = 42; + fake::Var(Var_D2CLIENT_ScreenSizeX) = 800; + fake::Var(Var_D2CLIENT_ScreenSizeY) = 600; + fake::SetLevelName(kWorldstoneKeep2, L"Worldstone Keep Level 2"); + fake::SetLevelName(kBloodMoor, L"Blood Moor"); + fake::SetLevelName(kColdPlains, L"Cold Plains"); + fake::SetLevelName(MAP_A5_HARROGATH, L"Harrogath"); + fake::SetDifficulty(2); + + App.screen.automapInfo.values = lines; + module.OnGameJoin(); + } + + // Monster level of a level in each difficulty: classic and expansion characters. + void SetMonsterLevel(DWORD levelNo, WORD classic, WORD expansion) { + for (int difficulty = 0; difficulty < 3; difficulty++) { + levels[levelNo].wMonLvl[difficulty] = static_cast(classic + difficulty); + levels[levelNo].wMonLvlEx[difficulty] = static_cast(expansion + difficulty); + } + } + + std::vector Draw() { + size_t before = fake::Drawn().size(); + module.OnAutomapDraw(); + std::vector lines; + for (size_t i = before; i < fake::Drawn().size(); i++) { + lines.push_back(fake::Drawn()[i].text); + } + return lines; + } +}; + +std::vector W(std::initializer_list values) { + return std::vector(values.begin(), values.end()); +} + +// A player who joined the game at `level` with `exp` experience. +struct ExperienceMeter { + BnetData bnet; + ScreenInfo module; + int quests = 0; + + ExperienceMeter(int level, DWORD exp) { + std::memset(&bnet, 0, sizeof(bnet)); + fake::Var(Var_D2LAUNCH_BnData) = &bnet; + fake::SetQuestInfo(&quests); + fake::Var(Var_D2CLIENT_ScreenSizeX) = 800; + fake::Var(Var_D2CLIENT_ScreenSizeY) = 600; + App.game.experienceMeter.value = true; + fake::SetStat(&fake::Player(), STAT_LEVEL, level); + fake::SetStat(&fake::Player(), STAT_EXP, static_cast(exp)); + module.OnGameJoin(); + // The quest state is known (packet 0x52), so the meter does not ask the server for it. + std::vector questPacket(64, 1); + questPacket[0] = 0x52; + bool block = false; + module.OnGamePacketRecv(questPacket.data(), &block); + } + + // The meter text after reaching `level` with `exp`, up to the experience rate. + std::wstring Show(int level, DWORD exp) { + fake::SetStat(&fake::Player(), STAT_LEVEL, level); + fake::SetStat(&fake::Player(), STAT_EXP, static_cast(exp)); + size_t before = fake::Drawn().size(); + module.OnDraw(); + if (fake::Drawn().size() != before + 1) { + return L""; + } + std::wstring text = fake::Drawn().back().text; + return text.substr(0, text.find(L" [")); + } +}; + +} // namespace + +TEST_SUITE("ScreenInfo") { + TEST_CASE("automap info fills in the game, character and connection details") { + Automap automap(kWorldstoneKeep2, { + "Name: %GAMENAME%", + "Password: %GAMEPASS%", + "%GAMEDIFF%", + "Ping: %PING%", + "%CHARNAME%@%ACCOUNTNAME% on %GAMEIP%", + }); + CHECK(automap.Draw() == W({ + L"Name: Baal-7", + L"Password: pw", + L"Hell", + L"Ping: 42", + L"Hero@acct on 10.0.0.5", + })); + } + + TEST_CASE("automap info names each difficulty") { + Automap automap(kWorldstoneKeep2, { "%GAMEDIFF%" }); + fake::SetDifficulty(0); + CHECK(automap.Draw() == W({ L"Normal" })); + fake::SetDifficulty(1); + CHECK(automap.Draw() == W({ L"Nightmare" })); + } + + TEST_CASE("lines without tokens are shown as written") { + Automap automap(kWorldstoneKeep2, { "gl hf", "100% magic find" }); + CHECK(automap.Draw() == W({ L"gl hf", L"100% magic find" })); + } + + TEST_CASE("a line whose token has no value is left out, without leaving a gap") { + Automap automap(kWorldstoneKeep2, { "Name: %GAMENAME%", "Password: %GAMEPASS%", "Ping: %PING%" }); + automap.bnet.szGamePass[0] = 0; + CHECK(automap.Draw() == W({ L"Name: Baal-7", L"Ping: 42" })); + REQUIRE(fake::Drawn().size() == 2); + CHECK(fake::Drawn()[1].y - fake::Drawn()[0].y == 16); + } + + TEST_CASE("the game password is masked when hideGamePassword is on") { + Automap automap(kWorldstoneKeep2, { "Password: %GAMEPASS%" }); + App.screen.hideGamePassword.value = true; + CHECK(automap.Draw() == W({ L"Password: **" })); + + // No password: still nothing to show. + automap.bnet.szGamePass[0] = 0; + CHECK(automap.Draw().empty()); + } + + TEST_CASE("the level line shows the area name and its monster level for an expansion character") { + Automap automap(kWorldstoneKeep2, { "%LEVEL%", "Area level: %AREALEVEL%" }); + automap.SetMonsterLevel(kWorldstoneKeep2, 40, 83); // hell: 42 classic, 85 expansion + CHECK(automap.Draw() == W({ L"Worldstone Keep Level 2 (85)", L"Area level: 85" })); + } + + TEST_CASE("the level line uses the classic monster level for a classic character") { + Automap automap(kBloodMoor, { "%LEVEL%" }); + automap.bnet.nCharFlags = 0; + automap.SetMonsterLevel(kBloodMoor, 1, 60); + fake::SetDifficulty(1); + CHECK(automap.Draw() == W({ L"Blood Moor (2)" })); + } + + TEST_CASE("towns have no monster level: the level line is the name alone and the area level line is left out") { + Automap automap(MAP_A5_HARROGATH, { "%LEVEL%", "Area level: %AREALEVEL%" }); + CHECK(automap.Draw() == W({ L"Harrogath" })); + } + + TEST_CASE("a corrupted zone shows in purple at monster level 85") { + Automap automap(kBloodMoor, { "%LEVEL%", "%AREALEVEL%" }); + automap.SetMonsterLevel(kBloodMoor, 60, 65); + fake::Player().pPlayerData->nCorruptZone = 1; // Blood Moor + Den of Evil + CHECK(automap.Draw() == W({ L"\u00FF" L"c;Blood Moor (85)", L"85" })); + } + + TEST_CASE("areas outside the corrupted zone keep their own monster level") { + Automap automap(kColdPlains, { "%LEVEL%" }); + automap.SetMonsterLevel(kColdPlains, 60, 65); + fake::Player().pPlayerData->nCorruptZone = 1; // Blood Moor + Den of Evil + CHECK(automap.Draw() == W({ L"Cold Plains (67)" })); + } + + TEST_CASE("Allocated Loot is shown under the info lines when loot allocation is on") { + Automap automap(kWorldstoneKeep2, { "%GAMENAME%" }); + fake::Player().pPlayerData->nItemAllocation = 1; + CHECK(automap.Draw() == W({ L"Baal-7", L"Allocated Loot" })); + fake::Player().pPlayerData->nItemAllocation = 0; + CHECK(automap.Draw() == W({ L"Baal-7" })); + } + + TEST_CASE("automap info is right-aligned 10px from the screen edge") { + Automap automap(kWorldstoneKeep2, { "Ping: %PING%" }); + automap.Draw(); + REQUIRE(fake::Drawn().size() == 1); + CHECK(fake::Drawn()[0].x + static_cast(8 * fake::Drawn()[0].text.size()) == 800 - 10); + } + + TEST_CASE("experience table gives the experience at which each level starts") { + CHECK(ExpByLevel[0] == 0); // level 1 + CHECK(ExpByLevel[1] == 500); // level 2 + CHECK(ExpByLevel[2] == 1500); + CHECK(ExpByLevel[3] == 3750); + CHECK(ExpByLevel[4] == 7875); + CHECK(ExpByLevel[97] == 3229426756LL); // level 98 + CHECK(ExpByLevel[98] == 3520485254LL); // level 99 + } + + TEST_CASE("experience meter shows progress through the level and the gain since joining") { + ExperienceMeter meter(2, 500); // level 2 just reached (500 .. 1500) + CHECK(meter.Show(2, 500) == L"0.00% (+0.00%)"); + CHECK(meter.Show(2, 1000) == L"50.00% (+50.00%)"); + CHECK(meter.Show(2, 1490) == L"99.00% (+99.00%)"); + } + + TEST_CASE("experience meter counts whole levels gained since joining") { + ExperienceMeter meter(2, 1000); // 50% into level 2 + // Level 3 runs 1500 .. 3750: 1950 is 20% in. Gained: the other 50% of level 2, plus 20%. + CHECK(meter.Show(3, 1950) == L"20.00% (+70.00%)"); + // Level 4 starts at 3750: the rest of level 2, all of level 3. + CHECK(meter.Show(4, 3750) == L"0.00% (+150.00%)"); + } + + TEST_CASE("experience meter is hidden when turned off or while the help screen is open") { + ExperienceMeter meter(2, 500); + fake::SetUIVar(UI_HELP_MENU, 1); + CHECK(meter.Show(2, 1000) == L""); + fake::SetUIVar(UI_HELP_MENU, 0); + App.game.experienceMeter.value = false; + CHECK(meter.Show(2, 1000) == L""); + } +} diff --git a/tests/StatsDisplayTests.cpp b/tests/StatsDisplayTests.cpp new file mode 100644 index 00000000..503f37b0 --- /dev/null +++ b/tests/StatsDisplayTests.cpp @@ -0,0 +1,373 @@ +// The character stats panel (Drawing/Stats/StatsDisplay.cpp): the FCR/FHR breakpoint tables and the +// breakpoint line it prints, the weapon class it derives from the equipped weapon (which picks the +// Paladin spear/staff and Druid one-hand FHR tables), the act used for the experience penalty, and +// how the panel is placed, opened and closed. +// +// Expected breakpoints come from the Project Diablo 2 wiki (https://wiki.projectdiablo2.com/wiki/Breakpoints); +// weapon rows from the game's Weapons.txt order (txt row = dwTxtFileNo). +#include +#include +#include +#include +#include + +#include "doctest/doctest.h" + +#include "BH.h" +#include "Constants.h" +#include "Drawing/Stats/StatsDisplay.h" +#include "FakeEngine.h" +#include "Modules/Item/Item.h" + +// The breakpoint tables StatsDisplay.cpp draws from (globals in that file), keyed by character class, +// mercenary monster id, or one of the file's alias keys below. +extern std::map> faster_hit_recovery_frames; +extern std::map> faster_cast_rate_frames; + +namespace { + +using Drawing::StatsDisplay; + +// StatsDisplay.cpp's alias keys for the shape-shifted / weapon-specific tables. +const DWORD kWolfForm = 139; +const DWORD kBearForm = 140; +const DWORD kDruidOneHandSwinging = 141; +const DWORD kPaladinSpearOrStaff = 142; +const DWORD kSorceressSlowSpells = 143; + +std::vector V(std::initializer_list values) { + return std::vector(values); +} + +// A stats panel on a width x height screen (800x600 by default). +// +// StatsDisplay's constructor reads members it has not set yet: it calls SetXSize(300), which checks +// the size against GetX(), before x is ever assigned, and LoadConfig's SetYSize keeps the old +// (uninitialised) height when it rejects a new one. In the game those members hold whatever the +// heap had. Here the panel is built in zeroed storage so that the tests are deterministic; this +// stands in for the missing initialisation and so hides that constructor bug (see the 800x600 +// should_fail below for the user-visible half of it). +// +// The panel is never destroyed: the constructor stores `this` in the static StatsDisplay::display +// that BH's draw/click/key hooks use, so the object must outlive the test. +struct Panel { + StatsDisplay* display; + + explicit Panel(DWORD width = 800, DWORD height = 600) { + fake::Var(Var_D2CLIENT_ScreenSizeX) = width; + fake::Var(Var_D2CLIENT_ScreenSizeY) = height; + void* storage = ::operator new(sizeof(StatsDisplay)); + std::memset(storage, 0, sizeof(StatsDisplay)); + display = new (storage) StatsDisplay("Stats"); + } + StatsDisplay* operator->() { + return display; + } +}; + +// The breakpoint line for a player with `value` of `stat`. +std::string BreakpointLine(int stat, int value, const std::vector& table) { + Panel panel; + fake::SetStat(&fake::Player(), stat, value); + char line[256] = ""; + panel->GetBreakpointString(&fake::Player(), stat, table, line); + return line; +} + +// AllStatList entries for stat ids 0..count-1, removed again when the test ends. +struct StatNames { + std::vector props; + explicit StatNames(int count) : props(count) { + for (int i = 0; i < count; i++) { + props[i].name = L"stat" + std::to_wstring(i); + props[i].statId = static_cast(i); + } + AllStatList.clear(); + for (auto& p : props) { + AllStatList.push_back(&p); + } + } + ~StatNames() { + AllStatList.clear(); + } +}; + +} // namespace + +TEST_SUITE("StatsDisplay") { + TEST_CASE("FCR breakpoint tables match the PD2 cast-rate breakpoints for every class") { + CHECK(faster_cast_rate_frames.at(CLASS_AMA) == V({ 7, 14, 22, 32, 48, 68, 99, 152 })); + CHECK(faster_cast_rate_frames.at(CLASS_ASN) == V({ 8, 16, 27, 42, 65, 102, 174 })); + CHECK(faster_cast_rate_frames.at(CLASS_BAR) == V({ 9, 20, 37, 63, 105, 200 })); + CHECK(faster_cast_rate_frames.at(CLASS_DRU) == V({ 4, 10, 19, 30, 46, 68, 99, 163 })); + CHECK(faster_cast_rate_frames.at(CLASS_NEC) == V({ 9, 18, 30, 48, 75, 125 })); + CHECK(faster_cast_rate_frames.at(CLASS_PAL) == V({ 9, 18, 30, 48, 75, 125 })); + CHECK(faster_cast_rate_frames.at(CLASS_SOR) == V({ 9, 20, 37, 63, 105, 200 })); + } + + TEST_CASE("FCR tables for wereforms, slow sorceress spells and casting mercenaries match PD2") { + CHECK(faster_cast_rate_frames.at(kBearForm) == V({ 7, 15, 26, 40, 63, 99, 163 })); + CHECK(faster_cast_rate_frames.at(kWolfForm) == V({ 6, 14, 26, 40, 60, 95, 157 })); + // Chain Lightning / Frozen Orb + CHECK(faster_cast_rate_frames.at(kSorceressSlowSpells) == V({ 7, 15, 23, 35, 52, 78, 117, 194 })); + CHECK(faster_cast_rate_frames.at(MERC_A3) == V({ 8, 15, 26, 39, 58, 86, 138 })); + CHECK(faster_cast_rate_frames.at(MERC_A4) == V({ 9, 18, 30, 48, 75, 125 })); + } + + TEST_CASE("FHR breakpoint tables match the PD2 hit-recovery breakpoints for every class") { + CHECK(faster_hit_recovery_frames.at(CLASS_AMA) == V({ 6, 13, 20, 32, 52, 86, 174, 600 })); + CHECK(faster_hit_recovery_frames.at(CLASS_ASN) == V({ 7, 15, 27, 48, 86, 200 })); + CHECK(faster_hit_recovery_frames.at(CLASS_BAR) == V({ 7, 15, 27, 48, 86, 200 })); + CHECK(faster_hit_recovery_frames.at(CLASS_DRU) == V({ 5, 10, 16, 26, 39, 56, 86, 152, 377 })); + CHECK(faster_hit_recovery_frames.at(CLASS_NEC) == V({ 5, 10, 16, 26, 39, 56, 86, 152, 377 })); + CHECK(faster_hit_recovery_frames.at(CLASS_PAL) == V({ 7, 15, 27, 48, 86, 200 })); + CHECK(faster_hit_recovery_frames.at(CLASS_SOR) == V({ 5, 9, 14, 20, 30, 42, 60, 86, 142, 280 })); + } + + TEST_CASE("FHR tables for wereforms, druid one-hand swinging weapons and mercenaries match PD2") { + CHECK(faster_hit_recovery_frames.at(kBearForm) == V({ 5, 10, 16, 24, 37, 54, 86, 152, 360 })); + CHECK(faster_hit_recovery_frames.at(kWolfForm) == V({ 9, 20, 42, 86, 280 })); + CHECK(faster_hit_recovery_frames.at(kDruidOneHandSwinging) == V({ 3, 7, 13, 19, 29, 42, 63, 99, 174, 456 })); + CHECK(faster_hit_recovery_frames.at(MERC_A1) == V({ 6, 13, 20, 32, 52, 86, 174, 600 })); + CHECK(faster_hit_recovery_frames.at(MERC_A2) == V({ 5, 9, 14, 20, 30, 42, 60, 86, 142, 280 })); + CHECK(faster_hit_recovery_frames.at(MERC_A3) == V({ 5, 8, 13, 18, 24, 32, 46, 63, 86, 133, 232, 600 })); + } + + // Vanilla Diablo II's Paladin spear/staff table (13 frames): 0/3/7/13/20/32/48/75/129/280. The PD2 + // wiki's row shows 120 for the 5-frame breakpoint, but the same page says PD2 changed no FHR + // breakpoints, and only 129 is consistent with the 3% and 280% breakpoints of a 13-frame animation. + TEST_CASE("FHR table for a Paladin holding a spear or staff is the vanilla 13-frame table") { + CHECK(faster_hit_recovery_frames.at(kPaladinSpearOrStaff) == V({ 3, 7, 13, 20, 32, 48, 75, 129, 280 })); + } + + // BUG: the Act 4 mercenary (Ascendant) uses the Druid wolf-form FHR table (7 frames: 9/20/42/86/280). + // The PD2 wiki lists the Ascendant with a 13-frame hit recovery whose breakpoints start at 3% and + // number nine (0/3/7/13/20/32/48/75/1xx/280, the Paladin spear/staff row). Only the certain part is + // asserted: the wiki's 120 for the 5-frame breakpoint is unverified (vanilla 13-frame tables say 129). + TEST_CASE("FHR table for the Act 4 mercenary is a 13-frame table" * doctest::should_fail()) { + const std::vector& table = faster_hit_recovery_frames.at(MERC_A4); + CHECK(table.size() == 9); + CHECK(table.front() == 3); + } + + TEST_CASE("breakpoint line highlights the highest breakpoint reached") { + const std::vector sorc = { 9, 20, 37, 63, 105, 200 }; + // Below the first breakpoint nothing is highlighted. + CHECK(BreakpointLine(STAT_FASTERCAST, 0, sorc) == "9 / 20 / 37 / 63 / 105 / 200"); + CHECK(BreakpointLine(STAT_FASTERCAST, 8, sorc) == "9 / 20 / 37 / 63 / 105 / 200"); + // Exactly on a breakpoint, and one short of the next. + CHECK(BreakpointLine(STAT_FASTERCAST, 9, sorc) == "ÿc89ÿc0 / 20 / 37 / 63 / 105 / 200"); + CHECK(BreakpointLine(STAT_FASTERCAST, 62, sorc) == "9 / 20 / ÿc837ÿc0 / 63 / 105 / 200"); + CHECK(BreakpointLine(STAT_FASTERCAST, 63, sorc) == "9 / 20 / 37 / ÿc863ÿc0 / 105 / 200"); + CHECK(BreakpointLine(STAT_FASTERCAST, 199, sorc) == "9 / 20 / 37 / 63 / ÿc8105ÿc0 / 200"); + } + + TEST_CASE("breakpoint line highlights the last breakpoint once it is reached or exceeded") { + const std::vector sorc = { 9, 20, 37, 63, 105, 200 }; + CHECK(BreakpointLine(STAT_FASTERCAST, 200, sorc) == "9 / 20 / 37 / 63 / 105 / ÿc8200"); + CHECK(BreakpointLine(STAT_FASTERCAST, 350, sorc) == "9 / 20 / 37 / 63 / 105 / ÿc8200"); + } + + TEST_CASE("breakpoint line reads the stat it is asked for") { + const std::vector pal = { 7, 15, 27, 48, 86, 200 }; + Panel panel; + fake::SetStat(&fake::Player(), STAT_FASTERCAST, 100); + fake::SetStat(&fake::Player(), STAT_FASTERHITRECOVERY, 27); + char line[256] = ""; + panel->GetBreakpointString(&fake::Player(), STAT_FASTERHITRECOVERY, pal, line); + CHECK(std::string(line) == "7 / 15 / ÿc827ÿc0 / 48 / 86 / 200"); + } + + TEST_CASE("weapon class follows the Weapons.txt rows of normal, exceptional and elite bases") { + // Normal tier + CHECK(StatsDisplay::GetCurrentWeaponType(0) == WeaponType::kAxe); // hax Hand Axe + CHECK(StatsDisplay::GetCurrentWeaponType(4) == WeaponType::kAxe); // wax War Axe + CHECK(StatsDisplay::GetCurrentWeaponType(5) == WeaponType::kAxe2H); // lax Large Axe + CHECK(StatsDisplay::GetCurrentWeaponType(9) == WeaponType::kAxe2H); // gix Giant Axe + CHECK(StatsDisplay::GetCurrentWeaponType(10) == WeaponType::kWand); // wnd Wand + CHECK(StatsDisplay::GetCurrentWeaponType(14) == WeaponType::kClub); // clb Club + CHECK(StatsDisplay::GetCurrentWeaponType(15) == WeaponType::kScepter); // scp Scepter + CHECK(StatsDisplay::GetCurrentWeaponType(18) == WeaponType::kClub); // spc Spiked Club + CHECK(StatsDisplay::GetCurrentWeaponType(19) == WeaponType::kMace); // mac Mace + CHECK(StatsDisplay::GetCurrentWeaponType(22) == WeaponType::kHammer); // whm War Hammer + CHECK(StatsDisplay::GetCurrentWeaponType(23) == WeaponType::kHammer2H); // mau Maul + CHECK(StatsDisplay::GetCurrentWeaponType(25) == WeaponType::kSword); // ssd Short Sword + CHECK(StatsDisplay::GetCurrentWeaponType(32) == WeaponType::kSword); // wsd War Sword + CHECK(StatsDisplay::GetCurrentWeaponType(33) == WeaponType::kSword2H); // 2hs Two-Handed Sword + CHECK(StatsDisplay::GetCurrentWeaponType(39) == WeaponType::kKnife); // dgr Dagger + CHECK(StatsDisplay::GetCurrentWeaponType(43) == WeaponType::kThrowing); // tkf Throwing Knife + CHECK(StatsDisplay::GetCurrentWeaponType(47) == WeaponType::kJavelin); // jav Javelin + CHECK(StatsDisplay::GetCurrentWeaponType(52) == WeaponType::kSpear); // spr Spear + CHECK(StatsDisplay::GetCurrentWeaponType(57) == WeaponType::kPole); // bar Bardiche + CHECK(StatsDisplay::GetCurrentWeaponType(63) == WeaponType::kStaff); // sst Short Staff + CHECK(StatsDisplay::GetCurrentWeaponType(68) == WeaponType::kBow); // sbw Short Bow + CHECK(StatsDisplay::GetCurrentWeaponType(76) == WeaponType::kCrossbow); // lxb Light Crossbow + CHECK(StatsDisplay::GetCurrentWeaponType(80) == WeaponType::kThrowingPot); // gps Rancid Gas Potion + // Quest weapons + CHECK(StatsDisplay::GetCurrentWeaponType(87) == WeaponType::kKnife); // g33 The Gidbinn + CHECK(StatsDisplay::GetCurrentWeaponType(88) == WeaponType::kClub); // leg Wirt's Leg + CHECK(StatsDisplay::GetCurrentWeaponType(89) == WeaponType::kHammer); // hdm Horadric Malus + CHECK(StatsDisplay::GetCurrentWeaponType(91) == WeaponType::kStaff); // hst Horadric Staff + CHECK(StatsDisplay::GetCurrentWeaponType(173) == WeaponType::kMace); // qf1 Khalim's Flail + CHECK(StatsDisplay::GetCurrentWeaponType(174) == WeaponType::kMace); // qf2 Khalim's Will + // Exceptional and elite tiers + CHECK(StatsDisplay::GetCurrentWeaponType(93) == WeaponType::kAxe); // 9ha Hatchet + CHECK(StatsDisplay::GetCurrentWeaponType(145) == WeaponType::kSpear); // 9sr War Spear + CHECK(StatsDisplay::GetCurrentWeaponType(156) == WeaponType::kStaff); // 8ss Jo Staff + CHECK(StatsDisplay::GetCurrentWeaponType(196) == WeaponType::kAxe); // 7ha Tomahawk + CHECK(StatsDisplay::GetCurrentWeaponType(210) == WeaponType::kClub); // 7cl Truncheon + CHECK(StatsDisplay::GetCurrentWeaponType(248) == WeaponType::kSpear); // 7sr Hyperion Spear + CHECK(StatsDisplay::GetCurrentWeaponType(259) == WeaponType::kStaff); // 6ss Walking Stick + CHECK(StatsDisplay::GetCurrentWeaponType(275) == WeaponType::kCrossbow); // 6rx Demon Crossbow + } + + TEST_CASE("weapon class of claws, orbs and Amazon weapons") { + for (int row = 175; row <= 195; row++) { // ktr Katar .. 7qr Scissors Suwayyah + CAPTURE(row); + WeaponType type = StatsDisplay::GetCurrentWeaponType(row); + CHECK((type == WeaponType::kClaw1 || type == WeaponType::kClaw2)); + } + CHECK(StatsDisplay::GetCurrentWeaponType(276) == WeaponType::kOrb); // ob1 Eagle Orb + CHECK(StatsDisplay::GetCurrentWeaponType(281) == WeaponType::kAmaBow); // am1 Stag Bow + CHECK(StatsDisplay::GetCurrentWeaponType(283) == WeaponType::kAmaSpear); // am3 Maiden Spear + CHECK(StatsDisplay::GetCurrentWeaponType(285) == WeaponType::kAmaJav); // am5 Maiden Javelin + CHECK(StatsDisplay::GetCurrentWeaponType(300) == WeaponType::kOrb); // obf Dimensional Shard + CHECK(StatsDisplay::GetCurrentWeaponType(305) == WeaponType::kAmaJav); // amf Matriarchal Javelin + } + + TEST_CASE("weapon class is unknown outside the Weapons.txt rows") { + CHECK(StatsDisplay::GetCurrentWeaponType(-1) == WeaponType::kUnknown); + CHECK(StatsDisplay::GetCurrentWeaponType(306) == WeaponType::kUnknown); + CHECK(StatsDisplay::GetCurrentWeaponType(1000) == WeaponType::kUnknown); + } + + TEST_CASE("act index for the experience penalty follows the act boundaries") { + // Act 1: Rogue Encampment .. Cow Level + CHECK(StatsDisplay::GetActIndex(MAP_A1_ROGUE_ENCAMPMENT, 0) == 0); + CHECK(StatsDisplay::GetActIndex(39, 0) == 0); + // Act 2: Lut Gholein .. Arcane Sanctuary / Duriel's Lair + CHECK(StatsDisplay::GetActIndex(MAP_A2_LUT_GHOLEIN, 0) == 1); + CHECK(StatsDisplay::GetActIndex(MAP_A2_ARCANE_SANCTUARY, 0) == 1); + CHECK(StatsDisplay::GetActIndex(MAP_A3_KURAST_DOCKS - 1, 0) == 1); + // Act 3: Kurast Docks .. Durance of Hate 3 + CHECK(StatsDisplay::GetActIndex(MAP_A3_KURAST_DOCKS, 0) == 2); + CHECK(StatsDisplay::GetActIndex(MAP_A3_DURANCE_OF_HATE_LEVEL_3, 0) == 2); + // Act 4: Pandemonium Fortress .. Chaos Sanctuary + CHECK(StatsDisplay::GetActIndex(MAP_A4_THE_PANDEMONIUM_FORTRESS, 0) == 3); + CHECK(StatsDisplay::GetActIndex(MAP_A4_THE_CHAOS_SANCTUARY, 0) == 3); + // Act 5: Harrogath .. Worldstone Chamber, Pandemonium event areas and Uber Tristram + CHECK(StatsDisplay::GetActIndex(MAP_A5_HARROGATH, 0) == 4); + CHECK(StatsDisplay::GetActIndex(MAP_A5_WORLDSTONE_KEEP, 0) == 4); + CHECK(StatsDisplay::GetActIndex(MAP_A5_FURNACE_OF_PAIN, 0) == 4); + CHECK(StatsDisplay::GetActIndex(MAP_A5_TRISTRAM, 0) == 4); + } + + TEST_CASE("act index counts five acts per difficulty") { + CHECK(StatsDisplay::GetActIndex(MAP_A1_ROGUE_ENCAMPMENT, 1) == 5); + CHECK(StatsDisplay::GetActIndex(MAP_A1_ROGUE_ENCAMPMENT, 2) == 10); + CHECK(StatsDisplay::GetActIndex(MAP_A3_KURAST_DOCKS, 1) == 7); + CHECK(StatsDisplay::GetActIndex(MAP_A5_HARROGATH, 2) == 14); + CHECK(StatsDisplay::GetActIndex(MAP_A5_TRISTRAM, 2) == 14); + } + + TEST_CASE("act index is unknown when the area is unknown") { + CHECK(StatsDisplay::GetActIndex(MAP_UNKNOWN, 0) == -1); + CHECK(StatsDisplay::GetActIndex(MAP_UNKNOWN, 2) == -1); + } + + TEST_CASE("panel sits 10px from the left edge by default and 10px from the right edge with statsOnRight") { + { + Panel panel; + CHECK(panel->GetX() == 10); + CHECK(panel->GetY() == 10); + } + App.general.statsOnRight.value = true; + Panel panel; + CHECK(panel->GetX() + panel->GetXSize() == 800 - 10); + CHECK(panel->GetY() == 10); + } + + TEST_CASE("the panel grows by one 16px row per extra stat from the config, plus one 8px gap") { + StatNames names(10); + STAT_MAX = 10; + int base; + { + Panel panel(1024, 768); + base = panel->GetYSize(); + } + App.screen.additionalStats.values["7"] = ""; + { + Panel panel(1024, 768); + CHECK(panel->GetYSize() == base + 16 + 8); + } + App.screen.additionalStats.values[" 9 "] = "1"; // STAT_MAX - 1, the last stat id + Panel panel(1024, 768); + CHECK(panel->GetYSize() == base + 2 * 16 + 8); + } + + TEST_CASE("extra stats that are not stat ids are ignored") { + StatNames names(10); + STAT_MAX = 10; + int base; + { + Panel panel(1024, 768); + base = panel->GetYSize(); + } + App.screen.additionalStats.values["life"] = ""; + App.screen.additionalStats.values["11"] = ""; // STAT_MAX + 1 + App.screen.additionalStats.values["-1"] = ""; + Panel panel(1024, 768); + CHECK(panel->GetYSize() == base); + } + + // BUG: on the game's 800x600 screen, two extra stats make the computed height (558 + 2*16 + 8 = 598) + // taller than the space below the panel (600 - 10), and SetYSize silently refuses it instead of + // clamping. The panel keeps its previous height, which on a fresh panel was never set (0 here; + // whatever the heap held in the game), so the box no longer covers the stat rows it draws and + // clicking on them does not close it. + TEST_CASE("with two extra stats on an 800x600 screen the open panel still covers its rows" * doctest::should_fail()) { + StatNames names(10); + STAT_MAX = 10; + App.screen.additionalStats.values["7"] = ""; + App.screen.additionalStats.values["8"] = ""; + Panel panel(800, 600); + panel->SetMinimized(false); + CHECK(panel->OnClick(false, panel->GetX() + 5, panel->GetY() + 500)); + CHECK(panel->IsMinimized()); + } + + TEST_CASE("clicking inside the open panel closes it and swallows the click") { + Panel panel; + panel->SetMinimized(false); + unsigned int right = panel->GetX() + panel->GetXSize(); + unsigned int bottom = panel->GetY() + panel->GetYSize(); + + CHECK_FALSE(panel->OnClick(false, right + 1, panel->GetY())); + CHECK_FALSE(panel->OnClick(false, panel->GetX(), bottom + 1)); + CHECK_FALSE(panel->IsMinimized()); + + CHECK(panel->OnClick(false, right, bottom)); + CHECK(panel->IsMinimized()); + } + + TEST_CASE("clicks pass through while the panel is closed") { + Panel panel; + panel->SetMinimized(true); + CHECK_FALSE(panel->OnClick(false, panel->GetX() + 5, panel->GetY() + 5)); + CHECK(panel->IsMinimized()); + } + + TEST_CASE("Escape closes the open panel and is swallowed; other keys pass through") { + Panel panel; + panel->SetMinimized(false); + CHECK_FALSE(panel->OnKey(false, 'A', 0)); + CHECK_FALSE(panel->OnKey(true, VK_ESCAPE, 0)); + CHECK_FALSE(panel->IsMinimized()); + + CHECK(panel->OnKey(false, VK_ESCAPE, 0)); + CHECK(panel->IsMinimized()); + + // Closed: Escape belongs to the game again. + CHECK_FALSE(panel->OnKey(false, VK_ESCAPE, 0)); + } +} From df9af24b9b6df793f4d9ff040475000306d9f4df Mon Sep 17 00:00:00 2001 From: errolgr Date: Thu, 1 Oct 2026 02:14:07 -0400 Subject: [PATCH 8/8] Build and run the unit tests in CI A second job in build.yml builds tests/BH.Tests.vcxproj (MSVC Release|Win32) and runs it; any failing test fails the run. The doctest summary is in the log and a JUnit report is attached as an artifact. README explains how to run the tests locally. --- .github/workflows/build.yml | 35 ++++++++++++++++++++++++++++++++++- README.md | 11 +++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3afa6da..ebf5e9c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,8 @@ name: Build # Builds BH.dll (Release|Win32) with MSVC on every pull request and push to main, # so a change that breaks the build is caught before it is merged. The built DLL is -# attached to each run as a downloadable artifact for testing. +# attached to each run as a downloadable artifact for testing. A second job builds +# and runs the unit tests (tests/BH.Tests.vcxproj); any failing test fails the run. on: pull_request: @@ -43,3 +44,35 @@ jobs: **/Release/BH.dll **/Release/BH.pdb if-no-files-found: error + + tests: + name: Unit tests (MSVC Release|Win32) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: microsoft/setup-msbuild@v2 + + - name: Build tests + run: msbuild tests/BH.Tests.vcxproj /m /p:Configuration=Release /p:Platform=Win32 + + - name: Run tests + working-directory: tests + run: bin\Release\BH.Tests.exe --duration=false + + - name: Write JUnit report + if: always() + working-directory: tests + # Same tests again, written as JUnit XML for the artifact. Pass/fail is decided by + # the previous step, so this one never fails on its own. + run: | + bin\Release\BH.Tests.exe --reporters=junit --out=junit.xml + exit 0 + + - name: Upload JUnit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ github.sha }} + path: tests/junit.xml + if-no-files-found: ignore diff --git a/README.md b/README.md index 027f224b..fa6d0102 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,14 @@ You can change the command line arguments as you see fit. ``` Any debug or changed version will only work in *Single Player*. Do not enter multiplayer with a modified BH + +## Unit tests + +`tests/BH.Tests.vcxproj` builds a console program that runs BH's game-independent logic (loot filter rules, formulas, config, stash export templates and more) against small fakes of the game functions it calls. It is not part of `BH.sln`, so building BH is unchanged. CI builds and runs it on every pull request. + +``` +msbuild tests\BH.Tests.vcxproj /p:Configuration=Release /p:Platform=Win32 +tests\bin\Release\BH.Tests.exe +``` + +Or open `tests\BH.Tests.vcxproj` in Visual Studio and run it. Run `BH.Tests.exe --help` for filtering options (for example `-ts=LootFilterRules` runs one suite). Tests marked `should_fail` document known bugs: they fail today, and the run turns red once the bug is fixed so the marker can be removed.