Compare commits

...

15 commits

Author SHA1 Message Date
Lukas Brübach
77a0253242 [Client] Settings: match CI clang-format-18 line breaking 2026-10-11 18:27:40 +02:00
Lukas Brübach
c08b345b71 [Client] Settings: extract SettingsView from DlgSettings
Move the search bar, tab bar, stacked pages and search results out of
DlgSettings into a reusable SettingsView widget. The dialog becomes a
thin frame (title, OK button, close validation) delegating to the view.
No visual or behavior change; tr() contexts preserved as DlgSettings.
2026-10-11 18:27:40 +02:00
BruebachL
1a4d89ff8f
[Game] Don't alert game tab for spectator activity (#1848) (#7263)
Spectator join/leave events were triggering the game tab's attention
indicator (exclamation mark and taskbar alert) for players who often
leave the tab open while waiting for an opponent. This made spectator
activity a false alarm.

Only emit the user event when an actual player joins or leaves, keeping
spectator joins/leaves quiet.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 16:54:03 +02:00
BruebachL
4feb31bd1a
[Game] Show loaded deck messages to players joining a game (#7268)
* Show loaded deck messages to players joining a game (#552)

The "Player X has loaded a deck" message is only broadcast when a deck is
selected, so players and spectators who join a game afterwards never see it.

On game join, replay a deck-select event context privately to the joining
participant for every player that already has a deck loaded, reusing the
existing Event_PlayerPropertiesChanged + Context_DeckSelect mechanism so no
protocol version bump is needed.

- Server_AbstractPlayer: expose the sideboard-locked state
- Server_Game: synthesize deck-select contexts in createGameJoinedEvent

* [Server] Skip deck replay on rejoin and after start

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 16:53:26 +02:00
BruebachL
4c5a5e3e55
[Game] Invalidate view when hiding replaced card menu (#7453)
* [Game] Invalidate view when hiding replaced card menu

Hide any visible card menu before replacing and invalidate viewport.

* [Game] Fix viewport update compile error in setCardMenu

QWidget::update has no QRectF overload, and sceneRect is in scene
coordinates rather than viewport coordinates anyway, so invalidate the
whole viewport instead.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:36:11 +02:00
BruebachL
5d06bb903d
[Chat] Fix card completion after an @ mention (#7469)
An open [[ card link now takes precedence over a space-preceded @
mention, so typing an @ before a card no longer shadows the card
completer.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:35:10 +02:00
BruebachL
57a6a0d9cb
[Client] Warn when data paths point inside the program directory (#7473)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:34:49 +02:00
BruebachL
f83a61979f
[Installer] Uninstaller deletes only the installed payload, never the whole tree (#7472)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:34:49 +02:00
BruebachL
07eb9f0103
[Report] Prompt for a note on Resolve and drop the redundant option (#7445)
- Remove the third close option (Resolve with note...) so closing a
  report is one of two clear actions
- Make Resolve always prompt for the optional resolution note, matching
  Dismiss, and rename the button to Resolve...
- Halve the resolveReport signature now that every caller prompts

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:33:48 +02:00
BruebachL
38acddeede
[Report] Add context menu to report queue rows (#7444)
* [Report] Add report reopen action for closed reports

- New Command_ReportReopen moderator command (extension 1206) with server
  handler that reopens resolved/dismissed reports, resets resolution state
  and notified, and records the reopening note in the audit log
- Reopen... button in the report queue action bar, gated to resolved and
  dismissed reports, with optional note prompt and tooltip
- Success confirmations for Assign, Resolve/Dismiss and Reopen now survive
  the list refresh via a pending status message instead of being clobbered
- Dispatch/auth coverage in server_moderator_role_test

* [Report] Surface resolution audit info in report details

- Add resolved_by_name to ServerInfo_Report and join resolved_by users in
  the report list, my-list and details queries
- Render resolution note and resolved-at/by in the report detail pane
  instead of duplicating the description into the chat log context

* [Report] Add context menu to report queue rows

- Open the standard UserContextMenu on right-click/Menu key for the
  reported user, selecting the row first so the details panel matches
  the menu target
- Carry the reported user's level in ServerInfo_Report so the menu
  shows the same actions as the user list, with an IsUser fallback for
  deleted or unregistered users
- Wire the Private chat action through TabReport to TabSupervisor and
  retranslate the menu on live language switches

* [Server] Fix reported_user_level column indices in report queries

The SELECT lists resolved_by_name at index 10, shifting room_id to 11,
replay_id to 12, reported_user_id to 13 and reported_user_admin to 14.
The code read 12/13, so 'user exists' meant 'a replay exists' and the
admin flags came from the user id (e.g. id 5 -> admin+judge; users
without a replay reported as unregistered, hiding registered-only
actions). Fixed in cmdReportList and cmdReportMyList, plus the column
comment.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:33:47 +02:00
BruebachL
74a6acde91
[Report] Surface resolution audit info in report details (#7443)
- Add resolved_by_name to ServerInfo_Report and join resolved_by users in
  the report list, my-list and details queries
- Render resolution note and resolved-at/by in the report detail pane
  instead of duplicating the description into the chat log context

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:29:09 +02:00
BruebachL
4a010bd17f
[Report] Add report reopen action for closed reports (#7442)
- New Command_ReportReopen moderator command (extension 1206) with server
  handler that reopens resolved/dismissed reports, resets resolution state
  and notified, and records the reopening note in the audit log
- Reopen... button in the report queue action bar, gated to resolved and
  dismissed reports, with optional note prompt and tooltip
- Success confirmations for Assign, Resolve/Dismiss and Reopen now survive
  the list refresh via a pending status message instead of being clobbered
- Dispatch/auth coverage in server_moderator_role_test

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:28:45 +02:00
BruebachL
315cbae345
[Build] Extend PCH to libraries and grow IWYU-driven header sets (#7456)
The executables precompile Qt since #7235, but the libcockatrice_*
static libraries still reparse the full Qt Core stack in every TU.
Measured over the tree after the IWYU pass: 380 MB of header parse in
the libraries, 92% of it reachable from qtcore_pch.h.

- Apply qtcore_pch.h to the 14 Qt Core static library targets
  (skips libcockatrice_rng: its SFMT.c is C and cannot use a C++
  precompiled header).
- Grow qtcore_pch.h with 25 QtCore headers that IWYU shows are
  re-parsed across many TUs and were not already reachable from the
  existing list (JSON/QUuid cluster, QSettings, item models,
  threading, XmlStream, QTemporaryDir, ...).
- Grow qtwidgets_pch.h with 30 QtGui/QtWidgets headers (dialog
  cluster, item views, QImageReader, ...).

Only stable Qt headers go into the PCH, so it rarely invalidates.
Test executables stay PCH-less: 1-3 TUs per target does not amortize
the extra precompile.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:07:30 +02:00
BruebachL
bdda3dedfa
[CI] Publish a gcovr line-coverage report from the test run (#7404)
* [Tests] Register each test case with CTest instead of each test binary

Registering a test cost four edits across a dozen CMakeLists files: add_test,
add_executable, a conditional add_dependencies on gtest, and target_link_libraries.
The conditional branch was the trap -- it is only needed when GTest was fetched and
add_subdirectory'd, and omitting it yields undefined gtest symbols at link time --
so it was easy to leave out and impossible to notice until the link failed.

Fold all four steps into cockatrice_add_test() and migrate every test directory
onto it, with a cockatrice_add_manual_test() wrapper for the three benchmarks that
are built but deliberately kept out of ctest. The calls now read as a declaration
of what a test is rather than a transcript of how to register one.

While rewriting the registrations, replace add_test(NAME x COMMAND x) with
gtest_discover_tests(). Naming the binary as a single CTest entry meant a CI failure
reported only which binary failed, and ctest -R Suite.Case could not select an
individual case. Discovery raises the suite from 35 opaque entries to 469 real ones:
ctest -R ThemeMigrationTest.IsIdempotent now runs exactly that case, and
ctest -L server / -LE bench work off labels applied at registration.

Give every test a 60s TIMEOUT. CTest's implicit default is 1500s, so one hung
socket wait would stall CI for 25 minutes, and single_instance_manager_test already
does real QLocalSocket waits.

Dropping per-case granularity costs wall-clock time, since the suite becomes
process-spawn bound rather than CPU bound: serial ctest went from 3.8s to 30.8s. Run
it with -j in CI to recover it -- 7.6s at -j4, and 3.5s at -j8, both at or below
the old serial time. The binaries are parallel-safe: the only test claiming a fixed
resource scopes its local socket name to its own pid, so concurrent copies cannot
collide.

Also delete dummy_test, which asserted 1 == 1 and only proved the harness was
wired up -- something the other 468 cases already establish.

No test is added, removed, or changed by this commit; the 469 cases are the same
ones the 35 binaries contained.

* [Tests] Make QtTest available to test targets

QtTest was not among the modules test targets could link, so the Qt model and
signal contracts had no way to be verified from a test: QAbstractItemModelTester
and QSignalSpy both live there.

Add Test to _TEST_NEEDED in cmake/FindQtRuntime.cmake. That is the single place the
union of test-only Qt modules is declared, so this is the only file that needs to
change to make the module reachable from every test target.

Nothing uses it yet. This lands the capability ahead of the model-layer tests that
need it, so those can be added as tests alone rather than as test plus build-system
plumbing.

* [Tests] Correct the note on the oracle test compression defines

add_definitions() populates the directory's COMPILE_DEFINITIONS property, which is
consumed at generate time, so it reaches every target in the directory no matter
where it is called relative to add_executable(). The previous note claimed the
flags stayed confined to the benchmark because the find_package block came after
it; oracle_importer_test, created above that block, carries both defines too.

Verified harmless rather than a live bug: HAS_ZLIB and HAS_LZMA gate code only in
oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, and no test target compiles
either. The ordering and the leaked defines are both pre-existing, so this only
corrects the explanation.

* [Tests] Carry one CTest label per test instead of several

gtest_discover_tests() can hand CTest exactly one label. GoogleTestAddTests.cmake
accumulates its generated script with `foreach(arg ${ARGN})`, so a label list reaches
the CTestTestfile as bare `LABELS ci server`, and CTest reads only the first value of a
property and drops the rest without a word.

The `ci` label that is appended to every registered test was therefore absent from every
test that also asked for `server` or `bench`: `ctest -L ci` reported 400 of 469 cases,
and `ctest --print-labels` never listed a second label anywhere in the build.

Reduce each test to exactly one of `ci`, `server`, or `bench`, which is all the
selections need. The three now partition the suite exactly, 400 + 64 + 5 = 469, and
`ctest -LE bench` still means everything but benchmarks. A second label is now a
configure-time FATAL_ERROR rather than a line that quietly means something else.

Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it
composes with the group instead of competing for the single slot.

Also raise DISCOVERY_TIMEOUT off GoogleTestAddTests' 5s default. A plain debug binary
answers --gtest_list_tests in 0.04s, but that budget is thin once several binaries
discover concurrently behind a -j build, and being over it fails at the *link* step with
a bare "Process terminated due to timeout" that points nowhere near the cause.

* [Tests] Link gtest_main on the fetched-gtest path

The downloaded-gtest branch set GTEST_BOTH_LIBRARIES to `gtest` alone, while
FindGTest sets it to both halves. Every test target links that list rather than
picking an entry point, so on this path nothing provides main() and any test
source that does not define its own fails at link with `undefined reference to
main` pointing at crt1.o, naming neither gtest nor the line responsible.

That stayed latent while every test carried its own four-line main(). A test that
omits it now fails, and the error points at the C runtime rather than at gtest,
which is a long way from the cause. Restoring both halves makes the two paths
agree and drops the boilerplate requirement on new test sources.

The 18 sources that still define main() keep linking: gtest_main.cc defines
nothing but main, so the linker leaves its object in the archive unused rather
than reporting a duplicate.

* [Build] Add an ENABLE_SANITIZERS option for ASan and UBSan

Adds -DENABLE_SANITIZERS, off by default, building with AddressSanitizer and
UndefinedBehaviorSanitizer. The bulk of the work is in the three ways the obvious
implementation silently does the wrong thing.

Probe the flags on the link step, not just the compile step. check_cxx_compiler_flag()
only compiles, so every sanitizer flag comes back unsupported, because linking an ASan
object without -fsanitize=address leaves __asan_init undefined. The result is not a
build failure but a build that appears instrumented and is not, which is worse. Use
check_cxx_source_compiles() with CMAKE_REQUIRED_LINK_OPTIONS instead, and treat an
unsupported sanitizer pair as fatal since the cosmetic flags are allowed to degrade.

Opt vendored sources out per source, through a cockatrice_disable_sanitizers_for()
helper rather than a list of set_source_files_properties() calls at the root. The
helper has to exist because CMake resolves a source-file property against the directory
scope its target was declared in: setting the property from the root with absolute paths
is a no-op, silently, which is why the call sites sit next to the add_library and
add_executable that pull the vendored code in. SFMT, minizip, the LZMA SDK and Qxt SMTP
are excluded. oracle/src/qt-json is in the history but not in the tree, removed in
#7214.

Also mark those sources SKIP_PRECOMPILE_HEADERS. The PCH is built with the sanitizer
flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with
-fno-sanitize=all does not and GCC then refuses the PCH, which is a fatal
-Werror=invalid-pch in the servatrice and oracle builds.

-fno-sanitize=vptr is not optional. Qt constructs QObject subclasses into raw storage
and writes the vptr afterwards, so the vptr check reports on essentially every QObject
instantiation, and it has no value here because nothing outside moc-generated code
inherits QObject.

Verified on the resulting build: our own translation units carry ASan and UBSan calls,
SFMT.o carries neither, and the full suite of 464 cases passes clean under
ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 with UBSAN_OPTIONS=halt_on_error=0.

The test suite needs two adjustments to make this option usable at all, both of which
land here because they are consequences of COCKATRICE_SANITIZERS existing rather than
independent changes.

DISCOVERY_TIMEOUT rises from 60s to 300s under sanitizers. An instrumented binary takes
2.8s to answer --gtest_list_tests against 0.04s uninstrumented, and discovery runs
several binaries at once behind a -j build.

One target is excluded via a NO_SANITIZE flag, server_developer_role_test. Reaching
google::protobuf::TextFormat::Printer's destructor aborts on an absl assertion inside
system protobuf 33.4 whenever the consumer translation unit is built without NDEBUG,
which a Debug sanitizer build is. This reproduces with no Cockatrice code at all, just
"TextFormat::Printer p; p.PrintToString(msg, &out);" built with -fsanitize=address, so it
is upstream rather than ours. It is a flag and not a fourth label because "skip this in
the sanitizer leg" has to compose with a test's group rather than compete for the single
label slot, and because a flag excludes by not registering the cases at all, which
cannot rot the way a label silently can. The target still builds and still runs on the
legs without sanitizers.

* [CI] Run the suite once under ASan and UBSan

Adds --sanitize to .ci/compile.sh, mirroring --test, and turns it on for one entry of
the existing Linux Docker matrix: Ubuntu 26.04, which already had a test-enabled leg.
That reuses the image, the ccache layer and the existing step shape, so the CI diff is a
matrix key plus the argument plumbing. Debian 13 and Fedora 44 keep covering the
uninstrumented suite, so nothing is lost by instrumenting one leg.

Sanitizers stay off the release and package legs, which build with --release for
packaging: instrumenting those would change the shipped binaries' optimization profile
and build time for no benefit. macOS and Windows stay uninstrumented too, since ASan is
unreliable against the Qt build there and MSVC has no equivalent without /fsanitize=address.

--sanitize does not imply --test, so an instrumented build can be produced locally
without paying for ctest. When both are given, ctest runs with detect_leaks=0 because Qt
and the sanitizer runtimes both leak at exit by design and LeakSanitizer's default buries
real findings under hundreds of process-teardown reports, and with UBSan halt_on_error=0 so
one run collects many findings instead of stopping at the first. ASan keeps
abort_on_error=1, since a bad access leaves the process unusable and continuing only
produces a second, more confusing report from the wreckage.

* [CI] Publish a gcovr line-coverage report from the test run

Sanitizers tell us what a run did wrong; they say nothing about what it never
reached. This adds the other half: an instrumented build whose report says which
of our own lines no test executes.

Add an ENABLE_COVERAGE option that compiles with --coverage -O0 -g, mirroring the
ENABLE_SANITIZERS opt-in including the per-source carve-out that keeps vendored
trees out of the denominator. -O0 is deliberate: at higher optimisation levels GCC
merges and relocates lines, so the report would describe code that no longer exists
in that form. Only GCC is supported, since Clang's intermediate format needs
llvm-cov; asking for coverage under Clang is a hard error rather than a build that
silently produces unreadable .gcno files.

Keep the filters in a committed gcovr.cfg rather than in the invocation. The tests
themselves are excluded because they are compiled with the same instrumentation
and are only ever executed by the run doing the measuring, so leaving them in would
report a healthier project than we have. The process entry points and the client's
window chrome go too: each needs a display, an interactive terminal or a live
database before it does anything a headless test could observe, so they would
otherwise sit at 0% forever and bury real movement.

Give coverage its own matrix leg rather than a second flag on the sanitizer leg.
The two answer different questions, and combining them slows both down without
telling us more. The new variant key gives the leg its own Docker image and ccache
entry so the two legs cannot race on the same tag.

The leg publishes an HTML report with per-file pages, LCOV, Cobertura XML and a
JSON summary. gcovr is a report-time dependency rather than a build dependency, so
a missing one warns and skips instead of failing an otherwise green build.

* [CI] Resolve the coverage source root before the build dir cd

The previous commit computed SRC_ROOT after `cd "$BUILD_DIR"`, under a comment
claiming it ran before. `$0` is relative to the directory the script was invoked
from, so from inside the build dir `dirname "$0"` no longer resolves and the
subshell's `cd` fails. Under `set -e` that is fatal rather than an empty variable,
which took down every leg in the matrix, coverage or not, including the ones this
change does not otherwise touch.

Move it above the cd and guard it on MAKE_COVERAGE, since the coverage report is
the only consumer. Resolving it unconditionally meant a coverage-only detail was
paid for by every build in the fleet, and gave every one of them a way to fail.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:05:28 +02:00
BruebachL
8822ade49d
[Tests] Register each test case with CTest instead of each test binary (#7399)
* [Tests] Register each test case with CTest instead of each test binary

Registering a test cost four edits across a dozen CMakeLists files: add_test,
add_executable, a conditional add_dependencies on gtest, and target_link_libraries.
The conditional branch was the trap -- it is only needed when GTest was fetched and
add_subdirectory'd, and omitting it yields undefined gtest symbols at link time --
so it was easy to leave out and impossible to notice until the link failed.

Fold all four steps into cockatrice_add_test() and migrate every test directory
onto it, with a cockatrice_add_manual_test() wrapper for the three benchmarks that
are built but deliberately kept out of ctest. The calls now read as a declaration
of what a test is rather than a transcript of how to register one.

While rewriting the registrations, replace add_test(NAME x COMMAND x) with
gtest_discover_tests(). Naming the binary as a single CTest entry meant a CI failure
reported only which binary failed, and ctest -R Suite.Case could not select an
individual case. Discovery raises the suite from 35 opaque entries to 469 real ones:
ctest -R ThemeMigrationTest.IsIdempotent now runs exactly that case, and
ctest -L server / -LE bench work off labels applied at registration.

Give every test a 60s TIMEOUT. CTest's implicit default is 1500s, so one hung
socket wait would stall CI for 25 minutes, and single_instance_manager_test already
does real QLocalSocket waits.

Dropping per-case granularity costs wall-clock time, since the suite becomes
process-spawn bound rather than CPU bound: serial ctest went from 3.8s to 30.8s. Run
it with -j in CI to recover it -- 7.6s at -j4, and 3.5s at -j8, both at or below
the old serial time. The binaries are parallel-safe: the only test claiming a fixed
resource scopes its local socket name to its own pid, so concurrent copies cannot
collide.

Also delete dummy_test, which asserted 1 == 1 and only proved the harness was
wired up -- something the other 468 cases already establish.

No test is added, removed, or changed by this commit; the 469 cases are the same
ones the 35 binaries contained.

* [Tests] Make QtTest available to test targets

QtTest was not among the modules test targets could link, so the Qt model and
signal contracts had no way to be verified from a test: QAbstractItemModelTester
and QSignalSpy both live there.

Add Test to _TEST_NEEDED in cmake/FindQtRuntime.cmake. That is the single place the
union of test-only Qt modules is declared, so this is the only file that needs to
change to make the module reachable from every test target.

Nothing uses it yet. This lands the capability ahead of the model-layer tests that
need it, so those can be added as tests alone rather than as test plus build-system
plumbing.

* [Tests] Correct the note on the oracle test compression defines

add_definitions() populates the directory's COMPILE_DEFINITIONS property, which is
consumed at generate time, so it reaches every target in the directory no matter
where it is called relative to add_executable(). The previous note claimed the
flags stayed confined to the benchmark because the find_package block came after
it; oracle_importer_test, created above that block, carries both defines too.

Verified harmless rather than a live bug: HAS_ZLIB and HAS_LZMA gate code only in
oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, and no test target compiles
either. The ordering and the leaked defines are both pre-existing, so this only
corrects the explanation.

* [Tests] Carry one CTest label per test instead of several

gtest_discover_tests() can hand CTest exactly one label. GoogleTestAddTests.cmake
accumulates its generated script with `foreach(arg ${ARGN})`, so a label list reaches
the CTestTestfile as bare `LABELS ci server`, and CTest reads only the first value of a
property and drops the rest without a word.

The `ci` label that is appended to every registered test was therefore absent from every
test that also asked for `server` or `bench`: `ctest -L ci` reported 400 of 469 cases,
and `ctest --print-labels` never listed a second label anywhere in the build.

Reduce each test to exactly one of `ci`, `server`, or `bench`, which is all the
selections need. The three now partition the suite exactly, 400 + 64 + 5 = 469, and
`ctest -LE bench` still means everything but benchmarks. A second label is now a
configure-time FATAL_ERROR rather than a line that quietly means something else.

Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it
composes with the group instead of competing for the single slot.

Also raise DISCOVERY_TIMEOUT off GoogleTestAddTests' 5s default. A plain debug binary
answers --gtest_list_tests in 0.04s, but that budget is thin once several binaries
discover concurrently behind a -j build, and being over it fails at the *link* step with
a bare "Process terminated due to timeout" that points nowhere near the cause.

* [Tests] Link gtest_main on the fetched-gtest path

The downloaded-gtest branch set GTEST_BOTH_LIBRARIES to `gtest` alone, while
FindGTest sets it to both halves. Every test target links that list rather than
picking an entry point, so on this path nothing provides main() and any test
source that does not define its own fails at link with `undefined reference to
main` pointing at crt1.o, naming neither gtest nor the line responsible.

That stayed latent while every test carried its own four-line main(). A test that
omits it now fails, and the error points at the C runtime rather than at gtest,
which is a long way from the cause. Restoring both halves makes the two paths
agree and drops the boilerplate requirement on new test sources.

The 18 sources that still define main() keep linking: gtest_main.cc defines
nothing but main, so the linker leaves its object in the archive unused rather
than reporting a duplicate.

* [Build] Run the test suite under ASan and UBSan (#7400)

* [Build] Add an ENABLE_SANITIZERS option for ASan and UBSan

Adds -DENABLE_SANITIZERS, off by default, building with AddressSanitizer and
UndefinedBehaviorSanitizer. The bulk of the work is in the three ways the obvious
implementation silently does the wrong thing.

Probe the flags on the link step, not just the compile step. check_cxx_compiler_flag()
only compiles, so every sanitizer flag comes back unsupported, because linking an ASan
object without -fsanitize=address leaves __asan_init undefined. The result is not a
build failure but a build that appears instrumented and is not, which is worse. Use
check_cxx_source_compiles() with CMAKE_REQUIRED_LINK_OPTIONS instead, and treat an
unsupported sanitizer pair as fatal since the cosmetic flags are allowed to degrade.

Opt vendored sources out per source, through a cockatrice_disable_sanitizers_for()
helper rather than a list of set_source_files_properties() calls at the root. The
helper has to exist because CMake resolves a source-file property against the directory
scope its target was declared in: setting the property from the root with absolute paths
is a no-op, silently, which is why the call sites sit next to the add_library and
add_executable that pull the vendored code in. SFMT, minizip, the LZMA SDK and Qxt SMTP
are excluded. oracle/src/qt-json is in the history but not in the tree, removed in
#7214.

Also mark those sources SKIP_PRECOMPILE_HEADERS. The PCH is built with the sanitizer
flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with
-fno-sanitize=all does not and GCC then refuses the PCH, which is a fatal
-Werror=invalid-pch in the servatrice and oracle builds.

-fno-sanitize=vptr is not optional. Qt constructs QObject subclasses into raw storage
and writes the vptr afterwards, so the vptr check reports on essentially every QObject
instantiation, and it has no value here because nothing outside moc-generated code
inherits QObject.

Verified on the resulting build: our own translation units carry ASan and UBSan calls,
SFMT.o carries neither, and the full suite of 464 cases passes clean under
ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 with UBSAN_OPTIONS=halt_on_error=0.

The test suite needs two adjustments to make this option usable at all, both of which
land here because they are consequences of COCKATRICE_SANITIZERS existing rather than
independent changes.

DISCOVERY_TIMEOUT rises from 60s to 300s under sanitizers. An instrumented binary takes
2.8s to answer --gtest_list_tests against 0.04s uninstrumented, and discovery runs
several binaries at once behind a -j build.

One target is excluded via a NO_SANITIZE flag, server_developer_role_test. Reaching
google::protobuf::TextFormat::Printer's destructor aborts on an absl assertion inside
system protobuf 33.4 whenever the consumer translation unit is built without NDEBUG,
which a Debug sanitizer build is. This reproduces with no Cockatrice code at all, just
"TextFormat::Printer p; p.PrintToString(msg, &out);" built with -fsanitize=address, so it
is upstream rather than ours. It is a flag and not a fourth label because "skip this in
the sanitizer leg" has to compose with a test's group rather than compete for the single
label slot, and because a flag excludes by not registering the cases at all, which
cannot rot the way a label silently can. The target still builds and still runs on the
legs without sanitizers.

* [CI] Run the suite once under ASan and UBSan

Adds --sanitize to .ci/compile.sh, mirroring --test, and turns it on for one entry of
the existing Linux Docker matrix: Ubuntu 26.04, which already had a test-enabled leg.
That reuses the image, the ccache layer and the existing step shape, so the CI diff is a
matrix key plus the argument plumbing. Debian 13 and Fedora 44 keep covering the
uninstrumented suite, so nothing is lost by instrumenting one leg.

Sanitizers stay off the release and package legs, which build with --release for
packaging: instrumenting those would change the shipped binaries' optimization profile
and build time for no benefit. macOS and Windows stay uninstrumented too, since ASan is
unreliable against the Qt build there and MSVC has no equivalent without /fsanitize=address.

--sanitize does not imply --test, so an instrumented build can be produced locally
without paying for ctest. When both are given, ctest runs with detect_leaks=0 because Qt
and the sanitizer runtimes both leak at exit by design and LeakSanitizer's default buries
real findings under hundreds of process-teardown reports, and with UBSan halt_on_error=0 so
one run collects many findings instead of stopping at the first. ASan keeps
abort_on_error=1, since a bad access leaves the process unusable and continuing only
produces a second, more confusing report from the wreckage.

* [Build] Fix sanitizer flag leak into release package and make UBSan fail CI

- compile.sh always passes -DENABLE_SANITIZERS explicitly so the
  release-package reconfigure in the same build dir cannot inherit ON
  from the sanitized debug step (which shipped libasan/libubsan DEB deps).
- Sanitizers.cmake resets COCKATRICE_SANITIZERS to OFF when the option is
  off (CACHE INTERNAL otherwise sticks forever, keeping NO_SANITIZE skips
  and the 300s discovery timeout).
- UBSan now fails the job: -fno-sanitize-recover=undefined at compile time
  plus UBSAN_OPTIONS halt_on_error=1 (recover mode exits 0, so the old
  halt_on_error=0 could never fail).

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 14:50:09 +02:00
71 changed files with 2344 additions and 1000 deletions

View file

@ -9,6 +9,7 @@ RUN apt-get update && \
cmake \
file \
g++ \
gcovr \
git \
libgl-dev \
liblzma-dev \

View file

@ -8,14 +8,17 @@
# --suffix <suffix> renames package with this suffix, requires arg
# --server compiles servatrice
# --test runs tests
# --sanitize builds with AddressSanitizer and UndefinedBehaviorSanitizer, gcc/clang only
# --coverage instruments the test run for line coverage and prints a gcovr summary, gcc only
# --debug or --release sets the build type ie CMAKE_BUILD_TYPE
# --ccache [<size>] uses ccache and shows stats, optionally provide size
# --evict-ccache <age> runs ccache eviction based on given age after build
# --dir <dir> sets the name of the build dir, default is "build"
# --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake
# --target-macos-version <version> sets the min os version - only used for macOS builds
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --ccache <ccache_size> --dir <dir>)
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir>)
# exitcode: 1 for failure, 3 for invalid arguments
# Read arguments
@ -57,6 +60,14 @@ while [[ $# != 0 ]]; do
MAKE_TEST=1
shift
;;
'--sanitize')
MAKE_SANITIZE=1
shift
;;
'--coverage')
MAKE_COVERAGE=1
shift
;;
'--debug')
BUILDTYPE="Debug"
shift
@ -131,6 +142,15 @@ if [[ ! $BUILD_DIR ]]; then
BUILD_DIR="build"
fi
mkdir -p "$BUILD_DIR"
if [[ $MAKE_COVERAGE ]]; then
# Resolved while the working directory is still the source root. $0 is relative to the
# directory the script was invoked from, so after the cd below it no longer resolves, and
# under `set -e` the failed subshell would abort the build rather than just yielding an empty
# variable. Only the coverage report needs this, so only coverage pays for it.
SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
fi
cd "$BUILD_DIR"
# Set minimum CMake Version
@ -147,6 +167,17 @@ fi
if [[ $MAKE_TEST ]]; then
flags+=("-DTEST=1")
fi
if [[ $MAKE_SANITIZE ]]; then
flags+=("-DENABLE_SANITIZERS=ON")
else
# The release-package step re-runs cmake in the same build dir, so an
# ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak
# into the shipped binaries (libasan/libubsan Depends in the DEB).
flags+=("-DENABLE_SANITIZERS=OFF")
fi
if [[ $MAKE_COVERAGE ]]; then
flags+=("-DENABLE_COVERAGE=ON")
fi
if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1")
# PCH-aware caching is required or ccache refuses to cache any TU that
@ -306,7 +337,50 @@ fi
if [[ $MAKE_TEST ]]; then
echo "::group::Run tests"
ctest -C "$BUILDTYPE" --output-on-failure
if [[ $MAKE_SANITIZE ]]; then
# detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and
# LeakSanitizer's default is aggressive enough to bury our real findings under hundreds
# of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for
# and they stay fully on.
# abort_on_error=1: a bad access leaves the process in an unusable state, so continuing
# only produces a second, more confusing report from the wreckage.
# UBSAN halt_on_error=1: without it UBSan prints "runtime error: ..." but the
# process exits 0 (recover mode), so gtest/ctest report a pass and the job
# stays green. Combined with -fno-sanitize-recover=undefined at compile time,
# the first finding aborts the test process with a non-zero exit.
export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:print_stacktrace=1:symbolize=1"
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1:symbolize=1"
fi
# Every TEST() is its own CTest entry, so the suite is process-spawn bound rather than CPU
# bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
echo "::endgroup::"
fi
if [[ $MAKE_COVERAGE ]]; then
# gcovr is not a build dependency and is not available everywhere, so a missing one is reported
# and skipped rather than failing an otherwise green build.
echo "::group::Coverage report"
GCOVR="$(command -v gcovr || true)"
if [[ -z $GCOVR ]]; then
echo "gcovr not found; install it to produce a coverage report"
else
"$GCOVR" --version
# This script configures with `cmake ..`, so the build dir it was started from is the object
# directory and the source root is one level up. Both are passed explicitly: gcovr otherwise
# walks the current directory and trips over in-tree build directories left by other tooling,
# whose stale .gcno files cannot be resolved. --config is named too, so the filters apply no
# matter what directory gcovr happens to start from.
GCOVR_ARGS=("." --root "$SRC_ROOT" --object-directory "." --config "$SRC_ROOT/gcovr.cfg")
"$GCOVR" "${GCOVR_ARGS[@]}" --txt --decisions
"$GCOVR" "${GCOVR_ARGS[@]}" --html-details coverage-details.html --html-title "Cockatrice line coverage"
"$GCOVR" "${GCOVR_ARGS[@]}" --lcov -o coverage.lcov
"$GCOVR" "${GCOVR_ARGS[@]}" --cobertura-pretty -o coverage.xml
"$GCOVR" "${GCOVR_ARGS[@]}" --json-summary-pretty -o coverage-summary.json
echo "Coverage artifacts written to coverage-details.html, coverage.lcov, coverage.xml and coverage-summary.json"
fi
echo "::endgroup::"
fi

View file

@ -13,11 +13,17 @@
# --set-cache <location> sets the location to cache the image or for ccache
#
# requires: docker
# uses env: NAME CACHE BUILD GET SAVE INTERACTIVE
# uses env: NAME IMAGE_SUFFIX CACHE BUILD GET SAVE INTERACTIVE
# (correspond to args: <name> --set-cache <cache> --build --get --save --interactive)
# sets env: RUN CCACHE_DIR IMAGE_NAME RUN_ARGS RUN_OPTS BUILD_SCRIPT
# exitcode: 1 for failure, 2 for missing dockerfile, 3 for invalid arguments
#
# IMAGE_SUFFIX is optional and only appended to the image name, never to NAME, so that a matrix
# leg can have its own image and image cache without needing its own directory under .ci. Two legs
# building and tagging the same image concurrently would race on `docker build --tag`.
# It is deliberately not called SUFFIX: compile.sh already uses SUFFIX for output file names, and
# a job-level SUFFIX would be silently overridden by those per-step env blocks.
#
# exported RUN function will run the BUILD_SCRIPT inside of the docker container.
# note that the docker container will not inherit any environment variables!
#
@ -81,7 +87,7 @@ if ! [[ $NAME ]]; then
return 3
fi
export IMAGE_NAME="${project_name,,}_${NAME,,}" # lower case
export IMAGE_NAME="${project_name,,}_${NAME,,}${IMAGE_SUFFIX:+_${IMAGE_SUFFIX,,}}" # lower case
docker_dir=".ci/$NAME"
if ! [[ -r $docker_dir/Dockerfile ]]; then

View file

@ -144,17 +144,36 @@ jobs:
package: DEB
name: ${{ matrix.distro }} ${{ matrix.version }}
# The one leg that runs the suite under AddressSanitizer and
# UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the
# uninstrumented suite, so nothing is lost by instrumenting this one.
sanitize: yes
- distro: Ubuntu
version: 26.04
variant: cover # separate image and ccache, same distro
# The one leg that runs instrumented for line coverage and publishes the report as
# a workflow artifact. It is a separate leg rather than a second flag on the
# sanitizer leg because the two instruments answer different questions: the
# sanitizer leg reports what the run did wrong, the coverage leg reports what the
# run never touched at all. Combining them would also slow both down. Packaging is
# skipped because the leg above already publishes the DEB for this distro.
coverage: yes
package: skip
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure
runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
timeout-minutes: 70
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
steps:
- name: "Checkout"
@ -164,7 +183,7 @@ jobs:
id: restore_ccache
uses: actions/cache/restore@v6
with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
path: ${{ env.CACHE }}
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
@ -174,6 +193,9 @@ jobs:
- name: "Build debug and test"
if: matrix.test != 'skip'
shell: bash
env:
sanitize: '${{ matrix.sanitize }}'
coverage: '${{ matrix.coverage }}'
run: |
source .ci/docker.sh
args=()
@ -181,7 +203,33 @@ jobs:
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
RUN --server --debug --test "${args[@]}"
extra=()
[[ $sanitize == yes ]] && extra+=(--sanitize)
[[ $coverage == yes ]] && extra+=(--coverage)
RUN --server --debug --test "${extra[@]}" "${args[@]}"
# RUN bind-mounts the workspace into the container, so the report the build wrote into
# build/ is already on the host and no second container is needed to collect it.
- name: "Collect coverage report"
if: matrix.coverage == 'yes'
shell: bash
run: |
mkdir -p coverage
# --html-details writes one page per source file plus a stylesheet and script, and the
# index links to all of them by relative name. Copying the index alone would publish a
# report where every link 404s, so the whole family has to travel together.
cp build/coverage-details* coverage/
cp build/coverage.lcov build/coverage.xml build/coverage-summary.json coverage/
ls -la coverage/
- name: "Upload coverage artifact"
if: matrix.coverage == 'yes'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
if-no-files-found: warn
- name: "Build release package"
id: build

View file

@ -22,6 +22,10 @@ option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile tests
option(TEST "Build tests" OFF)
# Build with AddressSanitizer and UndefinedBehaviorSanitizer
option(ENABLE_SANITIZERS "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF)
# Build instrumented for GCC line coverage, reported with gcovr
option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
@ -211,6 +215,12 @@ else()
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
endif()
# Always included, because they define cockatrice_disable_sanitizers_for() and
# cockatrice_disable_coverage_for() for the vendored trees. They only touch the global flags
# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on.
include(Sanitizers)
include(Coverage)
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_definitions("-DSFMT_MEXP=19937")

75
cmake/Coverage.cmake Normal file
View file

@ -0,0 +1,75 @@
# Line-coverage instrumentation opt-in, plus per-source opt-out
# Input: ENABLE_COVERAGE
# Defines: cockatrice_disable_coverage_for
#
# Included once from the root CMakeLists.txt, immediately after the per-compiler flag
# block, so add_compile_options() reaches every library and executable defined below.
#
# Note the name deliberately shadows CMake's own built-in Modules/Coverage.cmake. It resolves
# to this file only because the root CMakeLists.txt puts cmake/ at the front of
# CMAKE_MODULE_PATH; if that ever moves, include(Coverage) would silently pick up the built-in
# module, which only defines a `COVERAGE` custom target and touches no global flags, so
# instrumentation would quietly stop happening rather than erroring.
include(CheckCXXSourceCompiles)
# Opt a set of vendored sources out of instrumentation.
#
# cockatrice_disable_coverage_for(<source> ...)
#
# Third-party code drags the total down with lines no test can ever reach, and a total that
# cannot move is not a useful signal. This is the same carve-out the sanitizer build uses.
#
# The sources must be named relative to the directory that compiles them. CMake resolves a
# source-file property against the directory scope the target was declared in, so calling
# this from the root with absolute paths silently does nothing. That is why the call sites
# live next to the add_library/add_executable that pull the vendored code in.
#
# SKIP_PRECOMPILE_HEADERS mirrors the sanitizer helper. The precompiled header carries the
# instrumentation flags, and mixing an instrumented PCH with an uninstrumented translation
# unit makes GCC reject it.
function(cockatrice_disable_coverage_for)
if(NOT ENABLE_COVERAGE)
return()
endif()
# There is no -fno-coverage: --coverage is shorthand for -fprofile-arcs -ftest-coverage, so both
# halves have to be negated. CC rejects "-fno-coverage" outright, which is how the vendored C
# files below hit it first.
set_source_files_properties(
${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-profile-arcs;-fno-test-coverage" SKIP_PRECOMPILE_HEADERS ON
)
endfunction()
if(ENABLE_COVERAGE)
# Line coverage needs the compiler's own toolchain, so only GCC is supported. Clang's
# equivalent is llvm-cov, which reads a different intermediate format and would need a
# separate report path; rejecting it is better than emitting .gcno files that gcovr cannot
# read.
if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
message(FATAL_ERROR "ENABLE_COVERAGE requires GCC, but the compiler is ${CMAKE_CXX_COMPILER_ID}. "
"Clang coverage needs llvm-cov and is not wired up."
)
endif()
# Probe the way the flag will actually be used: compile AND link. A .gcno file comes from the
# compile step, but the runtime that writes .gcda files comes from the link step, so probing
# only the compile step would pass on a toolchain that cannot actually produce coverage.
set(_cockatrice_saved_required_flags "${CMAKE_REQUIRED_FLAGS}")
set(_cockatrice_saved_required_link_options "${CMAKE_REQUIRED_LINK_OPTIONS}")
set(CMAKE_REQUIRED_FLAGS "-O0 -g")
set(CMAKE_REQUIRED_LINK_OPTIONS -fprofile-arcs -ftest-coverage)
check_cxx_source_compiles("int main() { return 0; }" COCKATRICE_HAVE_COVERAGE_FLAGS)
set(CMAKE_REQUIRED_FLAGS "${_cockatrice_saved_required_flags}")
set(CMAKE_REQUIRED_LINK_OPTIONS "${_cockatrice_saved_required_link_options}")
if(NOT COCKATRICE_HAVE_COVERAGE_FLAGS)
message(FATAL_ERROR "ENABLE_COVERAGE is on, but this toolchain rejected the coverage flags.")
endif()
# -O0 keeps the line mapping honest: at higher optimisation levels the compiler merges and
# relocates lines, and the report describes code that no longer exists in that form.
add_compile_options(--coverage -O0 -g)
add_link_options(--coverage)
message(STATUS "Coverage instrumentation enabled (--coverage). Report with gcovr over the build dir.")
endif()

View file

@ -40,7 +40,9 @@ endif()
if(TEST)
# Union of Qt modules required across all test targets (independent of application targets).
# When adding a new test that needs additional Qt modules, add them here rather than in the test's CMakeLists.txt.
set(_TEST_NEEDED Concurrent Network Svg Widgets)
# Test is QtTest: it brings QAbstractItemModelTester and QSignalSpy, which are how the Qt model
# and signal contracts get verified without standing up a GUI.
set(_TEST_NEEDED Concurrent Network Svg Widgets Test)
endif()
set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED}

View file

@ -304,10 +304,11 @@ SetShellVarContext all
; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller
; normally copies itself to %TEMP% and runs from there so that it can delete its
; own directory, and the process we started exits at once. ExecWait then returns
; before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" executes
; while the section below is still writing the new payload. _?= disables the copy
; and makes ExecWait block until the uninstall finishes. It must be the last
; argument and must not be quoted, even when the path contains spaces.
; before the uninstall has run, and the temp copy keeps deleting files - from
; 3.1.0 and 3.1.1 on disk a blanket "RMDir /r $INSTDIR" - while the section
; below is still writing the new payload. _?= disables the copy and makes
; ExecWait block until the uninstall finishes. It must be the last argument
; and must not be quoted, even when the path contains spaces.
;
; --- 32-bit uninstall ---
SetRegView 32
@ -1107,9 +1108,14 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
; upgrade portable mode
!insertmacro CockLog "section: portable upgrade purge"
!insertmacro CockLogListing "before-portable-purge" "*.dll"
; plugins and translations are payload and are rewritten by the payload
; below, so removing them here is safe. sounds and themes are payload
; too - the default packs are rewritten below as well - but a portable
; user's custom sounds and themes live in exactly those folders, so a
; recursive purge here would delete user content along with the defaults.
; Stale default files from an older release are overwritten or replaced
; by the payload, never by a purge.
RMDir /r "$INSTDIR\plugins"
RMDir /r "$INSTDIR\sounds"
RMDir /r "$INSTDIR\themes"
RMDir /r "$INSTDIR\translations"
Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\cockatrice.exe"
@ -1263,16 +1269,32 @@ Section "un.Application" UnSecApplication
SetShellVarContext all
StrCpy $R1 0
; Record whether this was a portable install before the directory (and the
; portable.dat marker inside it) is removed below.
; Record whether this was a portable install before portable.dat is
; removed below.
IfFileExists "$INSTDIR\portable.dat" 0 +2
StrCpy $R1 1
; Remove the entire application directory so any file that is not part of
; the installed payload (e.g. build-tree artifacts such as *.dir folders,
; *_autogen and *.tlog files from a build) cannot survive between an
; uninstall and a fresh reinstall.
RMDir /r "$INSTDIR"
; Remove only what the installer wrote. The two lists below are generated
; by CPack from the staged install tree: one Delete per installed file and
; one non-recursive RMDir per installed directory, children first.
; Anything the user put in $INSTDIR - decks, pictures, portable data
; folders - is not on those lists and survives, and $INSTDIR itself is
; only removed when nothing else is left in it. An earlier version of this
; section ran a blanket "RMDir /r $INSTDIR" to keep build-tree artifacts
; out of a fresh reinstall; that also deleted user decks whenever a user
; had pointed their data folders into the install directory, so payload
; purity may never be bought at that price again.
@CPACK_NSIS_DELETE_FILES@
@CPACK_NSIS_DELETE_DIRECTORIES@
; Written by the installer itself, so absent from the staged payload
; above; qdebug.txt is app-generated log output next to the executables.
Delete "$INSTDIR\uninstall.exe"
Delete "$INSTDIR\portable.dat"
Delete "$INSTDIR\qdebug.txt"
; Succeeds only when no foreign files remain.
RMDir "$INSTDIR"
RMDir /r "$SMPROGRAMS\Cockatrice"

109
cmake/Sanitizers.cmake Normal file
View file

@ -0,0 +1,109 @@
# AddressSanitizer + UndefinedBehaviorSanitizer opt-in, plus per-source opt-out
# Input: ENABLE_SANITIZERS
# Defines: cockatrice_disable_sanitizers_for
#
# Included once from the root CMakeLists.txt, immediately after the per-compiler flag
# block, so add_compile_options() reaches every library and executable defined below.
include(CheckCXXSourceCompiles)
# Opt a set of vendored sources out of instrumentation.
#
# cockatrice_disable_sanitizers_for(<source> ...)
#
# Sanitizer findings inside vendored code are not actionable for us, and letting them
# through buries our own bugs in noise until everyone learns to skip the job.
#
# The sources must be named relative to the directory that compiles them. CMake resolves a
# source-file property against the directory scope the target was declared in, so calling
# this from the root with absolute paths silently does nothing. That is why the call sites
# live next to the add_library/add_executable that pull the vendored code in.
#
# SKIP_PRECOMPILE_HEADERS is not optional. The precompiled header is built with the
# sanitizer flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with
# -fno-sanitize=all does not, and GCC then refuses the PCH with
# "not used because __SANITIZE_ADDRESS__ not defined", which is a fatal -Werror=invalid-pch
# in the servatrice and oracle builds.
function(cockatrice_disable_sanitizers_for)
if(NOT ENABLE_SANITIZERS)
return()
endif()
set_source_files_properties(${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-sanitize=all" SKIP_PRECOMPILE_HEADERS ON)
endfunction()
if(ENABLE_SANITIZERS)
# Probe a flag the way it will actually be used: on the compile step AND the link step.
# check_cxx_compiler_flag() only probes the compile step, which makes every sanitizer
# flag look unsupported because linking an ASan object without -fsanitize=address leaves
# __asan_init undefined. That is a silent misconfiguration rather than an error, so the
# probe has to link.
function(cockatrice_probe_flag FLAG VARNAME)
set(CMAKE_REQUIRED_FLAGS "${FLAG}")
set(CMAKE_REQUIRED_LINK_OPTIONS "${FLAG}")
check_cxx_source_compiles("int main() { return 0; }" ${VARNAME})
unset(CMAKE_REQUIRED_FLAGS)
unset(CMAKE_REQUIRED_LINK_OPTIONS)
endfunction()
set(SANITIZER_FLAGS
-fsanitize=address,undefined
-fno-omit-frame-pointer
# UBSan defaults to recover mode: it prints "runtime error: ..." but the
# process exits 0, so gtest/ctest report a pass and CI stays green.
# -fno-sanitize-recover makes the first finding abort instead. This is
# probed like the other cosmetic flags, but CI also sets
# UBSAN_OPTIONS=halt_on_error=1 as the runtime-side guarantee.
-fno-sanitize-recover=undefined
# Qt builds QObject subclasses into raw storage and writes the vptr afterwards, which
# the vptr check reports on essentially every QObject instantiation. The check has no
# value for us because nothing outside moc-generated code inherits QObject, so it is
# off outright rather than suppressed piecemeal.
-fno-sanitize=vptr
)
# MSVC has no ASan/UBSan equivalent, and clang-cl would need /fsanitize=address plus a
# matching runtime link, which is a separate piece of work.
if(MSVC)
message(FATAL_ERROR "ENABLE_SANITIZERS is not supported with MSVC")
endif()
# Probe rather than assume, so an older toolchain gets the sanitizers it can support
# instead of failing the build on a flag it does not recognize.
set(SANITIZER_SUPPORTED_FLAGS)
foreach(FLAG ${SANITIZER_FLAGS})
string(MAKE_C_IDENTIFIER "COCKATRICE_HAS${FLAG}" _FLAG_VAR)
cockatrice_probe_flag("${FLAG}" ${_FLAG_VAR})
if(${_FLAG_VAR})
list(APPEND SANITIZER_SUPPORTED_FLAGS ${FLAG})
else()
message(STATUS "Sanitizers: ${FLAG} unsupported by ${CMAKE_CXX_COMPILER_ID}, skipping")
endif()
endforeach()
# The whole point of the option is the sanitizers, so an unsupported pair is fatal even
# though the cosmetic flags above are allowed to degrade.
if(NOT "-fsanitize=address,undefined" IN_LIST SANITIZER_SUPPORTED_FLAGS)
message(FATAL_ERROR "ENABLE_SANITIZERS requires -fsanitize=address,undefined, which "
"${CMAKE_CXX_COMPILER_ID} does not accept"
)
endif()
add_compile_options(${SANITIZER_SUPPORTED_FLAGS})
add_link_options(-fsanitize=address,undefined)
# Recorded so the test tree can label its entries and a plain `make` at the same time
# cannot quietly disagree with what CI ran.
set(COCKATRICE_SANITIZERS
ON
CACHE INTERNAL "AddressSanitizer and UBSan are enabled"
)
else()
# ENABLE_SANITIZERS is OFF, but COCKATRICE_SANITIZERS is CACHE INTERNAL, so a
# reused build dir (e.g. CI's release-package step reconfiguring the same dir
# after the sanitized debug step) would otherwise keep skipping NO_SANITIZE
# tests and keep the 300s discovery timeout. Reset it explicitly.
set(COCKATRICE_SANITIZERS
OFF
CACHE INTERNAL "AddressSanitizer and UBSan are enabled" FORCE
)
endif()

View file

@ -3,22 +3,53 @@
*
* Safe for every target that links Qt Core, including the headless
* Servatrice binary. Keep this header free of any widget/gui types.
*
* Candidates are chosen from IWYU include statistics: a header earns a
* spot when it is not already reachable from the rest of this list and
* enough translation units re-parse it. Qt headers are stable across
* builds, so the precompiled header rarely invalidates.
*/
#include <QBasicTimer>
#include <QByteArray>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QItemSelectionModel>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QList>
#include <QLoggingCategory>
#include <QMap>
#include <QMetaEnum>
#include <QMetaObject>
#include <QMutex>
#include <QMutexLocker>
#include <QObject>
#include <QPointer>
#include <QPropertyAnimation>
#include <QRandomGenerator>
#include <QReadWriteLock>
#include <QRegularExpression>
#include <QSet>
#include <QSettings>
#include <QSharedPointer>
#include <QSortFilterProxyModel>
#include <QString>
#include <QStringList>
#include <QTemporaryDir>
#include <QThread>
#include <QTimer>
#include <QUuid>
#include <QVariant>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>

View file

@ -4,27 +4,56 @@
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
* Network layers that virtually every client translation unit re-parses.
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
*
* Candidates are chosen from IWYU include statistics: a header earns a
* spot when it is not already reachable from the rest of this list and
* enough translation units re-parse it. Qt headers are stable across
* builds, so the precompiled header rarely invalidates.
*/
#include "qtcore_pch.h"
#include <QAction>
#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QCompleter>
#include <QDialogButtonBox>
#include <QDockWidget>
#include <QFileDialog>
#include <QFormLayout>
#include <QFrame>
#include <QGraphicsItem>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsView>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QImage>
#include <QImageReader>
#include <QInputDialog>
#include <QLabel>
#include <QLayout>
#include <QListWidget>
#include <QMainWindow>
#include <QMenu>
#include <QMessageBox>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPainter>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
#include <QSplitter>
#include <QStandardItemModel>
#include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QToolBar>
#include <QToolButton>
#include <QTreeWidget>
#include <QVBoxLayout>
#include <QWidget>

View file

@ -59,6 +59,8 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_update.cpp
src/interface/widgets/dialogs/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp
src/interface/widgets/dialogs/path_safety_warning.cpp
src/interface/widgets/dialogs/settings_view.cpp
src/interface/widgets/dialogs/tip_of_the_day.cpp
src/interface/widgets/deck_share/deck_share_utils.cpp
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp

View file

@ -277,8 +277,6 @@ void GameEventHandler::eventSpectatorLeave(const Event_Leave &event,
emit spectatorLeft(eventPlayerId);
game->getPlayerManager()->removeSpectator(eventPlayerId);
emitUserEvent();
}
void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event,
@ -465,9 +463,9 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/,
PlayerLogic *newPlayer = game->getPlayerManager()->addPlayer(playerId, playerInfo.user_info());
emit logJoinPlayer(newPlayer);
emit playerJoined(playerInfo);
}
emitUserEvent();
emitUserEvent();
}
}
QString GameEventHandler::getLeaveReason(Event_Leave::LeaveReason reason)

View file

@ -1,205 +1,41 @@
/**
* @file dlg_settings.cpp
* @brief Implementation of the main settings dialog
* @brief Implementation of the main settings dialog (thin frame around SettingsView)
* @ingroup Dialogs
*/
#include "dlg_settings.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../main.h"
#include "../settings_page/abstract_settings_page.h"
#include "../settings_page/appearance_settings_page.h"
#include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_settings_page.h"
#include "../settings_page/settings_search_delegate.h"
#include "../settings_page/settings_search_model.h"
#include "../settings_page/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h"
#include "../settings_page/user_interface_settings_page.h"
#include "libcockatrice/card/database/card_database.h"
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QAbstractAnimation>
#include <QAbstractItemView>
#include <QApplication>
#include <QBrush>
#include <QByteArray>
#include <QCloseEvent>
#include <QColor>
#include <QDebug>
#include <QDir>
#include <QEasingCurve>
#include <QEvent>
#include <QFlags>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QIcon>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLineEdit>
#include <QListView>
#include <QMessageBox>
#include <QMessageLogger>
#include <QModelIndex>
#include <QObject>
#include <QPalette>
#include <QPixmap>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QRect>
#include <QScreen>
#include <QScrollArea>
#include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QShortcut>
#include <QSize>
#include <QSizePolicy>
#include <QStackedLayout>
#include <QStackedWidget>
#include <QStringLiteral>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
#include <QtGlobal>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <qnamespace.h>
class QModelIndex;
/**
* @brief Wraps a widget in a scroll area for long settings pages
* @param widget The widget to wrap
* @return The scroll area containing the widget
*/
static QScrollArea *makeScrollable(QWidget *widget)
{
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
auto *scrollArea = new QScrollArea;
scrollArea->setWidgetResizable(true);
scrollArea->setContentsMargins(0, 0, 0, 0);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->horizontalScrollBar()->setEnabled(false);
scrollArea->setWidget(widget);
return scrollArea;
}
/**
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
*/
static QStringList pageIconResources()
{
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
}
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
{
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi();
searchEdit->setFocus();
adjustSize();
}
void DlgSettings::setupUi()
{
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);
// Tab bar (built in setupTabBar)
setupTabBar();
// Pages stacked widget
pagesWidget = new QStackedWidget;
auto *generalPage = new GeneralSettingsPage;
auto *appearancePage = new AppearanceSettingsPage;
auto *userInterfacePage = new UserInterfaceSettingsPage;
auto *deckEditorPage = new DeckEditorSettingsPage;
auto *storagePage = new StorageSettingsPage;
auto *messagesPage = new MessagesSettingsPage;
auto *soundPage = new SoundSettingsPage;
auto *shortcutsPage = new ShortcutSettingsPage;
pages.append(generalPage);
pages.append(appearancePage);
pages.append(userInterfacePage);
pages.append(deckEditorPage);
pages.append(storagePage);
pages.append(messagesPage);
pages.append(soundPage);
pages.append(shortcutsPage);
pagesWidget->addWidget(makeScrollable(generalPage));
pagesWidget->addWidget(makeScrollable(appearancePage));
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
pagesWidget->addWidget(makeScrollable(messagesPage));
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);
Q_ASSERT(pages.size() == NumPages);
// Search results view (hidden by default)
searchResultsView = new QListView;
searchResultsView->setUniformItemSizes(false);
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
searchResultsView->setVisible(false);
searchResultsView->setStyleSheet(
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
searchModel = new SettingsSearchModel(this);
searchDelegate = new SettingsSearchDelegate(this);
searchResultsView->setModel(searchModel);
searchResultsView->setItemDelegate(searchDelegate);
connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
const QStringList icons = pageIconResources();
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
tabButtons[i]->setIcon(QPixmap(icons[i]));
}
searchDelegate->setPageIcons(icons);
searchResultsView->viewport()->update();
});
// Build search index after pages are created
buildSearchIndex();
// Pages container (stacked widget + search results overlay)
pagesContainer = new QWidget;
auto *containerLayout = new QStackedLayout;
containerLayout->setStackingMode(QStackedLayout::StackAll);
containerLayout->addWidget(pagesWidget);
containerLayout->addWidget(searchResultsView);
pagesContainer->setLayout(containerLayout);
settingsView = new SettingsView(this);
// Bottom buttons
auto *buttonBox = new QHBoxLayout;
@ -211,241 +47,25 @@ void DlgSettings::setupUi()
// Main layout
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(searchLayout);
mainLayout->addWidget(tabBarWidget);
auto *separator = new QFrame;
separator->setFrameShape(QFrame::HLine);
separator->setFrameShadow(QFrame::Sunken);
mainLayout->addWidget(separator);
mainLayout->addWidget(pagesContainer);
mainLayout->addWidget(settingsView);
mainLayout->addSpacing(4);
mainLayout->addLayout(buttonBox);
setLayout(mainLayout);
// Keyboard shortcuts
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi();
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
int next = (currentTabIndex + 1) % tabButtons.size();
setActiveTab(next);
});
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
setActiveTab(prev);
});
// Initialize to first tab
setActiveTab(0);
}
void DlgSettings::setupTabBar()
{
tabBarWidget = new QWidget;
auto *tabLayout = new QHBoxLayout;
tabLayout->setContentsMargins(0, 0, 0, 0);
tabLayout->setSpacing(2);
const QStringList iconResources = pageIconResources();
for (int i = 0; i < iconResources.size(); ++i) {
auto *tabButton = new QToolButton;
tabButton->setCheckable(true);
tabButton->setIcon(QPixmap(iconResources[i]));
tabButton->setIconSize(QSize(48, 48));
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
tabButton->setAutoExclusive(true);
tabButton->setMinimumHeight(85);
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
tabButtons.append(tabButton);
tabLayout->addWidget(tabButton);
}
tabBarWidget->setLayout(tabLayout);
}
void DlgSettings::buildSearchIndex()
{
QList<SettingsSearchEntry> allEntries;
const QStringList pageNames = translatedPageNames();
searchDelegate->setPageNames(pageNames);
searchDelegate->setPageIcons(pageIconResources());
for (int i = 0; i < pages.size(); ++i) {
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
for (auto &entry : pageEntries) {
if (entry.pageIndex == -1) {
entry.pageIndex = i;
}
}
allEntries.append(pageEntries);
}
searchModel->setSourceEntries(allEntries);
}
void DlgSettings::onTabClicked(int index)
{
if (searchActive) {
switchToTabMode();
}
setActiveTab(index);
}
void DlgSettings::setActiveTab(int index)
{
if (index < 0 || index >= tabButtons.size()) {
return;
}
currentTabIndex = index;
pagesWidget->setCurrentIndex(index);
for (int i = 0; i < tabButtons.size(); ++i) {
tabButtons[i]->setChecked(i == index);
}
// Style active tab with a thick accent border + subtle background tint
for (int i = 0; i < tabButtons.size(); ++i) {
if (i == index) {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: palette(window); padding-bottom: 1px; }");
} else {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: transparent; }");
}
}
}
void DlgSettings::flashWidget(QWidget *widget)
{
auto *overlay = new QWidget(widget);
overlay->setGeometry(widget->rect());
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
QPalette pal = overlay->palette();
QColor flashColor = pal.color(QPalette::Highlight);
flashColor.setAlpha(100);
pal.setBrush(QPalette::Window, flashColor);
overlay->setPalette(pal);
overlay->setAutoFillBackground(true);
auto *effect = new QGraphicsOpacityEffect(overlay);
effect->setOpacity(0.0);
overlay->setGraphicsEffect(effect);
overlay->show();
overlay->raise();
auto *flashIn = new QPropertyAnimation(effect, "opacity");
flashIn->setDuration(120);
flashIn->setStartValue(0.0);
flashIn->setEndValue(0.6);
flashIn->setEasingCurve(QEasingCurve::OutCubic);
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
fadeOut->setDuration(900);
fadeOut->setStartValue(0.6);
fadeOut->setEndValue(0.0);
fadeOut->setEasingCurve(QEasingCurve::InCubic);
auto *group = new QSequentialAnimationGroup(overlay);
group->addAnimation(flashIn);
group->addAnimation(fadeOut);
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
group->start(QAbstractAnimation::DeleteWhenStopped);
}
void DlgSettings::onSearchTextChanged(const QString &text)
{
searchModel->setFilterString(text);
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
if (!searchActive) {
switchToSearchMode();
}
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
} else if (searchActive) {
switchToTabMode();
}
}
void DlgSettings::switchToSearchMode()
{
searchActive = true;
tabBarWidget->setVisible(false);
pagesWidget->setVisible(false);
searchResultsView->setVisible(true);
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
}
void DlgSettings::switchToTabMode()
{
searchActive = false;
tabBarWidget->setVisible(true);
pagesWidget->setVisible(true);
searchResultsView->setVisible(false);
searchEdit->blockSignals(true);
searchEdit->clear();
searchEdit->blockSignals(false);
setActiveTab(currentTabIndex);
}
void DlgSettings::onSearchResultClicked(const QModelIndex &index)
{
navigateToSearchResult(index);
}
void DlgSettings::navigateToSearchResult(const QModelIndex &index)
{
SettingsSearchEntry entry = searchModel->entryForIndex(index);
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
return;
}
// Switch to the page
switchToTabMode();
setActiveTab(entry.pageIndex);
// Scroll to the widget, focus it, and flash to highlight it
if (entry.widget) {
QWidget *widget = entry.widget;
while (widget) {
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
scrollArea->ensureWidgetVisible(entry.widget);
break;
}
widget = widget->parentWidget();
}
entry.widget->setFocus();
flashWidget(entry.widget);
}
adjustSize();
}
void DlgSettings::setTab(int index)
{
if (index >= 0 && index < tabButtons.size()) {
setActiveTab(index);
}
settingsView->setTab(index);
}
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
{
return pages.value(static_cast<int>(which));
return settingsView->page(which);
}
void DlgSettings::updateLanguage()
@ -454,49 +74,6 @@ void DlgSettings::updateLanguage()
installNewTranslator();
}
bool DlgSettings::eventFilter(QObject *watched, QEvent *event)
{
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Escape) {
if (searchActive) {
switchToTabMode();
return true;
}
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
if (searchActive) {
if (searchResultsView->currentIndex().isValid()) {
navigateToSearchResult(searchResultsView->currentIndex());
}
return true;
}
} else if (keyEvent->key() == Qt::Key_Down) {
if (searchActive) {
int nextRow = searchResultsView->currentIndex().row() + 1;
if (nextRow >= searchModel->rowCount()) {
nextRow = 0;
}
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
searchResultsView->scrollTo(searchModel->index(nextRow));
return true;
}
} else if (keyEvent->key() == Qt::Key_Up) {
if (searchActive) {
int prevRow = searchResultsView->currentIndex().row() - 1;
if (prevRow < 0) {
prevRow = searchModel->rowCount() - 1;
}
if (prevRow >= 0) {
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
searchResultsView->scrollTo(searchModel->index(prevRow));
}
return true;
}
}
}
return QDialog::eventFilter(watched, event);
}
void DlgSettings::closeEvent(QCloseEvent *event)
{
bool showLoadError = true;
@ -576,26 +153,5 @@ void DlgSettings::closeEvent(QCloseEvent *event)
void DlgSettings::retranslateUi()
{
setWindowTitle(tr("Settings"));
retranslateTabNames();
searchEdit->setPlaceholderText(tr("Search settings..."));
okButton->setText(tr("OK"));
// Rebuild search index for translated text
buildSearchIndex();
}
QStringList DlgSettings::translatedPageNames()
{
return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"),
tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")};
}
void DlgSettings::retranslateTabNames()
{
const QStringList tabLabels = translatedPageNames();
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
tabButtons[i]->setText(tabLabels[i]);
}
}

View file

@ -1,105 +1,59 @@
/**
* @file dlg_settings.h
* @brief Main settings dialog for the Cockatrice client
* @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView)
* @ingroup Dialogs
*/
#ifndef DLG_SETTINGS_H
#define DLG_SETTINGS_H
#include "settings_view.h"
#include <QDialog>
#include <QList>
#include <QLoggingCategory>
#include <QString>
#include <QStringList>
#include <qtmetamacros.h>
class QPushButton;
class QModelIndex;
class QWidget;
class AbstractSettingsPage;
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
class QStackedWidget;
class QToolButton;
class QListView;
class QLineEdit;
class AbstractSettingsPage;
class SettingsSearchModel;
class SettingsSearchDelegate;
/**
* @brief Main application settings dialog with tabbed navigation and search
* @brief Main application settings dialog hosting the shared SettingsView
*
* Provides a modern settings interface organized into tabbed pages. Users can
* either navigate by clicking tabs or search for specific settings using the
* built-in search bar. Search results are filtered and ranked by relevance.
* Thin QDialog frame around SettingsView: only the window title, the OK button
* and the close validation live here. All pages, navigation and search are
* owned by SettingsView so other hosts (e.g. a settings tab) can reuse them.
*/
class DlgSettings : public QDialog
{
Q_OBJECT
public:
/**
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
*
* Use these values instead of raw indices so reordering pages never silently
* breaks external callers like tab_room.cpp.
*/
enum SettingsPage
{
GeneralPage = 0,
AppearancePage,
UserInterfacePage,
DeckEditorPage,
StoragePage,
MessagesPage,
SoundPage,
ShortcutsPage,
NumPages
};
using SettingsPage = SettingsView::SettingsPage;
static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage;
static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage;
static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage;
static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage;
static constexpr SettingsPage StoragePage = SettingsView::StoragePage;
static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage;
static constexpr SettingsPage SoundPage = SettingsView::SoundPage;
static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage;
static constexpr SettingsPage NumPages = SettingsView::NumPages;
explicit DlgSettings(QWidget *parent = nullptr);
void setTab(int index);
AbstractSettingsPage *page(SettingsPage which) const;
private slots:
void onTabClicked(int index);
void onSearchTextChanged(const QString &text);
void onSearchResultClicked(const QModelIndex &index);
void updateLanguage();
private:
// UI elements
QLineEdit *searchEdit; ///< Search bar for filtering settings
QWidget *tabBarWidget; ///< Container widget for the tab buttons
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
QListView *searchResultsView; ///< Search results list view
QWidget *pagesContainer; ///< Container stacking pages and search results
QPushButton *okButton; ///< Button to close the dialog
// Data
QList<AbstractSettingsPage *> pages; ///< All settings page instances
SettingsSearchModel *searchModel; ///< Model for search results
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
int currentTabIndex; ///< Currently active tab index
bool searchActive; ///< Whether search mode is active
void setupUi();
void setupTabBar();
void buildSearchIndex();
void switchToTabMode();
void switchToSearchMode();
void navigateToSearchResult(const QModelIndex &index);
void setActiveTab(int index);
static void flashWidget(QWidget *widget);
static QStringList translatedPageNames();
SettingsView *settingsView; ///< Shared settings content
QPushButton *okButton; ///< Button to close the dialog
void retranslateUi();
void retranslateTabNames();
protected:
void closeEvent(QCloseEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
};
#endif // DLG_SETTINGS_H

View file

@ -0,0 +1,34 @@
#include "path_safety_warning.h"
#include "../../client/settings/cache_settings.h"
#include <QCoreApplication>
#include <QMessageBox>
#include <QObject>
#include <QString>
#include <libcockatrice/settings/paths_settings.h>
class QWidget;
bool PathSafetyWarning::confirmOutsideProgramDir(QWidget *parent, const QString &path)
{
if (SettingsCache::instance().getIsPortableBuild()) {
return true;
}
if (!PathsSettings::isInsideDir(path, QCoreApplication::applicationDirPath())) {
return true;
}
QMessageBox box(QMessageBox::Warning, QObject::tr("Path inside the program directory"),
QObject::tr("'%1' is inside the Cockatrice program directory.\n\n"
"Files stored there can be deleted by an update or by uninstalling "
"Cockatrice, so this change was not applied. Choose a location outside "
"the program directory.")
.arg(path),
QMessageBox::Ok, parent);
box.setTextFormat(Qt::PlainText);
box.exec();
return false;
}

View file

@ -0,0 +1,25 @@
#ifndef COCKATRICE_PATH_SAFETY_WARNING_H
#define COCKATRICE_PATH_SAFETY_WARNING_H
class QString;
class QWidget;
namespace PathSafetyWarning
{
/**
* @brief Warns when the user tries to configure a data path inside the program directory.
*
* Shows a message box explaining that files kept there can be deleted by an
* update or by uninstalling Cockatrice. Portable builds are always exempt:
* their data lives beside the executables by design.
*
* @param parent The parent widget
* @param path The path the user is trying to configure
* @return true when the path is outside the program directory and safe to apply
*/
bool confirmOutsideProgramDir(QWidget *parent, const QString &path);
} // namespace PathSafetyWarning
#endif // COCKATRICE_PATH_SAFETY_WARNING_H

View file

@ -0,0 +1,497 @@
/**
* @file settings_view.cpp
* @brief Implementation of the settings content widget
* @ingroup Dialogs
*/
#include "settings_view.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../settings_page/abstract_settings_page.h"
#include "../settings_page/appearance_settings_page.h"
#include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_settings_page.h"
#include "../settings_page/settings_search_delegate.h"
#include "../settings_page/settings_search_model.h"
#include "../settings_page/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h"
#include "../settings_page/user_interface_settings_page.h"
#include <QAbstractAnimation>
#include <QAbstractItemView>
#include <QBrush>
#include <QColor>
#include <QCoreApplication>
#include <QDebug>
#include <QEasingCurve>
#include <QEvent>
#include <QFlags>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QHBoxLayout>
#include <QIcon>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLineEdit>
#include <QListView>
#include <QMessageLogger>
#include <QModelIndex>
#include <QObject>
#include <QPalette>
#include <QPixmap>
#include <QPropertyAnimation>
#include <QRect>
#include <QScreen>
#include <QScrollArea>
#include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QShortcut>
#include <QSize>
#include <QSizePolicy>
#include <QStackedLayout>
#include <QStackedWidget>
#include <QStringLiteral>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidget>
#include <QtGlobal>
#include <libcockatrice/settings/personal_settings.h>
#include <qnamespace.h>
/**
* @brief Wraps a widget in a scroll area for long settings pages
* @param widget The widget to wrap
* @return The scroll area containing the widget
*/
static QScrollArea *makeScrollable(QWidget *widget)
{
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
auto *scrollArea = new QScrollArea;
scrollArea->setWidgetResizable(true);
scrollArea->setContentsMargins(0, 0, 0, 0);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->horizontalScrollBar()->setEnabled(false);
scrollArea->setWidget(widget);
return scrollArea;
}
/**
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
*/
static QStringList pageIconResources()
{
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
}
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
{
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
retranslateUi();
searchEdit->setFocus();
}
void SettingsView::setupUi()
{
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);
// Tab bar (built in setupTabBar)
setupTabBar();
// Pages stacked widget
pagesWidget = new QStackedWidget;
auto *generalPage = new GeneralSettingsPage;
auto *appearancePage = new AppearanceSettingsPage;
auto *userInterfacePage = new UserInterfaceSettingsPage;
auto *deckEditorPage = new DeckEditorSettingsPage;
auto *storagePage = new StorageSettingsPage;
auto *messagesPage = new MessagesSettingsPage;
auto *soundPage = new SoundSettingsPage;
auto *shortcutsPage = new ShortcutSettingsPage;
pages.append(generalPage);
pages.append(appearancePage);
pages.append(userInterfacePage);
pages.append(deckEditorPage);
pages.append(storagePage);
pages.append(messagesPage);
pages.append(soundPage);
pages.append(shortcutsPage);
pagesWidget->addWidget(makeScrollable(generalPage));
pagesWidget->addWidget(makeScrollable(appearancePage));
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
pagesWidget->addWidget(makeScrollable(messagesPage));
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);
Q_ASSERT(pages.size() == NumPages);
// Search results view (hidden by default)
searchResultsView = new QListView;
searchResultsView->setUniformItemSizes(false);
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
searchResultsView->setVisible(false);
searchResultsView->setStyleSheet(
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
searchModel = new SettingsSearchModel(this);
searchDelegate = new SettingsSearchDelegate(this);
searchResultsView->setModel(searchModel);
searchResultsView->setItemDelegate(searchDelegate);
connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
const QStringList icons = pageIconResources();
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
tabButtons[i]->setIcon(QPixmap(icons[i]));
}
searchDelegate->setPageIcons(icons);
searchResultsView->viewport()->update();
});
// Build search index after pages are created
buildSearchIndex();
// Pages container (stacked widget + search results overlay)
pagesContainer = new QWidget;
auto *containerLayout = new QStackedLayout;
containerLayout->setStackingMode(QStackedLayout::StackAll);
containerLayout->addWidget(pagesWidget);
containerLayout->addWidget(searchResultsView);
pagesContainer->setLayout(containerLayout);
// Main layout
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(searchLayout);
mainLayout->addWidget(tabBarWidget);
auto *separator = new QFrame;
separator->setFrameShape(QFrame::HLine);
separator->setFrameShadow(QFrame::Sunken);
mainLayout->addWidget(separator);
mainLayout->addWidget(pagesContainer);
mainLayout->addSpacing(4);
setLayout(mainLayout);
// Keyboard shortcuts
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
int next = (currentTabIndex + 1) % tabButtons.size();
setActiveTab(next);
});
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
setActiveTab(prev);
});
// Initialize to first tab
setActiveTab(0);
}
void SettingsView::setupTabBar()
{
tabBarWidget = new QWidget;
auto *tabLayout = new QHBoxLayout;
tabLayout->setContentsMargins(0, 0, 0, 0);
tabLayout->setSpacing(2);
const QStringList iconResources = pageIconResources();
for (int i = 0; i < iconResources.size(); ++i) {
auto *tabButton = new QToolButton;
tabButton->setCheckable(true);
tabButton->setIcon(QPixmap(iconResources[i]));
tabButton->setIconSize(QSize(48, 48));
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
tabButton->setAutoExclusive(true);
tabButton->setMinimumHeight(85);
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
tabButtons.append(tabButton);
tabLayout->addWidget(tabButton);
}
tabBarWidget->setLayout(tabLayout);
}
void SettingsView::buildSearchIndex()
{
QList<SettingsSearchEntry> allEntries;
const QStringList pageNames = translatedPageNames();
searchDelegate->setPageNames(pageNames);
searchDelegate->setPageIcons(pageIconResources());
for (int i = 0; i < pages.size(); ++i) {
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
for (auto &entry : pageEntries) {
if (entry.pageIndex == -1) {
entry.pageIndex = i;
}
}
allEntries.append(pageEntries);
}
searchModel->setSourceEntries(allEntries);
}
void SettingsView::onTabClicked(int index)
{
if (searchActive) {
switchToTabMode();
}
setActiveTab(index);
}
void SettingsView::setActiveTab(int index)
{
if (index < 0 || index >= tabButtons.size()) {
return;
}
currentTabIndex = index;
pagesWidget->setCurrentIndex(index);
for (int i = 0; i < tabButtons.size(); ++i) {
tabButtons[i]->setChecked(i == index);
}
// Style active tab with a thick accent border + subtle background tint
for (int i = 0; i < tabButtons.size(); ++i) {
if (i == index) {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: palette(window); padding-bottom: 1px; }");
} else {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: transparent; }");
}
}
}
void SettingsView::flashWidget(QWidget *widget)
{
auto *overlay = new QWidget(widget);
overlay->setGeometry(widget->rect());
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
QPalette pal = overlay->palette();
QColor flashColor = pal.color(QPalette::Highlight);
flashColor.setAlpha(100);
pal.setBrush(QPalette::Window, flashColor);
overlay->setPalette(pal);
overlay->setAutoFillBackground(true);
auto *effect = new QGraphicsOpacityEffect(overlay);
effect->setOpacity(0.0);
overlay->setGraphicsEffect(effect);
overlay->show();
overlay->raise();
auto *flashIn = new QPropertyAnimation(effect, "opacity");
flashIn->setDuration(120);
flashIn->setStartValue(0.0);
flashIn->setEndValue(0.6);
flashIn->setEasingCurve(QEasingCurve::OutCubic);
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
fadeOut->setDuration(900);
fadeOut->setStartValue(0.6);
fadeOut->setEndValue(0.0);
fadeOut->setEasingCurve(QEasingCurve::InCubic);
auto *group = new QSequentialAnimationGroup(overlay);
group->addAnimation(flashIn);
group->addAnimation(fadeOut);
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
group->start(QAbstractAnimation::DeleteWhenStopped);
}
void SettingsView::onSearchTextChanged(const QString &text)
{
searchModel->setFilterString(text);
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
if (!searchActive) {
switchToSearchMode();
}
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
} else if (searchActive) {
switchToTabMode();
}
}
void SettingsView::switchToSearchMode()
{
searchActive = true;
tabBarWidget->setVisible(false);
pagesWidget->setVisible(false);
searchResultsView->setVisible(true);
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
}
void SettingsView::switchToTabMode()
{
searchActive = false;
tabBarWidget->setVisible(true);
pagesWidget->setVisible(true);
searchResultsView->setVisible(false);
searchEdit->blockSignals(true);
searchEdit->clear();
searchEdit->blockSignals(false);
setActiveTab(currentTabIndex);
}
void SettingsView::onSearchResultClicked(const QModelIndex &index)
{
navigateToSearchResult(index);
}
void SettingsView::navigateToSearchResult(const QModelIndex &index)
{
SettingsSearchEntry entry = searchModel->entryForIndex(index);
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
return;
}
// Switch to the page
switchToTabMode();
setActiveTab(entry.pageIndex);
// Scroll to the widget, focus it, and flash to highlight it
if (entry.widget) {
QWidget *widget = entry.widget;
while (widget) {
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
scrollArea->ensureWidgetVisible(entry.widget);
break;
}
widget = widget->parentWidget();
}
entry.widget->setFocus();
flashWidget(entry.widget);
}
}
void SettingsView::setTab(int index)
{
if (index >= 0 && index < tabButtons.size()) {
setActiveTab(index);
}
}
AbstractSettingsPage *SettingsView::page(SettingsPage which) const
{
return pages.value(static_cast<int>(which));
}
bool SettingsView::eventFilter(QObject *watched, QEvent *event)
{
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Escape) {
if (searchActive) {
switchToTabMode();
return true;
}
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
if (searchActive) {
if (searchResultsView->currentIndex().isValid()) {
navigateToSearchResult(searchResultsView->currentIndex());
}
return true;
}
} else if (keyEvent->key() == Qt::Key_Down) {
if (searchActive) {
int nextRow = searchResultsView->currentIndex().row() + 1;
if (nextRow >= searchModel->rowCount()) {
nextRow = 0;
}
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
searchResultsView->scrollTo(searchModel->index(nextRow));
return true;
}
} else if (keyEvent->key() == Qt::Key_Up) {
if (searchActive) {
int prevRow = searchResultsView->currentIndex().row() - 1;
if (prevRow < 0) {
prevRow = searchModel->rowCount() - 1;
}
if (prevRow >= 0) {
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
searchResultsView->scrollTo(searchModel->index(prevRow));
}
return true;
}
}
}
return QWidget::eventFilter(watched, event);
}
void SettingsView::retranslateUi()
{
retranslateTabNames();
// NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings
// before the view was extracted, and keeping the context preserves existing translations.
searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings..."));
// Rebuild search index for translated text
buildSearchIndex();
}
QStringList SettingsView::translatedPageNames()
{
// NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi).
return {QCoreApplication::translate("DlgSettings", "General"),
QCoreApplication::translate("DlgSettings", "Appearance"),
QCoreApplication::translate("DlgSettings", "User Interface"),
QCoreApplication::translate("DlgSettings", "Card Sources"),
QCoreApplication::translate("DlgSettings", "Storage"),
QCoreApplication::translate("DlgSettings", "Chat"),
QCoreApplication::translate("DlgSettings", "Sound"),
QCoreApplication::translate("DlgSettings", "Shortcuts")};
}
void SettingsView::retranslateTabNames()
{
const QStringList tabLabels = translatedPageNames();
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
tabButtons[i]->setText(tabLabels[i]);
}
}

View file

@ -0,0 +1,98 @@
/**
* @file settings_view.h
* @brief Settings content widget hosted by the settings dialog (and, later, the settings tab)
* @ingroup Dialogs
*/
#ifndef SETTINGS_VIEW_H
#define SETTINGS_VIEW_H
#include <QList>
#include <QString>
#include <QStringList>
#include <QWidget>
#include <qtmetamacros.h>
class QModelIndex;
class QObject;
class QEvent;
class QStackedWidget;
class QToolButton;
class QListView;
class QLineEdit;
class AbstractSettingsPage;
class SettingsSearchModel;
class SettingsSearchDelegate;
/**
* @brief Settings pages with tabbed navigation and search, without any dialog chrome
*
* Owns the search bar, the tab bar, the stacked settings pages and the search
* results view. Dialog/tab hosts only add their own frame (buttons, titles).
*/
class SettingsView : public QWidget
{
Q_OBJECT
public:
/**
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
*
* Use these values instead of raw indices so reordering pages never silently
* breaks external callers like tab_room.cpp.
*/
enum SettingsPage
{
GeneralPage = 0,
AppearancePage,
UserInterfacePage,
DeckEditorPage,
StoragePage,
MessagesPage,
SoundPage,
ShortcutsPage,
NumPages
};
explicit SettingsView(QWidget *parent = nullptr);
void setTab(int index);
AbstractSettingsPage *page(SettingsPage which) const;
private slots:
void onTabClicked(int index);
void onSearchTextChanged(const QString &text);
void onSearchResultClicked(const QModelIndex &index);
private:
// UI elements
QLineEdit *searchEdit; ///< Search bar for filtering settings
QWidget *tabBarWidget; ///< Container widget for the tab buttons
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
QListView *searchResultsView; ///< Search results list view
QWidget *pagesContainer; ///< Container stacking pages and search results
// Data
QList<AbstractSettingsPage *> pages; ///< All settings page instances
SettingsSearchModel *searchModel; ///< Model for search results
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
int currentTabIndex; ///< Currently active tab index
bool searchActive; ///< Whether search mode is active
void setupUi();
void setupTabBar();
void buildSearchIndex();
void switchToTabMode();
void switchToSearchMode();
void navigateToSearchResult(const QModelIndex &index);
void setActiveTab(int index);
static void flashWidget(QWidget *widget);
static QStringList translatedPageNames();
void retranslateUi();
void retranslateTabNames();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
};
#endif // SETTINGS_VIEW_H

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../dialogs/path_safety_warning.h"
#include "update/card_spoiler/spoiler_background_updater.h"
#include <QAbstractItemModel>
@ -363,6 +364,9 @@ void DeckEditorSettingsPage::spoilerPathButtonClicked()
if (lsPath.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, lsPath + "/spoiler.xml")) {
return;
}
mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml");
SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml");

View file

@ -1,6 +1,7 @@
#include "general_settings_page.h"
#include "../../../client/settings/cache_settings.h"
#include "../dialogs/path_safety_warning.h"
#include "../interface/card_picture_loader/card_picture_loader.h"
#include "../main.h"
#include "../server/user/user_info_connection.h"
@ -378,6 +379,9 @@ void GeneralSettingsPage::deckPathButtonClicked()
if (path.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
deckPathEdit->setText(path);
SettingsCache::instance().paths().setDeckPath(path);
@ -389,6 +393,9 @@ void GeneralSettingsPage::filtersPathButtonClicked()
if (path.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
filtersPathEdit->setText(path);
SettingsCache::instance().paths().setFiltersPath(path);
@ -400,6 +407,9 @@ void GeneralSettingsPage::replaysPathButtonClicked()
if (path.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
replaysPathEdit->setText(path);
SettingsCache::instance().paths().setReplaysPath(path);
@ -411,6 +421,9 @@ void GeneralSettingsPage::picsPathButtonClicked()
if (path.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
picsPathEdit->setText(path);
SettingsCache::instance().paths().setPicsPath(path);
@ -422,6 +435,9 @@ void GeneralSettingsPage::cardDatabasePathButtonClicked()
if (path.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
cardDatabasePathEdit->setText(path);
SettingsCache::instance().paths().setCardDatabasePath(path);
@ -433,6 +449,9 @@ void GeneralSettingsPage::customCardDatabaseButtonClicked()
if (path.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
customCardDatabasePathEdit->setText(path);
SettingsCache::instance().paths().setCustomCardDatabasePath(path);
@ -444,6 +463,9 @@ void GeneralSettingsPage::tokenDatabasePathButtonClicked()
if (path.isEmpty()) {
return;
}
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
return;
}
tokenDatabasePathEdit->setText(path);
SettingsCache::instance().paths().setTokenDatabasePath(path);

View file

@ -1060,6 +1060,12 @@ void TabGame::setCardMenu(CardMenu *menu)
return;
}
if (aCardMenu->menu() && aCardMenu->menu()->isVisible()) {
aCardMenu->menu()->hide();
if (gameView && gameView->viewport()) {
gameView->viewport()->update();
}
}
if (menu) {
aCardMenu->setMenu(menu);
} else {

View file

@ -1,5 +1,6 @@
#include "tab_report.h"
#include "../server/user/user_context_menu.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h"
@ -33,11 +34,13 @@
#include <QVariant>
#include <QWidget>
#include <QtGlobal>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_assign.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
@ -120,6 +123,10 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents);
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
table->setContextMenuPolicy(Qt::CustomContextMenu);
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
userContextMenu = new UserContextMenu(tabSupervisor, this);
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog);
descGroup = new QGroupBox;
descriptionEdit = new QTextEdit;
@ -231,14 +238,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
assignButton = new QPushButton;
resolveButton = new QPushButton;
resolveWithNoteButton = new QPushButton;
dismissButton = new QPushButton;
reopenButton = new QPushButton;
viewReplayButton = new QPushButton;
joinGameButton = new QPushButton;
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); });
connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); });
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true); });
connect(reopenButton, &QPushButton::clicked, this, &TabReport::reopenReport);
connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
@ -247,8 +254,8 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
auto *actionBar = new QHBoxLayout;
actionBar->addWidget(assignButton);
actionBar->addWidget(resolveButton);
actionBar->addWidget(resolveWithNoteButton);
actionBar->addWidget(dismissButton);
actionBar->addWidget(reopenButton);
actionBar->addSpacing(20);
actionBar->addWidget(viewReplayButton);
actionBar->addWidget(joinGameButton);
@ -272,6 +279,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
void TabReport::retranslateUi()
{
userContextMenu->retranslateUi();
searchEdit->setPlaceholderText(tr("Search by username, category..."));
statusFilter->clear();
statusFilter->addItem(tr("All Statuses"), "");
@ -312,9 +320,10 @@ void TabReport::retranslateUi()
statsGroup->setTitle(tr("Report Statistics"));
assignButton->setText(tr("Assign to Me"));
resolveButton->setText(tr("Resolve"));
resolveWithNoteButton->setText(tr("Resolve with note..."));
resolveButton->setText(tr("Resolve..."));
dismissButton->setText(tr("Dismiss..."));
reopenButton->setText(tr("Reopen..."));
reopenButton->setToolTip(tr("Available for resolved or dismissed reports."));
viewReplayButton->setText(tr("View Replay"));
joinGameButton->setText(tr("Join Game"));
}
@ -346,6 +355,7 @@ void TabReport::reportListResponse(const Response &response)
refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) {
pendingStatusMessage.clear();
statusLabel->setText(tr("Failed to load reports."));
return;
}
@ -359,6 +369,11 @@ void TabReport::reportListResponse(const Response &response)
applyFilters();
updateStats();
if (!pendingStatusMessage.isEmpty()) {
statusLabel->setText(pendingStatusMessage);
pendingStatusMessage.clear();
}
if (selectedReportIdBeforeRefresh >= 0) {
bool found = false;
for (int row = 0; row < table->rowCount(); ++row) {
@ -494,6 +509,36 @@ void TabReport::onSelectionChanged()
updateActionStates();
}
void TabReport::showTableContextMenu(const QPoint &pos)
{
QTableWidgetItem *clickedItem = table->itemAt(pos);
if (!clickedItem || clickedItem->row() < 0) {
return;
}
const int row = clickedItem->row();
const QTableWidgetItem *reportedItem = table->item(row, COL_REPORTED);
const QTableWidgetItem *idItem = table->item(row, COL_ID);
if (!reportedItem || !idItem || reportedItem->text().isEmpty()) {
return;
}
int userLevel = ServerInfo_User::IsUser;
const int reportId = idItem->data(Qt::UserRole).toInt();
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
if (r.reported_user_level() != 0) {
userLevel = r.reported_user_level();
}
break;
}
}
table->selectRow(row);
userContextMenu->showContextMenu(table->viewport()->mapToGlobal(pos), reportedItem->text(),
UserLevelFlags(userLevel));
}
void TabReport::loadReportDetails(int reportId)
{
detailsRequestedReportId = reportId;
@ -577,8 +622,8 @@ void TabReport::updateActionStates()
const QString status = QString::fromStdString(report.status());
assignButton->setEnabled(status == "open");
resolveButton->setEnabled(status == "open" || status == "assigned");
resolveWithNoteButton->setEnabled(status == "open" || status == "assigned");
dismissButton->setEnabled(status == "open" || status == "assigned");
reopenButton->setEnabled(status == "resolved" || status == "dismissed");
const bool hasGameId = report.game_id() > 0;
const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0;
@ -590,8 +635,8 @@ void TabReport::setActionsEnabled(bool enabled)
{
assignButton->setEnabled(enabled);
resolveButton->setEnabled(enabled);
resolveWithNoteButton->setEnabled(enabled);
dismissButton->setEnabled(enabled);
reopenButton->setEnabled(enabled);
viewReplayButton->setEnabled(false);
joinGameButton->setEnabled(false);
commentButton->setEnabled(enabled);
@ -645,7 +690,7 @@ void TabReport::assignReport()
void TabReport::assignResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Assigned."));
pendingStatusMessage = tr("Assigned.");
refreshList();
} else {
statusLabel->setText(tr("Assignment failed."));
@ -653,22 +698,19 @@ void TabReport::assignResponse(const Response &response)
}
}
void TabReport::resolveReport(bool dismissed, bool promptNote)
void TabReport::resolveReport(bool dismissed)
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
QString note;
if (promptNote) {
bool ok;
note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
QLineEdit::Normal, QString(), &ok);
if (!ok) {
return;
}
bool ok;
QString note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
QLineEdit::Normal, QString(), &ok);
if (!ok) {
return;
}
setActionsEnabled(false);
@ -689,7 +731,46 @@ void TabReport::resolveReport(bool dismissed, bool promptNote)
void TabReport::resolveResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Done."));
pendingStatusMessage = tr("Done.");
refreshList();
} else {
statusLabel->setText(tr("Action failed."));
updateActionStates();
}
}
void TabReport::reopenReport()
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
bool ok;
QString note = QInputDialog::getText(this, tr("Reopen Report"), tr("Reopening note (optional):"), QLineEdit::Normal,
QString(), &ok);
if (!ok) {
return;
}
setActionsEnabled(false);
statusLabel->setText(tr("Reopening..."));
Command_ReportReopen cmd;
cmd.set_report_id(reportId);
if (!note.isEmpty()) {
cmd.set_reopen_note(note.toStdString());
}
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::reopenResponse);
client->sendCommand(pend);
}
void TabReport::reopenResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
pendingStatusMessage = tr("Reopened.");
refreshList();
} else {
statusLabel->setText(tr("Action failed."));

View file

@ -4,6 +4,7 @@
#include "tab.h"
#include <QList>
#include <QPoint>
#include <QString>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
#include <qtmetamacros.h>
@ -22,6 +23,7 @@ class QTimer;
class GameReplay;
class Response;
class TabSupervisor;
class UserContextMenu;
class TabReport : public Tab
{
@ -37,15 +39,17 @@ public:
signals:
void openReplay(GameReplay *replay);
void requestJoinGame(int gameId, int roomId);
void openMessageDialog(const QString &userName, bool focus);
private slots:
void refreshList();
void reportListResponse(const Response &response);
void assignReport();
void assignResponse(const Response &response);
void resolveReport(bool dismissed, bool promptNote);
void resolveReport(bool dismissed);
void resolveResponse(const Response &response);
void onSelectionChanged();
void showTableContextMenu(const QPoint &pos);
void viewReplay();
void viewReplayResponse(const Response &response);
void joinGame();
@ -56,6 +60,8 @@ private slots:
void reportDetailsResponse(const Response &response);
void requestStats();
void statsResponse(const Response &response);
void reopenReport();
void reopenResponse(const Response &response);
private:
int selectedReportId() const;
@ -85,12 +91,13 @@ private:
QPushButton *commentButton;
QPushButton *assignButton;
QPushButton *resolveButton;
QPushButton *resolveWithNoteButton;
QPushButton *dismissButton;
QPushButton *reopenButton;
QPushButton *viewReplayButton;
QPushButton *joinGameButton;
QLabel *statusLabel;
QTimer *refreshTimer;
UserContextMenu *userContextMenu;
QGroupBox *userContextGroup;
QLabel *userContextName;
@ -108,6 +115,7 @@ private:
QLabel *userContextNotesLabel;
QLabel *userContextRecentReportsLabel;
QString lastRequestedUser;
QString pendingStatusMessage;
QGroupBox *statsGroup;
QLabel *statsTotalLabel;
QLabel *statsTrendLabel;

View file

@ -919,6 +919,7 @@ void TabSupervisor::openTabReport()
myAddTab(tabReport, aTabReport);
connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay);
connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame);
connect(tabReport, &TabReport::openMessageDialog, this, &TabSupervisor::addMessageTab);
connect(tabReport, &QObject::destroyed, this, [this] {
tabReport = nullptr;
aTabReport->setChecked(false);

View file

@ -130,10 +130,19 @@ void LineEditCompleter::keyPressEvent(QKeyEvent *event)
CompleterInfo *active = nullptr;
QString prefix;
// An open "[[" card link takes precedence over a mention: while the cursor is
// inside it, an earlier "@" must not shadow the card completer.
const int openCardPos = textValue.lastIndexOf("[[", cursorPos - 1);
const int closeCardPos = openCardPos == -1 ? -1 : textValue.indexOf("]]", openCardPos + 2);
const bool insideCardLink = openCardPos != -1 && (closeCardPos == -1 || closeCardPos >= cursorPos);
for (auto &info : completers) {
bool triggered = false;
switch (info.trigger) {
case CompleterTrigger::Mention: {
if (insideCardLink) {
break;
}
int triggerPos = textValue.lastIndexOf("@", cursorPos - 1);
if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) {
triggered = true;

View file

@ -100,10 +100,27 @@ void renderReportDetails(QTextEdit *chatLogEdit,
const QString &moderatorPrefix,
const QString &nonModeratorPrefix)
{
QString details;
if (report.has_resolution_note() && !report.resolution_note().empty()) {
details = QObject::tr("Resolution note: %1").arg(QString::fromStdString(report.resolution_note()));
}
if (report.has_resolution_time()) {
QString resolvedBy = QObject::tr("unknown");
if (report.has_resolved_by_name() && !report.resolved_by_name().empty()) {
resolvedBy = QString::fromStdString(report.resolved_by_name());
}
if (!details.isEmpty()) {
details += "\n";
}
details += QObject::tr("Resolved at %1 by %2").arg(formatReportTime(report.resolution_time()), resolvedBy);
}
if (report.has_chat_log() && !report.chat_log().empty()) {
chatLogEdit->setPlainText(QString::fromStdString(report.chat_log()));
const QString chatLog = QString::fromStdString(report.chat_log());
chatLogEdit->setPlainText(
details.isEmpty() ? chatLog : details + "\n\n" + QObject::tr("=== Chat Log ===") + "\n" + chatLog);
} else {
chatLogEdit->clear();
chatLogEdit->setPlainText(details);
}
commentsEdit->clear();

View file

@ -58,7 +58,9 @@
#include <QAction>
#include <QApplication>
#include <QButtonGroup>
#include <QCheckBox>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDesktopServices>
@ -603,8 +605,39 @@ MainWindow::MainWindow(QWidget *parent)
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
}
void MainWindow::warnAboutDataPathsInProgramDir()
{
auto &paths = SettingsCache::instance().paths();
if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) {
return;
}
const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath());
if (offenders.isEmpty()) {
return;
}
QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"),
tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
"Cockatrice keeps its own data outside the program directory by default. Files "
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
"Change them in the Settings dialog - most are on the General page - to keep "
"them safe.")
.arg(offenders.join(QLatin1Char('\n'))),
QMessageBox::Ok, this);
box.setTextFormat(Qt::PlainText);
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
box.setCheckBox(dontShowAgain);
box.exec();
if (dontShowAgain->isChecked()) {
paths.setAppDirWarningAcknowledged(true);
}
}
void MainWindow::startupConfigCheck()
{
warnAboutDataPathsInProgramDir();
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
// checkUnknownSets() is intentionally deferred from the card database load

View file

@ -149,6 +149,9 @@ private:
void runFirstRunWizard(bool firstRun = false);
/** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */
void warnAboutDataPathsInProgramDir();
inline QString getCardUpdaterBinaryName()
{
return "oracle";

63
gcovr.cfg Normal file
View file

@ -0,0 +1,63 @@
# gcovr configuration for Cockatrice line coverage.
#
# Used by .ci/compile.sh --coverage and by hand:
#
# cmake -S . -B build/cover -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=1 -DTEST=1 -DENABLE_COVERAGE=ON
# cmake --build build/cover -j 24
# ctest --test-dir build/cover -LE bench -j 24
# gcovr build/cover --root . --object-directory build/cover --txt --decisions
#
# The point of most of this file is subtraction. A raw report is dominated by code no test can
# reach: vendored third-party trees, build directories left over from earlier experiments, and
# the client's main() and window classes, which have no headless test. Those all dilute the one
# number worth watching, so they are filtered out here rather than in every invocation.
# root is deliberately not set here. This file is normally invoked from the build directory
# (compile.sh runs there, because it configures with `cmake ..`), so a `root` of `.` would
# resolve to the build tree and every filter below would exclude everything. compile.sh passes
# --root explicitly instead.
# Our own code only. This keeps generated Qt MOC output, protobuf output and anything CMake
# built under the source tree out of the report.
filter = ^cockatrice/
filter = ^libcockatrice_[^/]+/
filter = ^servatrice/
filter = ^oracle/
# The tests are the vehicle, not the subject. They are compiled with the same instrumentation,
# so leaving them in would add their near-perfect coverage to the numerator and quietly report a
# healthier project than we have: test code is only ever executed by the run that is measuring
# it. Helpers under tests/ are excluded on the same grounds.
exclude = ^tests/
# Vendored third-party code is not ours to cover and is never going to move the number.
exclude = ^vcpkg/
exclude = ^doc/doxygen/theme/
exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/
exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$
exclude = ^oracle/src/lzma/
exclude = ^oracle/src/qt-json/
exclude = ^oracle/src/zip/
exclude = ^servatrice/src/smtp/
# Qt's own moc/pch/uic output is generated into the build tree, not the source tree, so the
# filters above already keep it out. These cover CLion's directories, which sit in-tree and are
# picked up by the object-directory walk.
exclude = .*cmake-build-.*
exclude = .*\.cache/
# Process entry points and the client's window chrome. Each of these needs a display, an
# interactive terminal, or a live database before it does anything a test could observe, so no
# headless run will ever move them. Left in, they contribute thousands of lines at 0% and drag
# the total down by a fixed amount that hides movement in the code we can actually test. The
# window header is excluded with its .cpp because gcovr attributes its inline template code
# there, which is where most of its line count lives.
exclude = ^cockatrice/src/main\.cpp$
exclude = ^cockatrice/src/interface/window_main\.
exclude = ^oracle/src/main\.cpp$
exclude = ^servatrice/src/main\.cpp$
exclude-throw-branches = yes
exclude-function-lines = yes
print-summary = yes

View file

@ -60,3 +60,5 @@ target_link_libraries(
PUBLIC libcockatrice_utility
PUBLIC ${QT_CORE_MODULE}
)
target_precompile_headers(libcockatrice_card PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -41,3 +41,5 @@ target_link_libraries(
libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility
${QT_CORE_MODULE}
)
target_precompile_headers(libcockatrice_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -18,3 +18,5 @@ add_dependencies(libcockatrice_filters libcockatrice_card)
target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_filters PUBLIC libcockatrice_card ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_filters PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -31,3 +31,5 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES})
target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_interfaces PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -29,3 +29,5 @@ add_library(
target_include_directories(libcockatrice_models_database PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_models_database PUBLIC libcockatrice_card libcockatrice_filters ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_models_database PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -16,3 +16,5 @@ target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT
target_link_libraries(
libcockatrice_models_deck_list PUBLIC libcockatrice_card libcockatrice_deck_list ${QT_CORE_MODULE}
)
target_precompile_headers(libcockatrice_models_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -19,3 +19,5 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
libcockatrice_network_server_remote
)
target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -19,3 +19,5 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
libcockatrice_network_client_abstract
)
target_precompile_headers(libcockatrice_network_client_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -19,3 +19,5 @@ target_link_libraries(
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
libcockatrice_network_client_abstract libcockatrice_interfaces libcockatrice_utility libcockatrice_protocol
)
target_precompile_headers(libcockatrice_network_client_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -15,3 +15,5 @@ add_dependencies(libcockatrice_network_server_local libcockatrice_protocol)
target_include_directories(libcockatrice_network_server_local PUBLIC .)
target_link_libraries(libcockatrice_network_server_local PUBLIC ${COCKATRICE_QT_MODULES} libcockatrice_protocol)
target_precompile_headers(libcockatrice_network_server_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -62,3 +62,5 @@ target_link_libraries(
libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng
libcockatrice_deck_list ${COCKATRICE_QT_MODULES}
)
target_precompile_headers(libcockatrice_network_server_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -56,6 +56,10 @@ public:
{
return deck;
}
bool getSideboardLocked() const
{
return sideboardLocked;
}
bool getReadyStart() const
{
return readyStart;

View file

@ -61,6 +61,8 @@
#include <QTimer>
#include <google/protobuf/descriptor.h>
#include <google/protobuf/message.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/context_deck_select.pb.h>
#include <libcockatrice/protocol/pb/context_ping_changed.pb.h>
#include <libcockatrice/protocol/pb/event_delete_arrow.pb.h>
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
@ -825,6 +827,48 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
}
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1));
// A newly joined player never saw the "Player X has loaded a deck" messages
// (they are only broadcast when a deck is selected), so replay a deck-select
// context for every player that already has a deck loaded. It is delivered
// privately to the joining participant, whose client renders it as a log
// entry in the same way as a fresh deck selection. Skipped on reconnect
// (resuming) — the player already saw these — and once the game has started,
// when the message is no longer useful and would only spam the log.
if (!resuming && !gameStarted) {
for (auto *participant : participants.values()) {
if (participant == joiningParticipant) {
continue;
}
auto *deckOwner = qobject_cast<Server_Player *>(participant);
if (deckOwner == nullptr) {
continue;
}
const DeckList *deckList = deckOwner->getDeckList();
if (deckList == nullptr) {
continue;
}
Event_PlayerPropertiesChanged event;
event.mutable_player_properties()->set_sideboard_locked(deckOwner->getSideboardLocked());
event.mutable_player_properties()->set_deck_hash(deckList->getDeckHash().toStdString());
Context_DeckSelect deckSelect;
deckSelect.set_deck_hash(deckList->getDeckHash().toStdString());
deckSelect.set_sideboard_size(deckList->getSideboardSize());
if (getShareDecklistsOnLoad()) {
deckSelect.set_deck_list(deckList->writeToString_Native().toStdString());
}
GameEventContext context;
context.GetReflection()
->MutableMessage(&context, deckSelect.GetDescriptor()->FindExtensionByName("ext"))
->CopyFrom(deckSelect);
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER,
prepareGameEvent(event, deckOwner->getPlayerId(), &context));
}
}
}
void Server_Game::sendGameEventContainer(GameEventContainer *cont,

View file

@ -24,3 +24,5 @@ target_include_directories(
libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR} # points to the generated headers
)
target_precompile_headers(libcockatrice_protocol PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -55,6 +55,7 @@ set(PROTO_FILES
command_report_details.proto
command_report_list.proto
command_report_my_list.proto
command_report_reopen.proto
command_report_resolve.proto
command_report_stats.proto
command_report_user_info.proto

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportReopen {
extend ModeratorCommand {
optional Command_ReportReopen ext = 1206;
}
required int32 report_id = 1;
optional string reopen_note = 2;
}

View file

@ -26,6 +26,7 @@ message ModeratorCommand {
REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
REPORT_USER_INFO = 1204;
REPORT_STATS = 1205;
REPORT_REOPEN = 1206;
}
extensions 100 to max;
}

View file

@ -33,4 +33,7 @@ message ServerInfo_Report {
optional string chat_log = 14;
optional string resolution_note = 15;
optional string resolved_by_name = 16;
optional int32 reported_user_level = 17;
}

View file

@ -11,6 +11,10 @@ add_library(
libcockatrice/rng/sfmt/SFMT.c
)
# SFMT is vendored and its own headers trip UBSan's alignment and vptr checks.
cockatrice_disable_sanitizers_for(libcockatrice/rng/sfmt/SFMT.c)
cockatrice_disable_coverage_for(libcockatrice/rng/sfmt/SFMT.c)
target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE})

View file

@ -71,3 +71,5 @@ target_include_directories(
)
target_link_libraries(libcockatrice_settings PUBLIC libcockatrice_card libcockatrice_utility ${QT_CORE_MODULE})
target_precompile_headers(libcockatrice_settings PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -2,6 +2,8 @@
#include "libcockatrice/settings/settings_manager.h"
#include <QDir>
#include <QStringList>
#include <QVariant>
class QObject;
@ -121,3 +123,52 @@ void PathsSettings::setSpoilerDatabasePath(const QString &_spoilerDatabasePath)
setValue(_spoilerDatabasePath, "spoilerDatabase");
emit cardDatabasePathChanged();
}
QStringList PathsSettings::pathsInsideDir(const QString &baseDir) const
{
QStringList offenders;
const auto check = [&offenders, &baseDir](const QString &path) {
if (!path.isEmpty() && isInsideDir(path, baseDir)) {
offenders.append(QDir::cleanPath(path));
}
};
check(getDeckPath());
check(getFiltersPath());
check(getReplaysPath());
check(getPicsPath());
check(getCustomPicsPath());
check(getThemesPath());
check(getCardDatabasePath());
check(getCustomCardDatabasePath());
check(getTokenDatabasePath());
check(getSpoilerCardDatabasePath());
check(getRedirectCachePath());
offenders.removeDuplicates();
return offenders;
}
bool PathsSettings::isInsideDir(const QString &path, const QString &baseDir)
{
#if defined(Q_OS_WIN) || defined(Q_OS_MACOS)
// Windows and default macOS volumes are case-insensitive filesystems.
constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseInsensitive;
#else
constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive;
#endif
const QString base = QDir::cleanPath(baseDir);
const QString cleaned = QDir::cleanPath(path);
const QString prefix = base.endsWith(QLatin1Char('/')) ? base : base + QLatin1Char('/');
return cleaned == base || cleaned.startsWith(prefix, caseSensitivity);
}
bool PathsSettings::getAppDirWarningAcknowledged() const
{
return getValue("appDirWarningAcknowledged").toBool();
}
void PathsSettings::setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged)
{
setValue(_appDirWarningAcknowledged, "appDirWarningAcknowledged");
}

View file

@ -4,6 +4,7 @@
#include "settings_manager.h"
#include <QString>
#include <QStringList>
#include <libcockatrice/interfaces/interface_paths_settings_provider.h>
#include <qtmetamacros.h>
@ -27,6 +28,24 @@ public:
[[nodiscard]] QString getSpoilerCardDatabasePath() const override;
[[nodiscard]] QString getRedirectCachePath() const override;
/**
* @brief Returns the configured paths that resolve inside baseDir.
*
* Comparison is lexical (QDir::cleanPath); symlinks are not resolved, so a
* path that only reaches baseDir through a symlink is not reported.
*/
[[nodiscard]] QStringList pathsInsideDir(const QString &baseDir) const;
/**
* @brief Whether path resolves inside baseDir, as a single-path check.
*
* A path counts as inside when it equals baseDir or is nested below it;
* a sibling sharing the name prefix (baseDir + "2") does not.
*/
[[nodiscard]] static bool isInsideDir(const QString &path, const QString &baseDir);
[[nodiscard]] bool getAppDirWarningAcknowledged() const;
void setDeckPath(const QString &_deckPath);
void setFiltersPath(const QString &_filtersPath);
void setReplaysPath(const QString &_replaysPath);
@ -37,14 +56,17 @@ public:
void setCustomCardDatabasePath(const QString &_customCardDatabasePath);
void setTokenDatabasePath(const QString &_tokenDatabasePath);
void setSpoilerDatabasePath(const QString &_spoilerDatabasePath);
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
signals:
void cardDatabasePathChanged();
void picsPathChanged();
void themeChanged();
private:
public:
explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr);
private:
PathsSettings(const PathsSettings & /*other*/);
};

View file

@ -49,3 +49,5 @@ target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Cr
set(ORACLE_LIBS)
include_directories(${${COCKATRICE_QT_VERSION_NAME}Core_INCLUDE_DIRS})
target_precompile_headers(libcockatrice_utility PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -79,6 +79,9 @@ if(ZLIB_FOUND)
include_directories(${ZLIB_INCLUDE_DIRS})
add_definitions("-DHAS_ZLIB")
list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp)
# minizip and zlib are vendored.
cockatrice_disable_sanitizers_for(src/zip/unzip.cpp src/zip/zipglobal.cpp)
cockatrice_disable_coverage_for(src/zip/unzip.cpp src/zip/zipglobal.cpp)
else()
message(STATUS "Oracle: zlib not found; ZIP support disabled")
endif()
@ -89,6 +92,9 @@ if(LIBLZMA_FOUND)
include_directories(${LIBLZMA_INCLUDE_DIRS})
add_definitions("-DHAS_LZMA")
list(APPEND oracle_SOURCES src/lzma/decompress.cpp)
# The LZMA SDK is vendored.
cockatrice_disable_sanitizers_for(src/lzma/decompress.cpp)
cockatrice_disable_coverage_for(src/lzma/decompress.cpp)
else()
message(STATUS "Oracle: LibLZMA not found; xz support disabled")
endif()

View file

@ -27,6 +27,14 @@ set(servatrice_SOURCES
set(servatrice_RESOURCES servatrice.qrc)
# Qxt SMTP is vendored.
cockatrice_disable_sanitizers_for(
src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp
)
cockatrice_disable_coverage_for(
src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp
)
if(WIN32)
set(servatrice_SOURCES ${servatrice_SOURCES} servatrice.rc)
endif(WIN32)

View file

@ -110,6 +110,7 @@
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_my_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
@ -434,6 +435,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo
return cmdReportAssign(cmd.GetExtension(Command_ReportAssign::ext), rc);
case ModeratorCommand::REPORT_RESOLVE:
return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc);
case ModeratorCommand::REPORT_REOPEN:
return cmdReportReopen(cmd.GetExtension(Command_ReportReopen::ext), rc);
case ModeratorCommand::VIEWLOG_HISTORY:
return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true);
case ModeratorCommand::GRANT_REPLAY_ACCESS:
@ -1983,6 +1986,29 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com
return Response::RespOk;
}
namespace
{
int reportUserLevel(bool userExists, int adminFlags)
{
int userLevel = ServerInfo_User::IsUser;
if (userExists) {
userLevel |= ServerInfo_User::IsRegistered;
if (adminFlags & 1) {
userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator;
} else if (adminFlags & 2) {
userLevel |= ServerInfo_User::IsModerator;
}
if (adminFlags & 4) {
userLevel |= ServerInfo_User::IsJudge;
}
if (adminFlags & 8) {
userLevel |= ServerInfo_User::IsDeveloper;
}
}
return userLevel;
}
} // namespace
Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Command_ReportList &cmd,
ResponseContainer &rc)
{
@ -1996,8 +2022,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman
// Columns: 0=id, 1=reporter_name, 2=reported_user_name, 3=game_id,
// 4=category, 5=description, 6=created_at, 7=status,
// 8=resolution_note, 9=assigned_mod_name,
// 10=room_id, 11=replay_id
// 8=resolution_note, 9=assigned_mod_name, 10=resolved_by_name,
// 11=room_id, 12=replay_id,
// 13=reported_user_id, 14=reported_user_admin
QString whereClause;
if (unresolvedOnly) {
whereClause = "WHERE r.status = 'open' OR r.status = 'assigned' ";
@ -2012,11 +2039,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman
QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, "
"r.category, r.description, r.created_at, r.status, "
"r.resolution_note, u.name AS assigned_mod_name, r.room_id, "
"r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.room_id, "
"(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) "
"AS replay_id "
"AS replay_id, u2.id AS reported_user_id, u2.admin AS reported_user_admin "
"FROM {prefix}_reports r "
"LEFT JOIN {prefix}_users u ON r.assigned_to = u.id ";
"LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "
"LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id "
"LEFT JOIN {prefix}_users u2 ON u2.name = r.reported_user_name ";
if (unresolvedOnly) {
queryStr += "WHERE r.status = 'open' OR r.status = 'assigned' ";
@ -2057,14 +2086,19 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Comman
if (!query->value(9).isNull()) {
info->set_assigned_mod_name(query->value(9).toString().toStdString());
}
if (!query->value(10).isNull()) {
info->set_room_id(query->value(10).toInt());
info->set_resolved_by_name(query->value(10).toString().toStdString());
}
if (!query->value(11).isNull()) {
info->set_replay_id(query->value(11).toInt());
info->set_room_id(query->value(11).toInt());
}
if (!query->value(12).isNull()) {
info->set_replay_id(query->value(12).toInt());
}
info->set_reported_user_level(reportUserLevel(!query->value(13).isNull(), query->value(14).toInt()));
}
rc.setResponseExtension(re);
@ -2737,11 +2771,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm
QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, "
"r.category, r.description, r.created_at, r.status, "
"r.resolution_note, u.name AS assigned_mod_name, r.room_id, "
"r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.room_id, "
"(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) "
"AS replay_id "
"AS replay_id, u2.id AS reported_user_id, u2.admin AS reported_user_admin "
"FROM {prefix}_reports r "
"LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "
"LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id "
"LEFT JOIN {prefix}_users u2 ON u2.name = r.reported_user_name "
"WHERE r.reporter_id = :reporter_id "
"ORDER BY r.created_at DESC LIMIT 200";
@ -2777,20 +2813,61 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm
if (!query->value(9).isNull()) {
info->set_assigned_mod_name(query->value(9).toString().toStdString());
}
if (!query->value(10).isNull()) {
info->set_room_id(query->value(10).toInt());
info->set_resolved_by_name(query->value(10).toString().toStdString());
}
if (!query->value(11).isNull()) {
info->set_replay_id(query->value(11).toInt());
info->set_room_id(query->value(11).toInt());
}
if (!query->value(12).isNull()) {
info->set_replay_id(query->value(12).toInt());
}
info->set_reported_user_level(reportUserLevel(!query->value(13).isNull(), query->value(14).toInt()));
}
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdReportReopen(const Command_ReportReopen &cmd,
ResponseContainer & /*rc*/)
{
if (!sqlInterface->checkSql()) {
return Response::RespInternalError;
}
int reportId = cmd.report_id();
QString note = textFromStdString(cmd.reopen_note());
QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports "
"SET status = 'open', resolution_note = NULL, "
"resolution_time = NULL, resolved_by = NULL, assigned_to = NULL, "
"notified = 0 "
"WHERE id = :id AND status IN ('resolved', 'dismissed')");
query->bindValue(":id", reportId);
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespInternalError;
}
if (query->numRowsAffected() == 0) {
return Response::RespInvalidData;
}
QString auditDetails = QString("Report #%1 reopened").arg(reportId);
if (!note.isEmpty()) {
auditDetails += QString(". Reason: %1").arg(note.left(200));
}
sqlInterface->addAuditRecord(QString::number(reportId), this->getAddress(),
QString::fromStdString(userInfo->clientid()), "REPORT_REOPENED", auditDetails, true);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd,
ResponseContainer &rc)
{
@ -2806,17 +2883,19 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Com
// Columns: 0=id, 1=reporter_id, 2=reporter_name, 3=reported_user_name, 4=game_id,
// 5=category, 6=description, 7=created_at, 8=status,
// 9=resolution_note, 10=assigned_mod_name, 11=chat_log,
// 12=room_id, 13=resolution_time, 14=replay_id
QString queryStr = "SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, "
"r.category, r.description, r.created_at, r.status, "
"r.resolution_note, u.name AS assigned_mod_name, r.chat_log, r.room_id, "
"r.resolution_time, "
"(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) "
"AS replay_id "
"FROM {prefix}_reports r "
"LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "
"WHERE r.id = :id";
// 9=resolution_note, 10=assigned_mod_name, 11=resolved_by_name,
// 12=chat_log, 13=room_id, 14=resolution_time, 15=replay_id
QString queryStr =
"SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, "
"r.category, r.description, r.created_at, r.status, "
"r.resolution_note, u.name AS assigned_mod_name, ru.name AS resolved_by_name, r.chat_log, r.room_id, "
"r.resolution_time, "
"(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) "
"AS replay_id "
"FROM {prefix}_reports r "
"LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "
"LEFT JOIN {prefix}_users ru ON r.resolved_by = ru.id "
"WHERE r.id = :id";
QSqlQuery *query = sqlInterface->prepareQuery(queryStr);
query->bindValue(":id", reportId);
@ -2855,21 +2934,24 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Com
if (!query->value(10).isNull()) {
info->set_assigned_mod_name(query->value(10).toString().toStdString());
}
if (!query->value(11).isNull()) {
info->set_chat_log(query->value(11).toString().toStdString());
info->set_resolved_by_name(query->value(11).toString().toStdString());
}
if (!query->value(12).isNull()) {
info->set_room_id(query->value(12).toInt());
info->set_chat_log(query->value(12).toString().toStdString());
}
if (!query->value(13).isNull()) {
info->set_resolution_time(query->value(13).toDateTime().toSecsSinceEpoch());
info->set_room_id(query->value(13).toInt());
}
if (!query->value(14).isNull()) {
info->set_replay_id(query->value(14).toInt());
info->set_resolution_time(query->value(14).toDateTime().toSecsSinceEpoch());
}
if (!query->value(15).isNull()) {
info->set_replay_id(query->value(15).toInt());
}
QSqlQuery *commentQuery =

View file

@ -96,6 +96,7 @@ class Command_ReportAssign;
class Command_ReportDetails;
class Command_ReportList;
class Command_ReportMyList;
class Command_ReportReopen;
class Command_ReportResolve;
class Command_ReportStats;
class Command_ReportUserInfo;
@ -181,6 +182,7 @@ private:
Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &);
Response::ResponseCode cmdReportResolve(const Command_ReportResolve &cmd, ResponseContainer &);
Response::ResponseCode cmdReportReopen(const Command_ReportReopen &cmd, ResponseContainer &);
Response::ResponseCode cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc);
Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc);

View file

@ -2,109 +2,10 @@
# If a new test needs additional Qt modules, add them there — not in individual test CMakeLists.txt files.
enable_testing()
add_test(NAME dummy_test COMMAND dummy_test)
add_test(NAME expression_test COMMAND expression_test)
add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test)
add_test(NAME test_age_formatting COMMAND test_age_formatting)
add_test(NAME password_hash_test COMMAND password_hash_test)
add_test(NAME playmat_resolver_test COMMAND playmat_resolver_test)
add_test(NAME server_card_counter_test COMMAND server_card_counter_test)
add_test(NAME server_counter_test COMMAND server_counter_test)
add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test)
add_test(NAME server_developer_role_test COMMAND server_developer_role_test)
add_test(NAME server_game_join_test COMMAND server_game_join_test)
add_test(NAME warning_categories_test COMMAND warning_categories_test)
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
if(TARGET Qt6::lrelease)
add_test(NAME translation_loader_test COMMAND translation_loader_test)
endif()
add_test(NAME report_categories_test COMMAND report_categories_test)
add_test(NAME lag_monitor_test COMMAND lag_monitor_test)
add_test(NAME latency_tracker_test COMMAND latency_tracker_test)
add_test(NAME metrics_registry_test COMMAND metrics_registry_test)
add_test(NAME card_update_progress_test COMMAND card_update_progress_test)
add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test)
add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test)
set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15)
include(GoogleTest)
# Find GTest
add_executable(dummy_test dummy_test.cpp)
add_executable(expression_test expression_test.cpp)
add_executable(clamped_arithmetic_test clamped_arithmetic_test.cpp)
add_executable(test_age_formatting test_age_formatting.cpp)
add_executable(password_hash_test password_hash_test.cpp)
add_executable(playmat_resolver_test playmat_resolver_test.cpp)
add_executable(deck_hash_performance_test deck_hash_performance_test.cpp)
add_executable(server_card_counter_test server_card_counter_test.cpp)
add_executable(server_counter_test server_counter_test.cpp)
add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
add_executable(server_developer_role_test server_developer_role_test.cpp)
add_executable(server_game_join_test server_game_join_test.cpp)
add_executable(warning_categories_test warning_categories_test.cpp)
if(TARGET Qt6::lrelease)
add_executable(translation_loader_test translation_loader_test.cpp)
# QTranslator only accepts real .qm files, so the translation loader test gets one compiled from its
# own .ts fixture rather than a stub.
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
add_custom_command(
OUTPUT "${TRANSLATION_LOADER_TEST_QM}"
COMMAND Qt6::lrelease "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" -qm "${TRANSLATION_LOADER_TEST_QM}"
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts"
COMMENT "Compiling translation_loader_test.ts"
)
add_custom_target(translation_loader_test_qm DEPENDS "${TRANSLATION_LOADER_TEST_QM}")
add_dependencies(translation_loader_test translation_loader_test_qm)
target_compile_definitions(translation_loader_test PRIVATE TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}")
# A multi-config generator (MSBuild, Xcode) puts the executable in a per-config subdirectory while
# the .qm stays in the current binary directory, so copy the fixture next to the binary: that is the
# layout ABuildTreeFindsTheTranslationsNextToTheExecutable is about, and the only one it finds on
# those generators.
add_custom_command(
TARGET translation_loader_test
POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TRANSLATION_LOADER_TEST_QM}"
"$<TARGET_FILE_DIR:translation_loader_test>/"
VERBATIM
)
endif()
add_executable(report_categories_test report_categories_test.cpp)
add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp)
target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
add_executable(latency_tracker_test latency_tracker_test.cpp)
add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp)
add_executable(
card_update_progress_test ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_database_update/card_update_progress.cpp
card_update_progress_test.cpp
)
target_include_directories(card_update_progress_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
add_executable(
loader_local_matching_test
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${VERSION_STRING_CPP}
loader_local_matching_test.cpp
)
target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt.
if(WITH_CLIENT)
add_executable(
single_instance_manager_test ${CMAKE_SOURCE_DIR}/cockatrice/src/single_instance_manager.cpp
single_instance_manager_test.cpp
)
target_include_directories(single_instance_manager_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_include_directories(single_instance_manager_test PRIVATE ${CMAKE_SOURCE_DIR}/libcockatrice_utility)
add_test(NAME single_instance_manager_test COMMAND single_instance_manager_test)
endif()
find_package(GTest)
if(NOT GTEST_FOUND)
@ -129,93 +30,342 @@ if(NOT GTEST_FOUND)
target_include_directories(gtest INTERFACE "$<BUILD_INTERFACE:${CMAKE_BINARY_DIR}/gtest-src/include>")
set(GTEST_INCLUDE_DIRS "${CMAKE_BINARY_DIR}/gtest-src/include")
set(GTEST_BOTH_LIBRARIES gtest)
add_dependencies(dummy_test gtest)
add_dependencies(expression_test gtest)
add_dependencies(clamped_arithmetic_test gtest)
add_dependencies(test_age_formatting gtest)
add_dependencies(password_hash_test gtest)
add_dependencies(playmat_resolver_test gtest)
add_dependencies(deck_hash_performance_test gtest)
add_dependencies(server_card_counter_test gtest)
add_dependencies(server_counter_test gtest)
add_dependencies(server_rate_limiter_test gtest)
add_dependencies(server_developer_role_test gtest)
add_dependencies(server_game_join_test gtest)
add_dependencies(warning_categories_test gtest)
if(TARGET translation_loader_test)
add_dependencies(translation_loader_test gtest)
endif()
add_dependencies(report_categories_test gtest)
add_dependencies(lag_monitor_test gtest)
add_dependencies(latency_tracker_test gtest)
add_dependencies(metrics_registry_test gtest)
add_dependencies(card_update_progress_test gtest)
add_dependencies(loader_local_matching_test gtest)
add_dependencies(single_instance_manager_test gtest)
# gtest_main is not optional here. FindGTest sets GTEST_BOTH_LIBRARIES to both halves, so this
# has to match it or the two paths disagree about what provides main(). A test source that
# defines its own main() gets away with the omission; one that does not fails at link with
# `undefined reference to main` pointing at crt1.o, which names neither gtest nor this line.
set(GTEST_BOTH_LIBRARIES gtest gtest_main)
endif()
# Directory-scoped on purpose: the test subdirectories below add targets that include the
# fetched gtest headers and do not repeat this path themselves.
include_directories(${GTEST_INCLUDE_DIRS})
target_link_libraries(dummy_test Threads::Threads ${GTEST_BOTH_LIBRARIES})
target_link_libraries(expression_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(
clamped_arithmetic_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(
test_age_formatting libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(
password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(
playmat_resolver_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(
deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(
server_card_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(
server_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(
server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(
server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(
server_game_join_test libcockatrice_network_server_remote libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
target_link_libraries(
warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
if(TARGET translation_loader_test)
# --- Test registration ----------------------------------------------------------
#
# Every test target needs the same four steps: build the executable, depend on gtest when it
# was fetched from source, link the shared test libraries, and hand the cases to CTest. The
# `if(NOT GTEST_FOUND)` branch is the easy one to forget — omit it and the link fails with
# undefined gtest symbols — so it lives here once instead of being repeated per test.
#
# This also replaces `add_test(NAME x COMMAND x)`. Naming the binary as a single CTest entry
# hides which case failed and makes `ctest -R Suite.Case` impossible; discovery exposes each
# TEST() as its own CTest entry instead.
#
# Labels: exactly one per test, because gtest_discover_tests() cannot carry more than one.
# GoogleTestAddTests.cmake accumulates the generated script with `foreach(arg ${ARGN})`, so a
# label list serialises as bare `LABELS ci server` in the CTestTestfile, and CTest reads only
# the first value of a property and silently discards the rest. A label therefore classifies a
# test into exactly one group, and the three groups cover every selection worth making:
#
# ci the default hermetic set, so `ctest -LE bench` runs everything but benchmarks
# server servatrice-facing tests, so `ctest -L server` works
# bench the slow suites, kept out of the default run
#
# A second label is a configure-time FATAL_ERROR rather than a line CTest quietly reads as one.
# Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it
# composes with the group instead of competing for the single slot. NO_SANITIZE is such a flag.
#
# TIMEOUT is bounded per test because CTest's implicit default is 1500s: one hung socket wait
# would otherwise stall CI for 25 minutes.
#
# EXTRA_LIBS is appended after the shared libraries so a caller adding a static archive (zlib,
# lzma) keeps it last, which is where the linker needs it.
function(cockatrice_add_test)
cmake_parse_arguments(
PARSE_ARGV 0 ARG "MANUAL;NO_SANITIZE" "NAME" "SOURCES;LIBS;LABELS;DEFINITIONS;INCLUDE_DIRS;EXTRA_LIBS"
)
if(NOT ARG_NAME OR NOT ARG_SOURCES)
message(FATAL_ERROR "cockatrice_add_test requires at least NAME and SOURCES")
endif()
add_executable(${ARG_NAME} ${ARG_SOURCES})
if(ARG_INCLUDE_DIRS)
target_include_directories(${ARG_NAME} PRIVATE ${ARG_INCLUDE_DIRS})
endif()
if(ARG_DEFINITIONS)
target_compile_definitions(${ARG_NAME} PRIVATE ${ARG_DEFINITIONS})
endif()
target_link_libraries(
translation_loader_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
${ARG_NAME} ${ARG_LIBS} Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ${ARG_EXTRA_LIBS}
)
# Only needed when gtest was downloaded and add_subdirectory'd above. An imported gtest
# target already carries its own dependency, so this must stay conditional.
if(NOT GTEST_FOUND)
add_dependencies(${ARG_NAME} gtest gtest_main)
endif()
# MANUAL targets are built but deliberately kept out of ctest: benchmarks that need a card
# database, hit the network, or measure RAM. Leaving them unregistered keeps CI hermetic
# and fast. They still link gtest so they can use TEST() without a second code path.
if(ARG_MANUAL)
return()
endif()
# NO_SANITIZE marks a target the sanitizer leg cannot run. Excluded by not handing its cases
# to CTest at all, rather than by a label, so the exclusion cannot rot into a line CTest
# quietly reads as something else. The target still builds, and still runs on the other legs.
if(ARG_NO_SANITIZE AND COCKATRICE_SANITIZERS)
return()
endif()
# One label only, per the note above. Rejecting a second loudly beats emitting a
# CTestTestfile line that CTest will quietly read as one label.
list(LENGTH ARG_LABELS _label_count)
if(_label_count GREATER 1)
message(FATAL_ERROR "${ARG_NAME}: pass at most one label, got '${ARG_LABELS}'")
endif()
if(NOT ARG_LABELS)
set(ARG_LABELS ci)
endif()
# GoogleTestAddTests defaults DISCOVERY_TIMEOUT to 5s, which is ample for a plain debug
# binary (--gtest_list_tests costs 0.04s) but not for an instrumented one (~2.8s cold, and
# discovery runs several binaries concurrently behind a -j build). Left at the default, a
# sanitizer build fails at the *link* step with a bare "Process terminated due to timeout"
# and no hint that discovery, not the test, is what is slow.
if(COCKATRICE_SANITIZERS)
set(_discovery_timeout 300)
else()
set(_discovery_timeout 60)
endif()
gtest_discover_tests(
${ARG_NAME} DISCOVERY_MODE POST_BUILD DISCOVERY_TIMEOUT ${_discovery_timeout}
PROPERTIES LABELS "${ARG_LABELS}"
TIMEOUT 60
)
endfunction()
# Same wiring for targets that are built but deliberately kept out of ctest: benchmarks that
# need a card database, hit the network, or measure RAM. A separate name rather than a MANUAL
# flag spelled out at each call site, so "this is not in the CI suite" is visible in the CMake
# file. Note the unquoted expansions: an unset multi-value keyword expands to zero arguments,
# which is what lets this forward a caller's keywords verbatim without tripping the FATAL_ERROR
# guard above on an empty list.
function(cockatrice_add_manual_test)
cmake_parse_arguments(PARSE_ARGV 0 ARG "" "NAME" "SOURCES;LIBS;DEFINITIONS;INCLUDE_DIRS;EXTRA_LIBS")
cockatrice_add_test(
NAME
${ARG_NAME}
SOURCES
${ARG_SOURCES}
LIBS
${ARG_LIBS}
DEFINITIONS
${ARG_DEFINITIONS}
INCLUDE_DIRS
${ARG_INCLUDE_DIRS}
EXTRA_LIBS
${ARG_EXTRA_LIBS}
MANUAL
)
endfunction()
# --- Unit tests ------------------------------------------------------------------
cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(
NAME
playmat_resolver_test
SOURCES
playmat_resolver_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_utility
)
cockatrice_add_test(
NAME
server_rate_limiter_test
SOURCES
server_rate_limiter_test.cpp
LIBS
libcockatrice_utility
LABELS
server
)
cockatrice_add_test(
NAME
server_card_counter_test
SOURCES
server_card_counter_test.cpp
LIBS
libcockatrice_network
LABELS
server
)
cockatrice_add_test(
NAME
server_counter_test
SOURCES
server_counter_test.cpp
LIBS
libcockatrice_network
LABELS
server
)
cockatrice_add_test(
NAME
server_developer_role_test
NO_SANITIZE
SOURCES
server_developer_role_test.cpp
LIBS
libcockatrice_network
libcockatrice_rng
LABELS
server
)
cockatrice_add_test(
NAME
server_moderator_role_test
NO_SANITIZE
SOURCES
server_moderator_role_test.cpp
LIBS
libcockatrice_network
libcockatrice_rng
LABELS
server
)
cockatrice_add_test(
NAME
server_game_join_test
SOURCES
server_game_join_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)
cockatrice_add_test(
NAME
latency_tracker_test
SOURCES
latency_tracker_test.cpp
LIBS
libcockatrice_network
LABELS
server
)
cockatrice_add_test(
NAME
lag_monitor_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp
lag_monitor_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
metrics_registry_test
SOURCES
${CMAKE_SOURCE_DIR}/servatrice/src/metrics_registry.cpp
metrics_registry_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/servatrice/src
LABELS
server
)
cockatrice_add_test(
NAME
card_update_progress_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_database_update/card_update_progress.cpp
card_update_progress_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
loader_local_matching_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${VERSION_STRING_CPP}
loader_local_matching_test.cpp
LIBS
libcockatrice_settings
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test(
NAME
deck_hash_performance_test
SOURCES
deck_hash_performance_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_utility
LABELS
bench
)
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
if(TARGET Qt6::lrelease)
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
add_custom_command(
OUTPUT "${TRANSLATION_LOADER_TEST_QM}"
COMMAND Qt6::lrelease "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" -qm "${TRANSLATION_LOADER_TEST_QM}"
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts"
COMMENT "Compiling translation_loader_test.ts"
)
add_custom_target(translation_loader_test_qm DEPENDS "${TRANSLATION_LOADER_TEST_QM}")
cockatrice_add_test(
NAME
translation_loader_test
SOURCES
translation_loader_test.cpp
LIBS
libcockatrice_utility
DEFINITIONS
TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}"
)
add_dependencies(translation_loader_test translation_loader_test_qm)
# A multi-config generator (MSBuild, Xcode) puts the executable in a per-config subdirectory while
# the .qm stays in the current binary directory, so copy the fixture next to the binary: that is the
# layout ABuildTreeFindsTheTranslationsNextToTheExecutable is about, and the only one it finds on
# those generators.
add_custom_command(
TARGET translation_loader_test
POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TRANSLATION_LOADER_TEST_QM}"
"$<TARGET_FILE_DIR:translation_loader_test>/"
VERBATIM
)
endif()
target_link_libraries(
report_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(
latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src)
target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES})
target_link_libraries(card_update_progress_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
target_link_libraries(
loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt.
if(WITH_CLIENT)
target_link_libraries(single_instance_manager_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
cockatrice_add_test(
NAME
single_instance_manager_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/single_instance_manager.cpp
single_instance_manager_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
${CMAKE_SOURCE_DIR}/libcockatrice_utility
)
endif()
add_subdirectory(card_zone_algorithms)

View file

@ -1,15 +1,4 @@
add_executable(card_zone_algorithms_test card_zone_algorithms_test.cpp)
target_include_directories(card_zone_algorithms_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones)
target_link_libraries(
card_zone_algorithms_test
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
cockatrice_add_test(
NAME card_zone_algorithms_test SOURCES card_zone_algorithms_test.cpp INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones
)
add_test(NAME card_zone_algorithms_test COMMAND card_zone_algorithms_test)
if(NOT GTEST_FOUND)
add_dependencies(card_zone_algorithms_test gtest)
endif()

View file

@ -9,61 +9,46 @@ add_definitions("-DCARDDB_DATADIR=\"${CMAKE_CURRENT_SOURCE_DIR}/data/\"")
# ------------------------
# Card Database Test
# ------------------------
add_executable(carddatabase_test ${MOCKS_SOURCES} ${VERSION_STRING_CPP} carddatabase_test.cpp mocks.cpp)
target_link_libraries(
cockatrice_add_test(
NAME
carddatabase_test
PRIVATE libcockatrice_card
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
SOURCES
${VERSION_STRING_CPP}
carddatabase_test.cpp
mocks.cpp
LIBS
libcockatrice_card
)
add_test(NAME carddatabase_test COMMAND carddatabase_test)
# ------------------------
# Load Benchmark (manual, not run in CI)
# ------------------------
add_executable(load_benchmark ${MOCKS_SOURCES} ${VERSION_STRING_CPP} load_benchmark.cpp mocks.cpp)
target_link_libraries(
load_benchmark
PRIVATE libcockatrice_card
PRIVATE libcockatrice_models
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
)
if(NOT GTEST_FOUND)
add_dependencies(load_benchmark gtest)
endif()
# ------------------------
# Filter String Test
# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt)
# ------------------------
if(WITH_ORACLE OR WITH_CLIENT)
add_executable(filter_string_test ${MOCKS_SOURCES} ${VERSION_STRING_CPP} filter_string_test.cpp mocks.cpp)
target_link_libraries(
cockatrice_add_test(
NAME
filter_string_test
PRIVATE libcockatrice_filters
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
SOURCES
${VERSION_STRING_CPP}
filter_string_test.cpp
mocks.cpp
LIBS
libcockatrice_filters
)
add_test(NAME filter_string_test COMMAND filter_string_test)
if(NOT GTEST_FOUND)
add_dependencies(filter_string_test gtest)
endif()
endif()
# ------------------------
# Dependencies on gtest
# Load Benchmark (manual, not run in CI)
# ------------------------
if(NOT GTEST_FOUND)
add_dependencies(carddatabase_test gtest)
endif()
cockatrice_add_manual_test(
NAME
load_benchmark
SOURCES
${VERSION_STRING_CPP}
load_benchmark.cpp
mocks.cpp
LIBS
libcockatrice_card
libcockatrice_models
MANUAL
)

View file

@ -1,33 +1,23 @@
add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp)
if(NOT GTEST_FOUND)
add_dependencies(deck_list_model_custom_zones_test gtest)
endif()
target_link_libraries(
cockatrice_add_test(
NAME
deck_list_model_custom_zones_test
SOURCES
${VERSION_STRING_CPP}
deck_list_model_custom_zones_test.cpp
LIBS
libcockatrice_models
libcockatrice_card
libcockatrice_deck_list
Threads::Threads
${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test)
add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp)
if(NOT GTEST_FOUND)
add_dependencies(deck_list_model_zone_integration_test gtest)
endif()
target_link_libraries(
cockatrice_add_test(
NAME
deck_list_model_zone_integration_test
SOURCES
${VERSION_STRING_CPP}
deck_list_model_zone_integration_test.cpp
LIBS
libcockatrice_models
libcockatrice_card
libcockatrice_deck_list
Threads::Threads
${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test)

View file

@ -1,10 +1 @@
add_executable(deck_list_zones_test deck_list_zones_test.cpp)
if(NOT GTEST_FOUND)
add_dependencies(deck_list_zones_test gtest)
endif()
target_link_libraries(
deck_list_zones_test libcockatrice_deck_list Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
add_test(NAME deck_list_zones_test COMMAND deck_list_zones_test)
cockatrice_add_test(NAME deck_list_zones_test SOURCES deck_list_zones_test.cpp LIBS libcockatrice_deck_list)

View file

@ -1,20 +0,0 @@
#include "gtest/gtest.h"
#include <string>
namespace
{
class FooTest : public ::testing::Test
{
};
TEST(DummyTest, Works)
{
ASSERT_EQ(1, 1) << "One is not equal to one";
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -1,12 +1,13 @@
add_definitions("-DCARDDB_DATADIR=\"${CMAKE_CURRENT_SOURCE_DIR}/data/\"")
add_executable(loading_from_clipboard_test ${VERSION_STRING_CPP} clipboard_testing.cpp loading_from_clipboard_test.cpp)
if(NOT GTEST_FOUND)
add_dependencies(loading_from_clipboard_test gtest)
endif()
target_link_libraries(
loading_from_clipboard_test libcockatrice_deck_list libcockatrice_card Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
cockatrice_add_test(
NAME
loading_from_clipboard_test
SOURCES
${VERSION_STRING_CPP}
clipboard_testing.cpp
loading_from_clipboard_test.cpp
LIBS
libcockatrice_deck_list
libcockatrice_card
)
add_test(NAME loading_from_clipboard_test COMMAND loading_from_clipboard_test)

View file

@ -1,16 +1,11 @@
add_executable(reverse_card_move_test reverse_card_move_test.cpp)
if(NOT GTEST_FOUND)
add_dependencies(reverse_card_move_test gtest)
endif()
target_link_libraries(
cockatrice_add_test(
NAME
reverse_card_move_test
PRIVATE libcockatrice_network_server_remote
PRIVATE libcockatrice_rng
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
SOURCES
reverse_card_move_test.cpp
LIBS
libcockatrice_network_server_remote
libcockatrice_rng
LABELS
server
)
add_test(NAME reverse_card_move_test COMMAND reverse_card_move_test)

View file

@ -1,37 +1,32 @@
add_executable(parse_cipt_test ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp)
if(NOT GTEST_FOUND)
add_dependencies(parse_cipt_test gtest)
endif()
target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
add_test(NAME parse_cipt_test COMMAND parse_cipt_test)
cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp)
# Oracle importer unit tests
add_executable(
oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp
cockatrice_add_test(
NAME
oracle_importer_test
SOURCES
${VERSION_STRING_CPP}
../../oracle/src/oracleimporter.cpp
../../oracle/src/parsehelpers.cpp
../../oracle/src/raw_json_scanner.cpp
oracle_importer_test.cpp
LIBS
libcockatrice_card
libcockatrice_interfaces
)
if(NOT GTEST_FOUND)
add_dependencies(oracle_importer_test gtest)
endif()
target_link_libraries(
oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
)
add_test(NAME oracle_importer_test COMMAND oracle_importer_test)
# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark)
# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark
# can download and decompress whatever AllPrintings format the default URL selects.
# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also
# reach the two unit tests above. That is harmless: those macros gate code only in
# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles.
find_package(ZLIB)
if(ZLIB_FOUND)
add_definitions("-DHAS_ZLIB")
set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp)
cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES})
cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES})
set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES})
include_directories(${ZLIB_INCLUDE_DIRS})
else()
@ -42,28 +37,28 @@ find_package(LibLZMA)
if(LIBLZMA_FOUND)
add_definitions("-DHAS_LZMA")
list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp)
cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp)
cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp)
list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES})
include_directories(${LIBLZMA_INCLUDE_DIRS})
else()
message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled")
endif()
add_executable(
oracle_importer_benchmark_test
${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES}
)
if(NOT GTEST_FOUND)
add_dependencies(oracle_importer_benchmark_test gtest)
endif()
target_link_libraries(
cockatrice_add_manual_test(
NAME
oracle_importer_benchmark_test
SOURCES
${VERSION_STRING_CPP}
../../oracle/src/oracleimporter.cpp
../../oracle/src/parsehelpers.cpp
../../oracle/src/raw_json_scanner.cpp
oracle_importer_benchmark_test.cpp
${_ORACLE_BENCH_EXTRA_SOURCES}
LIBS
libcockatrice_card
libcockatrice_interfaces
Threads::Threads
${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
EXTRA_LIBS
${_ORACLE_BENCH_EXTRA_LIBRARIES}
MANUAL
)

View file

@ -1,8 +1,10 @@
# Manual benchmark hitting the real card image hosts. Not registered with
# add_test(): it requires a cards.xml, touches the network for minutes at a
# time, and needs network access. Build it explicitly and run by hand.
add_executable(
# Manual benchmark hitting the real card image hosts. Not registered with CTest: it requires a
# cards.xml, touches the network for minutes at a time, and needs network access. Build it
# explicitly and run by hand.
cockatrice_add_manual_test(
NAME
picture_loader_benchmark_test
SOURCES
${VERSION_STRING_CPP}
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
../../cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp
@ -11,16 +13,12 @@ add_executable(
../../cockatrice/src/client/settings/cache_settings.h
picture_loader_benchmark.cpp
settings_cache_mock.cpp
)
target_include_directories(picture_loader_benchmark_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_link_libraries(
picture_loader_benchmark_test
LIBS
libcockatrice_card
libcockatrice_settings
libcockatrice_interfaces
libcockatrice_utility
Threads::Threads
${TEST_QT_MODULES}
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
MANUAL
)

View file

@ -0,0 +1,156 @@
/** @file server_moderator_role_test.cpp
* @brief Tests for the moderator staff role authorization and dispatch.
* @ingroup Tests
*/
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/server_message.pb.h"
#include <QString>
#include <gtest/gtest.h>
#include <libcockatrice/network/server/remote/server.h>
#include <libcockatrice/network/server/remote/server_protocolhandler.h>
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <stdint.h>
#include <string>
// The server_remote library references the global RNG, which is normally
// defined by the servatrice/client executable main(). Provide a stub so the
// unit test can link against it.
RNG_Abstract *rng = nullptr;
namespace
{
class TestModeratorHandler : public Server_ProtocolHandler
{
public:
explicit TestModeratorHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr)
{
}
QString getAddress() const override
{
return {};
}
QString getConnectionType() const override
{
return {};
}
// Buffer the last response code sent to the client so tests can assert on
// the outcome of processCommandContainer().
Response::ResponseCode lastResponseCode = Response::RespNothing;
int dispatchCount = 0;
protected:
void transmitProtocolItem(const ServerMessage &item) override
{
if (item.message_type() == ServerMessage::RESPONSE) {
lastResponseCode = item.response().response_code();
}
}
Response::ResponseCode
processExtendedModeratorCommand(int cmdType, const ModeratorCommand &, ResponseContainer &) override
{
++dispatchCount;
// Fail closed for anything not explicitly handled.
if (cmdType != ModeratorCommand::REPORT_REOPEN) {
return Response::RespFunctionNotAllowed;
}
return Response::RespOk;
}
};
class ModeratorRoleTest : public ::testing::Test
{
protected:
Server server;
TestModeratorHandler handler{&server};
void setUserLevel(uint32_t level)
{
ServerInfo_User user;
user.set_user_level(level);
handler.setUserInfo(user);
}
};
TEST_F(ModeratorRoleTest, RejectsWhenNotLoggedIn)
{
CommandContainer cont;
cont.add_moderator_command();
handler.processCommandContainer(cont);
EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded);
EXPECT_EQ(handler.dispatchCount, 0);
}
TEST_F(ModeratorRoleTest, RejectsPlainUser)
{
setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered);
CommandContainer cont;
cont.add_moderator_command();
handler.processCommandContainer(cont);
EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded);
EXPECT_EQ(handler.dispatchCount, 0);
}
TEST_F(ModeratorRoleTest, DispatchesToModeratorCommandForModerator)
{
setUserLevel(ServerInfo_User::IsModerator);
CommandContainer cont;
ModeratorCommand *cmd = cont.add_moderator_command();
cmd->MutableExtension(Command_ReportReopen::ext);
handler.processCommandContainer(cont);
EXPECT_EQ(handler.lastResponseCode, Response::RespOk);
EXPECT_EQ(handler.dispatchCount, 1);
}
TEST_F(ModeratorRoleTest, DispatchesToModeratorCommandForAdmin)
{
setUserLevel(ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin);
CommandContainer cont;
ModeratorCommand *cmd = cont.add_moderator_command();
cmd->MutableExtension(Command_ReportReopen::ext);
handler.processCommandContainer(cont);
EXPECT_EQ(handler.lastResponseCode, Response::RespOk);
EXPECT_EQ(handler.dispatchCount, 1);
}
TEST_F(ModeratorRoleTest, RejectsDeveloperThatIsNotModerator)
{
setUserLevel(ServerInfo_User::IsDeveloper);
CommandContainer cont;
cont.add_moderator_command();
handler.processCommandContainer(cont);
EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded);
EXPECT_EQ(handler.dispatchCount, 0);
}
TEST_F(ModeratorRoleTest, FailClosedForUnknownModeratorCommand)
{
setUserLevel(ServerInfo_User::IsModerator);
CommandContainer cont;
cont.add_moderator_command(); // no extension set -> getPbExtension() returns -1
handler.processCommandContainer(cont);
EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed);
EXPECT_EQ(handler.dispatchCount, 1);
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -1,26 +1,14 @@
add_executable(settings_manager_test settings_manager_test.cpp)
add_executable(settings_defaults_test settings_defaults_test.cpp)
add_executable(settings_migration_test settings_migration_test.cpp)
cockatrice_add_test(NAME settings_manager_test SOURCES settings_manager_test.cpp LIBS libcockatrice_settings)
target_link_libraries(
settings_manager_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
cockatrice_add_test(
NAME
settings_defaults_test
SOURCES
settings_defaults_test.cpp
LIBS
libcockatrice_settings
DEFINITIONS
SETTINGS_UNIT_TEST
)
target_link_libraries(
settings_defaults_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_compile_definitions(settings_defaults_test PRIVATE SETTINGS_UNIT_TEST)
target_link_libraries(
settings_migration_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
add_test(NAME settings_manager_test COMMAND settings_manager_test)
add_test(NAME settings_defaults_test COMMAND settings_defaults_test)
add_test(NAME settings_migration_test COMMAND settings_migration_test)
if(NOT GTEST_FOUND)
add_dependencies(settings_manager_test gtest)
add_dependencies(settings_defaults_test gtest)
add_dependencies(settings_migration_test gtest)
endif()
cockatrice_add_test(NAME settings_migration_test SOURCES settings_migration_test.cpp LIBS libcockatrice_settings)

View file

@ -1,6 +1,7 @@
#include "libcockatrice/card/card_localization.h"
#include "gtest/gtest.h"
#include <QDir>
#include <QHash>
#include <QList>
#include <QString>
@ -15,6 +16,7 @@
#include <libcockatrice/settings/game_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/sound_settings.h>
#include <libcockatrice/settings/tabs_settings.h>
@ -695,6 +697,55 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_SetAndGet)
ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), false);
}
TEST_F(SettingsDefaultsTest, Paths_AppDirWarningAcknowledged_Default)
{
PathsSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getAppDirWarningAcknowledged(), false);
}
TEST_F(SettingsDefaultsTest, Paths_AppDirWarningAcknowledged_SetAndGet)
{
PathsSettings s(settingsPath, nullptr);
s.setAppDirWarningAcknowledged(true);
ASSERT_EQ(s.getAppDirWarningAcknowledged(), true);
}
TEST_F(SettingsDefaultsTest, Paths_PathsInsideDir_DetectsNestedAndIgnoresSiblings)
{
PathsSettings s(settingsPath, nullptr);
const QString base = QDir::cleanPath(settingsPath);
s.setDeckPath(base + "/decks/");
s.setPicsPath(base + "/pics");
s.setReplaysPath(base + "2"); // sibling sharing the name prefix, not nested
const QStringList offenders = s.pathsInsideDir(base);
ASSERT_EQ(offenders.size(), 2);
ASSERT_TRUE(offenders.contains(base + "/decks"));
ASSERT_TRUE(offenders.contains(base + "/pics"));
}
TEST_F(SettingsDefaultsTest, Paths_PathsInsideDir_EmptyWhenNothingMatches)
{
PathsSettings s(settingsPath, nullptr);
s.setDeckPath("/somewhere/else/decks");
ASSERT_TRUE(s.pathsInsideDir(QDir::cleanPath(settingsPath)).isEmpty());
}
TEST_F(SettingsDefaultsTest, Paths_PathsInsideDir_MatchesExactBaseAndNormalizesDots)
{
PathsSettings s(settingsPath, nullptr);
const QString base = QDir::cleanPath(settingsPath);
s.setCustomPicsPath(base); // exactly the base directory
s.setThemesPath(base + "/pics/../decks"); // needs cleanPath normalization
const QStringList offenders = s.pathsInsideDir(base);
ASSERT_EQ(offenders.size(), 2);
ASSERT_TRUE(offenders.contains(base));
ASSERT_TRUE(offenders.contains(base + "/decks"));
}
} // namespace
int main(int argc, char **argv)

View file

@ -1,5 +1,7 @@
add_executable(
cockatrice_add_test(
NAME
theme_migration_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp
@ -9,22 +11,11 @@ add_executable(
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${VERSION_STRING_CPP}
theme_migration_test.cpp
)
target_include_directories(theme_migration_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_link_libraries(
theme_migration_test
LIBS
libcockatrice_settings
libcockatrice_network
libcockatrice_network_server_remote
libcockatrice_protocol
Threads::Threads
${GTEST_BOTH_LIBRARIES}
${TEST_QT_MODULES}
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
add_test(NAME theme_migration_test COMMAND theme_migration_test)
if(NOT GTEST_FOUND)
add_dependencies(theme_migration_test gtest)
endif()