Compare commits

..

17 commits

Author SHA1 Message Date
Lukas Brübach
159485bc46 [Fonts] Retire max font size setting into theme CardText min/max range
The per-card text size lives in the theme now, so a theme can pick the size
it was designed for instead of every user overriding it globally.

- CardText stores family + max point size; CardTextMinSize stores the
  overlay shrink-to-fit floor (omitted from fonts.toml when 1).
- ThemeManager::cardTextMaxSize()/cardTextMinSize() resolve the range with
  the retired defaults (12 max, 1 min), and cardTextMaxSize() falls back to
  the CardText role size that #7541 made live.
- The overlay widget starts at the explicit size when one is set (deck
  preview), else the theme max, and shrinks down to the theme floor.
- The appearance page's "Maximum font size" spin is gone; the range is
  edited in the theme's font grid, whose min/max spins now keep the saved
  range consistent with each other.
- Migration: appearance/maxFontSize is seeded into every user theme folder
  and into the active theme's folder (created when the theme is a shipped
  one that only exists in the system directory). The retired
  game/maxfontsize -> appearance/maxFontSize mapping stays, so users still
  on global.ini keep their value through the chain.
2026-11-03 18:22:40 +01:00
Lukas Brübach
45ec061e4e [Fonts] Polish font editor accessibility and note text 2026-11-03 18:22:40 +01:00
Lukas Brübach
97ae1b93db [Fonts] Keep the DeckList fallback on the application font
DeckList is read by the deck editor headers, the filter tree and the deck
view too, not only by the two surfaces that hardcoded Serif. Resolving the
unset role to Serif in appFont() made every user without a fonts.toml see
Serif outside the deck list.

- appFont(DeckList) falls back to the application font again.
- ThemeManager::deckListFont() resolves the role and keeps the Serif
  fallback for the two historical sites: the deck view zone headers and
  the printed deck list.
- DeckView no longer repaints its viewport on fontsChanged; touching the
  scene items already schedules the repaint in every attached view.
2026-11-03 18:22:40 +01:00
Lukas Brübach
17061980cc [Fonts] Restore DeckList Serif default and live-update all role readers 2026-11-03 18:22:40 +01:00
Lukas Brübach
ad860eb932 [Fonts] Fix font preview leaks and reuse default-font loader 2026-11-03 18:22:39 +01:00
Lukas Brübach
28c200e950 [Fonts] Fix pixel sizes and game overlay fonts from review
- Set the sizes the stylesheets carried with setPixelSize() rather than
  setPointSize(): home buttons (34), banner (24), home group box (20),
  the placeholder labels (14), the analytics drag handle (10), the EDHREC
  navigation buttons (24), the user info popup (11/14/11/10/11) and the
  game overlays (14/16/12). Points at 96 DPI were ~33% larger.
- The drag/total counters and tally columns keep the monospace family the
  stylesheets set, so digit widths stop changing as counts tick and
  white-space: pre stays aligned.
- applyDeckListFont() drops the cached tally rows before re-measuring, so
  previewing a larger DeckList font no longer leaves the tally clipped.
- Tally labels and the constructor/fontsChanged pairs in the shared-deck
  preview, quick setup, analytics panel, home box and public decks build
  their font once in a local lambda instead of twice.
2026-11-03 18:22:39 +01:00
Lukas Brübach
60f7710d9f [Fonts] Drop unused lambda captures in font-refresh handlers 2026-11-03 18:22:39 +01:00
Lukas Brübach
d26a7b213f [Fonts] Migrate DeckList and InterfaceDefault surfaces to theme roles 2026-11-03 18:22:39 +01:00
Lukas Brübach
a7e151d871 [Fonts] Keep CardText call sites on the app font and fix px sizes
- CardText's unset fallback is qApp->font() again: only the three counter
  painters (paintNumberEllipse, GeneralCounter, PlayerCounter) were Serif
  before the role existed, and they now ask for it through
  ThemeManager::cardTextFont(). Card names, player names, counters, the
  overlay, the Archidekt and EDHREC labels and the completer delegate keep the
  app font they used before.
- Use setPixelSize for the EDHREC card name (20px) and commander name (28px)
  and details (16px) sizes: the stylesheets said px, and setPointSize made
  them about a third larger.
2026-11-03 18:22:39 +01:00
Lukas Brübach
2593824e93 [Fonts] Migrate CardText surfaces to theme font role 2026-11-03 18:22:39 +01:00
Lukas Brübach
4e78edf794 [Fonts] Keep ChatView on the app font and share the report log font
- ChatLog's unset fallback is qApp->font() again: ChatView never set a font
  before the role existed, so an unset role must not switch chat and the game
  log to monospace at the system GeneralFont size.
- Move the monospace fallback the report dialogs used before into
  report_utils::reportLogFont(), which returns the theme's ChatLog role when it
  is set and the old monospace otherwise. tab_report, dlg_my_reports and
  dlg_report_user now share it, so they cannot drift and they honour a themed
  size instead of overriding it.
2026-11-03 18:22:39 +01:00
Lukas Brübach
1bdd43d921 [Fonts] Migrate ChatLog surfaces to theme font role 2026-11-03 18:22:39 +01:00
Lukas Brübach
77ed6550f9 [Fonts] Make the editor's fallback and Reset preview-independent
- Add ThemeManager::defaultAppFont(), which resolves an unset role against the
  startup font instead of currentFonts, and use it in FontGridWidget::loadFonts
  so the grid never shows the live preview as the theme's default.
- Reload the live theme in PaletteEditorDialog::onReset before re-reading the
  grid, so Reset restores the saved fonts before loadFonts resolves fallbacks.
2026-11-03 18:22:39 +01:00
Lukas Brübach
b57cd77986 [Fonts] Add per-purpose font section to palette editor 2026-11-03 18:22:39 +01:00
Lukas Brübach
4a60b2fde2 [Fonts] Restore the startup font when a config has no InterfaceDefault
- Capture the untouched application font next to the default palette and use
  it in previewFonts' else branch, so a config without InterfaceDefault
  restores the startup font instead of leaving the last preview applied.
- Drop the unpolish/polish loop from previewFonts: QApplication::setFont
  already notifies every widget, and the loop ran on every editor auto-apply.
2026-11-03 18:22:39 +01:00
Lukas Brübach
134c830d42 [Fonts] Wire FontConfig into ThemeManager with appFont and preview 2026-11-03 18:22:39 +01:00
Lukas Brübach
d34bb81884 [Fonts] Add AppFont roles and FontConfig fonts.toml round-trip 2026-11-03 18:22:32 +01:00
78 changed files with 330 additions and 3148 deletions

View file

@ -175,9 +175,9 @@ fi
if [[ $MAKE_SANITIZE ]]; then
flags+=("-DENABLE_SANITIZERS=ON")
else
# Every leg states the flag explicitly rather than relying on the CMake
# default, so a build directory reused from a sanitized configuration can
# never leak libasan/libubsan into a shipped binary.
# 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

View file

@ -33,18 +33,10 @@ on:
- 'CMakeLists.txt'
- 'vcpkg.json'
- 'vcpkg' # needed to match submodule bumps (gitlink)
# The legs the pull-request matrix leaves out run here instead, so the legacy distros and
# the slow macOS runner stay covered without gating a pull request on them. Tags still
# build every leg, because a tag is what ships.
schedule:
- cron: '23 3 * * *'
# Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release)
concurrency:
# The event name is part of the group because a nightly run and a push run on master share
# refs/heads/master but must not cancel each other: the nightly would drop the push's vcpkg
# import, and a merge landing during the nightly would drop that night's coverage.
group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
group: "${{ github.workflow }} @ ${{ github.ref_name }}"
cancel-in-progress: ${{ github.ref_type != 'tag' }}
jobs:
@ -149,7 +141,6 @@ jobs:
allow-failure: yes
package: skip # We are packaged in Arch already
debug_test: yes # see the note on Fedora 44 below
- distro: Servatrice_Debian
version: 12
@ -158,47 +149,59 @@ jobs:
server_only: yes
test: skip
- distro: Debian
version: 12
package: DEB
test: skip # Running tests on all distros is superfluous
- distro: Debian
version: 13
package: DEB
- distro: Fedora
version: 43
package: RPM
test: skip # Running tests on all distros is superfluous
- distro: Fedora
version: 44
package: RPM
# Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions
# in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug:
# only the Debug configuration passes -Werror, so these two are the legs where a
# new warning in either compiler still fails the build.
debug_test: yes
- distro: Ubuntu
version: 24.04
package: DEB
test: skip # Running tests on all distros is superfluous
- distro: Ubuntu
version: 26.04
package: DEB
test: skip # The sanitizer leg below runs this distro's suite
# 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.
#
# It packages nothing. A sanitized Debug build is not something to ship, and
# carrying the package build here meant 18 minutes of Release compile queued
# behind the instrumented one in a single job. The leg above publishes this
# distro's DEB.
# 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: sanitize # separate image and ccache, same distro
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
sanitize: yes
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure
# The nightly legs live in build-linux-nightly below. A leg is in exactly one of the two
# matrices, so nothing that reviews a change waits for a distro nobody is changing for.
if: github.event_name != 'schedule'
runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
timeout-minutes: 70
@ -209,18 +212,8 @@ jobs:
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
# Which build the suite runs in, decided once per leg here so the steps' if: conditions
# and their shells cannot drift apart. A step's own env is not visible to its if:, which
# is why these live at job level rather than on the release step.
# DEBUG_TEST the instrumented legs (symbols and instrumentation matter) and the two
# newest distro toolchains, which only fail on a warning in Debug.
# RUN_TESTS every other leg that tests runs the suite against the release build it may
# also package, so testing and packaging cost one compile instead of two.
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
# Shared with build-linux-nightly so the two leg sets run the same steps.
steps: &linux-steps
steps:
- name: "Checkout"
uses: actions/checkout@v7
@ -235,11 +228,8 @@ jobs:
shell: bash
run: source .ci/docker.sh --build
# The instrumented legs and the two newest distro toolchains build and test Debug; every
# other leg runs its suite against the release build below, so testing and packaging cost
# one compile instead of two.
- name: "Build debug and test"
if: env.DEBUG_TEST == 'true'
if: matrix.test != 'skip'
shell: bash
env:
sanitize: '${{ matrix.sanitize }}'
@ -279,23 +269,15 @@ jobs:
path: coverage/
if-no-files-found: warn
# One build serves both purposes: the suite runs against the same configuration that
# gets packaged, so a packaging leg no longer pays for a full Debug compile it then
# throws away. macOS and Windows have always tested their release build.
#
# Instrumented and debug-test legs reach this step only when they also package; their
# suite already ran in the debug step above, so the tests are not repeated here.
- name: "Build release, test and package"
- name: "Build release package"
id: build
if: matrix.package != 'skip' || env.RUN_TESTS == 'true'
if: matrix.package != 'skip'
shell: bash
env:
SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}'
package: '${{ matrix.package }}'
server_only: '${{ matrix.server_only }}'
# Only a leg that packages builds the shipping configuration; a release build that
# only runs tests stays free of the crash reporting SDK.
WITH_CRASH_REPORTING: ${{ matrix.package != 'skip' && 'ON' || '' }}
WITH_CRASH_REPORTING: 'ON'
# Tag builds only: artifacts from master pushes and PR branches must not report to
# Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
# configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN
@ -305,7 +287,6 @@ jobs:
source .ci/docker.sh
args=()
[[ $server_only == yes ]] && args+=(--no-client)
[[ $RUN_TESTS == true ]] && args+=(--test)
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
@ -315,14 +296,7 @@ jobs:
# configures, and empty keeps the mock fallback.
export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN"
# --package takes an optional type, so the flag and its argument have to travel
# together or the type would become the next flag.
package_args=()
[[ -n $package && $package != skip ]] && package_args+=(--package "$package")
# compile.sh runs the tests before the package target, so a failing suite stops the
# leg before anything is built into an installer.
RUN --server --release "${package_args[@]}" "${args[@]}"
RUN --server --release --package "$package" "${args[@]}"
- name: "Install sentry-cli"
if: matrix.package != 'skip' && needs.configure.outputs.tag != null
@ -424,72 +398,30 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
build-linux-nightly:
strategy:
fail-fast: false
matrix:
include:
- distro: Debian
version: 12 # previous stable, still covered by the nightly
package: DEB
test: skip # Running tests on all distros is superfluous
- distro: Fedora
version: 43 # previous release, still covered by the nightly
package: RPM
test: skip # Running tests on all distros is superfluous
- distro: Ubuntu
version: 24.04 # previous LTS, still covered by the nightly
package: DEB
test: skip # Running tests on all distros is superfluous
# The one leg that runs instrumented for line coverage and publishes the report as
# a workflow artifact. It stays 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.
#
# It moved here from build-linux: gcovr.cfg sets no fail_under, so the leg cannot
# fail a build, and nothing consumes the report per change, which made it an
# artifact nobody read on every pull request. On the default branch it becomes a
# trend instead. Image and ccache keys come from distro, version and variant, so
# they are unchanged and the warm cache that leg built still matches.
- distro: Ubuntu
version: 26.04
variant: cover # separate image and ccache, same distro
coverage: yes
package: skip
# A report must not redden the nightly run: vcpkg-cache-import only imports the
# archives of a run whose conclusion is success.
allow-failure: yes
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure
# A nightly and a tag build cover the legs a pull request or a merge does not wait for:
# the previous release of each distro, and the one instrument that only reports.
if: github.event_name == 'schedule' || github.ref_type == 'tag'
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 }}${{ 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: *linux-steps
build-vcpkg:
strategy:
fail-fast: false
matrix:
include:
- os: macOS
target: 13 # EOL 2025-09-15
runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
# macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
override_target: 13
package_suffix: "-macOS13_Intel"
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Intel
type: Release
use_ccache: 1
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.3"
- os: macOS
target: 14 # EOL 2026-??
runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
@ -565,16 +497,13 @@ jobs:
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: [configure, vcpkg-baseline]
# The nightly leg lives in build-vcpkg-nightly below, next to the Linux nightly legs.
if: github.event_name != 'schedule'
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
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
# Shared with build-vcpkg-nightly so the two leg sets run the same steps.
steps: &vcpkg-steps
steps:
- name: "Checkout"
uses: actions/checkout@v7
with:
@ -1049,42 +978,6 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
build-vcpkg-nightly:
strategy:
fail-fast: false
matrix:
include:
- os: macOS
target: 13 # EOL 2025-09-15
runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
# macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
override_target: 13
package_suffix: "-macOS13_Intel"
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Intel
type: Release
use_ccache: 1
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
xcode: "26.3"
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure
# The EOL target on the slowest runner is too slow to gate a change, so it runs in the
# nightly and on tags only, where the vcpkg cache import also has to accept schedule runs.
if: github.event_name == 'schedule' || github.ref_type == 'tag'
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
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
steps: *vcpkg-steps
prune-caches:
name: Prune caches
# Only master writes caches at all, so only master may clean them up too
@ -1103,9 +996,8 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ github.token }}
# Must list every vcpkg_scope of the build-vcpkg and build-vcpkg-nightly
# matrices above, or the entries of a scope that is still in use get deleted
# on the next run.
# Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of
# a scope that is still in use get deleted on the next run.
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \

View file

@ -4,14 +4,12 @@ name: Import vcpkg cache
# rebuilds protobuf, openssl and abseil from source and saves them into the shared
# cache, where every later run can restore them.
#
# Only master pushes and the nightly schedule run on master are imported here: the
# collect step, keys.txt and the zip contents all come from the workflow run being
# imported, so importing from an unmerged pull request would let anyone who can push
# a branch — or a bot actor such as Dependabot, whose PRs are same-repo — promote
# arbitrary binaries into the scope that master and tag builds restore and link into
# signed releases. Forks build untrusted code and must never reach that scope either.
# A scheduled run is the default branch at the commit a master push already built, so
# it carries the same trust as a push.
# Only master pushes are imported here: the collect step, keys.txt and the zip
# contents all come from the workflow run being imported, so importing from an
# unmerged pull request would let anyone who can push a branch — or a bot actor
# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into
# the scope that master and tag builds restore and link into signed releases.
# Forks build untrusted code and must never reach that scope either.
#
# repository_dispatch is not used on purpose: it would also resolve to the default branch,
# but it counts as a low-trust trigger and may only write there when it declares a
@ -40,7 +38,7 @@ jobs:
# the shared scope even if they somehow target the default branch.
if: >-
github.event.workflow_run.conclusion == 'success' &&
(github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!endsWith(github.event.workflow_run.actor.login, '[bot]') &&

3
.gitmodules vendored
View file

@ -4,6 +4,3 @@
[submodule "doxygen-awesome-css"]
path = doc/doxygen/theme
url = https://github.com/jothepro/doxygen-awesome-css.git
[submodule "external/qttesting"]
path = external/qttesting
url = https://github.com/Kitware/QtTesting.git

View file

@ -20,8 +20,6 @@ option(WITH_CLIENT "Build Cockatrice client" ON)
option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
# Compile Servatrice
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile GUI tests (QtTesting widget-level playback)
option(WITH_GUI_TEST "Build the QtTesting-based GUI test harness" OFF)
# Compile tests
option(TEST "Build tests" OFF)
# Build with AddressSanitizer and UndefinedBehaviorSanitizer
@ -31,14 +29,6 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
# The GUI test harness lives in a submodule; a checkout without it must still configure.
if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/CMakeLists.txt")
message(WARNING "WITH_GUI_TEST is ON but external/qttesting is missing, so GUI tests are disabled.\n"
"Run 'git submodule update --init external/qttesting' to enable them."
)
set(WITH_GUI_TEST OFF)
endif()
# Default to "Release" build type
# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call
if(DEFINED CMAKE_BUILD_TYPE)
@ -473,34 +463,6 @@ if(WITH_ORACLE)
set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
endif()
if(WITH_GUI_TEST)
set(QtTesting_QT_VERSION "6")
# QtTesting registers its own unit tests when BUILD_TESTING is on. They target the
# Qt version the harness was written for and fail on Qt 6.11 (toolbutton
# long-press recording), and they are not Cockatrice's tests, so suppress them
# for the duration of the subdirectory instead of painting ctest red.
if(DEFINED BUILD_TESTING)
set(_cockatrice_saved_build_testing ${BUILD_TESTING})
endif()
set(BUILD_TESTING
OFF
CACHE BOOL "Build the testing tree." FORCE
)
add_subdirectory(external/qttesting)
if(DEFINED _cockatrice_saved_build_testing)
set(BUILD_TESTING
${_cockatrice_saved_build_testing}
CACHE BOOL "Build the testing tree." FORCE
)
else()
unset(BUILD_TESTING CACHE)
endif()
# cl.exe has no warnings-as-errors here, and it rejects -Wno-error with D8021.
if(NOT MSVC)
target_compile_options(qttesting PRIVATE -Wno-error)
endif()
endif()
if(TEST)
include(CTest)
add_subdirectory(tests)

View file

@ -1,66 +0,0 @@
# GUI tests
Widget-level playback tests for the Cockatrice client, driven by
[QtTesting](https://github.com/Kitware/QtTesting) (`external/qttesting`, a submodule).
Each `*_test.xml` file is a list of events that QtTesting replays against the running
application; a test passes when every event is handled without error.
## Running
```bash
git submodule update --init external/qttesting # once
cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=0 -DWITH_GUI_TEST=ON
cmake --build build/debug -j 24
./Testing/run_test.sh --all # headless, offscreen
./Testing/run_test.sh Testing/smoke_test.xml # one file
./Testing/run_test.sh --visual Testing/smoke_test.xml # shows the window
```
`WITH_GUI_TEST` is on by default; a checkout without the submodule configures with a
warning and no harness. `COCKATRICE_BIN` points the runner at another build, and
`TEST_TIMEOUT` (seconds, default 180) bounds a single test — a failing event inside a
modal dialog can otherwise leave the process alive. The client also hard-exits a few
seconds after playback returns, whatever the result.
The tests are **not** part of `ctest`: they need a card database and a developer's
settings, so they stay a local loop for now.
## Caveats
- **Not hermetic.** The client reads the local settings and card database, so a test can
pass on one machine and fail on another. Avoid asserting things that restored
workspace state or user preferences decide (for example the number of open tabs).
- **English labels only.** Menu entries are addressed by their (translated) text, so the
tests expect the source language. Widgets are addressed by objectName paths, which are
stable across translations.
- Actions that open a dialog are followed by a `pause` event: playback continues in the
dialog's own event loop, and the pause gives the UI time to build itself.
- Widget paths include Qt-internal names where a QTabWidget is involved
(`qt_tabwidget_stackedwidget`), and the deck views in a game are indexed
(`tabbedDeckView_0`, `tabbedDeckView_1`).
## Writing a test
A test is a `<events>` list. Widgets are named by their path from the main window, e.g.
`MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu`. Useful commands:
| Command | Arguments | Effect |
|---------|-----------|--------|
| `activate` | action or menu name | triggers a QAction (menus are opened first) |
| `key` | Qt key code | presses that key on the focused widget |
| `pause` | milliseconds | waits, letting the UI settle |
| `clickCard` | `card,zone` | double click the card found in that zone (plays it from hand) |
| `flipCard` | `card,zone` | right-click → *Turn Over* |
| `cardAction` | `card,zone,label` | right-click → first menu entry whose label contains `label` (submenus included) |
| `moveCard` | `card,srcZone,dstZone` | drags the card onto the destination zone |
| `drawArrow` | `srcCard,srcZone,dstCard,dstZone` | right-drag an arrow between two cards |
| `createToken` | `name,color,pt` | creates a token as the active local player |
| `selectItem` | `item label` | selects the tree/list entry with that label |
| `typeText` | `text` | types text one key event at a time |
| `expectProperty` | `property,value` | fails playback unless the widget property matches |
The card, item and property commands are `pqCockatriceEventPlayer`
(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name
and zone through the `QGraphicsScene` and send real mouse events at the card's current
viewport position, so a test does not depend on the window size or screen resolution.

View file

@ -1,69 +0,0 @@
<events>
<!--
Card actions (custom commands of pqCockatriceEventPlayer).
Exercises the context menu path (tap, counters in a submenu, flip),
arrow drawing, and dragging a card between zones. All of it through
mouse events on the game view, so the same file runs at any resolution.
-->
<!-- ===== Two-player local game ===== -->
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &amp;local game..." />
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- ===== Player 1 loads a one-card deck ===== -->
<!-- (No tab-count assertion: the restored workspace state decides how many other
tabs exist, and the paths below already prove the game tab is there.) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- ===== Player 2 loads the same deck and readies up ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- ===== Player 1, precombat main ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== A token to interact with ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Goblin,r,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- Tap it through the context menu -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="cardAction" arguments="Goblin,table,Tap / Untap" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- Add a counter: the entry lives in the "Card counters" submenu -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="cardAction" arguments="Goblin,table,Add counter (A)" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- Flip it face down -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="flipCard" arguments="Goblin,table" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- ===== An arrow from a second token to the Goblin ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Elf,w,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="drawArrow" arguments="Elf,table,Goblin,table" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== Drag the Goblin into the graveyard ===== -->
<!-- A token that leaves the battlefield is destroyed rather than moved, so
there is nothing to drag back afterwards. -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="moveCard" arguments="Goblin,table,grave" />
<event widget="MainWindow" command="pause" arguments="1000" />
</events>

View file

@ -1,4 +0,0 @@
<events>
<!-- Open a new deck editor tab from the home page -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
</events>

View file

@ -1,14 +0,0 @@
<events>
<!-- Open a new visual deck editor tab from the home page -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<!-- Type "Lightning" into the search box (keycodes for L-i-g-h-t-n-i-n-g) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="104" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="116" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="110" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="110" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
</events>

View file

@ -1,11 +0,0 @@
<events>
<!-- Open and close Settings dialog (modal exec) -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow/settingsDialog" command="key" arguments="16777216" />
<!-- Open and close About dialog (modal exec) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;About Cockatrice" />
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
</events>

View file

@ -1,101 +0,0 @@
<events>
<!--
Tier 7: Game Mechanics
Tests: play cards (clickCard), flip cards (flipCard),
create tokens (createToken), draw arrows (drawArrow).
All interactions are pure UI mouse events through the QGraphicsView viewport.
-->
<!-- ===== Start a local 2-player game ===== -->
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &amp;local game..." />
<!-- Force two players so both deck views exist, independent of the user's settings -->
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
<!-- ===== Player 1: Load 3-card deck ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="3 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- ===== Player 2: Load same deck ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="3 Lightning Bolt" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- ===== Player 1, Turn 1 ===== -->
<!-- Upkeep → Draw → Main 1 -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Create a Goblin token -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Goblin,r,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- Create a second token (Elf) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Elf,w,1/1" />
<event widget="MainWindow" command="pause" arguments="1000" />
<!-- Flip the Goblin (right-click → Turn Over) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="flipCard" arguments="Goblin,table" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Play a Lightning Bolt from hand (double-click) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="clickCard" arguments="Lightning Bolt,hand" />
<event widget="MainWindow" command="pause" arguments="500" -->
<!-- Draw arrow from Elf to Goblin (right-drag) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="drawArrow" arguments="Elf,table,Goblin,table" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Advance through all remaining phases to end turn -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== Player 2, Turn 1 ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Advance through all phases to end turn -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="200" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
</events>

View file

@ -1,45 +0,0 @@
<events>
<!-- Integration test: exercises multiple tiers in sequence -->
<!-- Tier 2: Open and close all menus -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<event widget="MainWindow/menubar" command="activate" arguments="tabsMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Tier 2: Open and close About (modal) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;About Cockatrice" />
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
<!-- Tier 4: Open and close ViewLog (non-modal) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="View &amp;Debug Log" />
<event widget="MainWindow/viewLogDialog" command="key" arguments="16777216" />
<!-- Tier 4: Open and close Tip of the Day (non-modal) -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;Tip of the Day" />
<event widget="MainWindow/tipOfTheDayDialog" command="key" arguments="16777216" />
<!-- Tier 5: Settings search (state interaction) -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="100" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="101" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="98" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="117" />
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
<!-- Tier 3: Open deck editor from home and type in search -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="104" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="116" />
</events>

View file

@ -1,178 +0,0 @@
<events>
<!--
Tier 6: Local Game Lifecycle (Extended)
Tests: create game -> load decks -> ready up -> game starts
-> play through turns using menu activate commands
Phase actions triggered via activate on phasesMenu:
aNextPhaseAction advances phase + triggers phase action (e.g., draw at draw step)
aNextTurn in gameMenu triggers next turn
This avoids keyboard shortcut issues in offscreen/test environments.
-->
<!-- ===== Phase 1: Start a local game via main menu ===== -->
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &amp;local game..." />
<!-- Force two players so both deck views exist, independent of the user's settings -->
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
<!-- ===== Phase 2: Player 1 loads deck from clipboard ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<!-- 1 Lightning Bolt -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<!-- Ctrl+Return to accept clipboard dialog -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<!-- Delay -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Player 1 ready up -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 3: Player 2 loads deck from clipboard ===== -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
<!-- 1 Lightning Bolt -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
<!-- Ctrl+Return to accept clipboard dialog -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
<!-- Delay -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Player 2 ready up -> game auto-starts -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
<!-- Delay for game to start -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 4: Advance through all 11 phases via phasesMenu activate ===== -->
<!-- Using activate on phasesMenu triggers aNextPhaseAction which:
- advances the phase by 1
- calls triggerPhaseAction (e.g., draw card at draw step, untap all at untap) -->
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 1 draws 1 card -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Phase 2 (Draw) → 3 (Main 1) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- ===== Main 1: Play a card from hand by double-click ===== -->
<!-- clickCard resolves card by name in the scene, maps to viewport coords at current window size. -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="clickCard" arguments="Lightning Bolt,hand" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- Phase 3 → 4 (Begin Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="pause" arguments="300" />
<!-- Phase 4 → 5 (Declare Attackers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 5 → 6 (Declare Blockers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 6 → 7 (Combat Damage) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 7 → 8 (End Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 8 → 9 (Main 2) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 9 → 10 (Cleanup) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 10 → 0 (Untap): TRIGGERS NEXT TURN + UNTAP ALL — now Player 2's turn -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<!-- Delay for turn transition -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 5: Player 2's turn — advance through all 11 phases ===== -->
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 2 draws 1 card -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 2 (Draw) → 3 (Main 1) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 3 → 4 (Begin Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 4 → 5 (Declare Attackers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 5 → 6 (Declare Blockers) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 6 → 7 (Combat Damage) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 7 → 8 (End Combat) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 8 → 9 (Main 2) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 9 → 10 (Cleanup) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 10 → 0 (Untap): TRIGGERS NEXT TURN — back to Player 1 -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<!-- Delay -->
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- ===== Phase 6: Player 1 turn 2 — advance to draw again ===== -->
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 1 draws again -->
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
<event widget="MainWindow" command="key" arguments="32" />
<event widget="MainWindow" command="key" arguments="32" />
</events>

View file

@ -1,19 +0,0 @@
<events>
<!-- Test 1: Open and close each top-level menu -->
<!-- Cockatrice menu -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Database menu -->
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Tabs menu -->
<event widget="MainWindow/menubar" command="activate" arguments="tabsMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
<!-- Help menu -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow" command="key" arguments="16777216" />
</events>

View file

@ -1,6 +0,0 @@
<events>
<!-- Test non-modal dialog lifecycle: open and close Manage Sets (QMainWindow) -->
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
<event widget="MainWindow/menubar/dbMenu" command="activate" arguments="&amp;Manage sets..." />
<event widget="MainWindow/manageSetsWindow" command="key" arguments="16777216" />
</events>

View file

@ -1,6 +0,0 @@
<events>
<!-- Test non-modal dialog lifecycle: open and close Tip of the Day -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;Tip of the Day" />
<event widget="MainWindow/tipOfTheDayDialog" command="key" arguments="16777216" />
</events>

View file

@ -1,6 +0,0 @@
<events>
<!-- Test non-modal dialog lifecycle: open and close ViewLog -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="View &amp;Debug Log" />
<event widget="MainWindow/viewLogDialog" command="key" arguments="16777216" />
</events>

View file

@ -1,118 +0,0 @@
#!/bin/bash
# Run QtTesting XML test files against the Cockatrice build.
#
# Usage:
# ./Testing/run_test.sh [--visual] [--all | <test-file.xml> ...]
#
# Options:
# --visual Run with native display (shows the GUI window).
# Default is headless (QT_QPA_PLATFORM=offscreen).
# --all Run every *.xml file in Testing/.
#
# Environment:
# COCKATRICE_BIN Path to the client binary (default: build/debug/cockatrice/cockatrice,
# or the .app bundle binary on macOS).
# TEST_TIMEOUT Seconds a single test may run before it is killed (default: 180).
# Uses timeout, or gtimeout from coreutils on macOS.
#
# Requires the debug build to exist at build/debug/cockatrice/cockatrice.
set -euo pipefail
shopt -s nullglob
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
if [ -n "${COCKATRICE_BIN:-}" ]; then
COCKATRICE="$COCKATRICE_BIN"
elif [ "$(uname -s)" = "Darwin" ]; then
# A macOS build produces an app bundle, not a bare executable.
COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice.app/Contents/MacOS/cockatrice"
else
COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice"
fi
TEST_DIR="$REPO_ROOT/Testing"
TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
# Stock macOS has no `timeout`; coreutils provides `gtimeout`. Without either the
# runner still works, but a hung test is not killed.
TIMEOUT=()
if command -v timeout >/dev/null 2>&1; then
TIMEOUT=(timeout --foreground "$TEST_TIMEOUT")
elif command -v gtimeout >/dev/null 2>&1; then
TIMEOUT=(gtimeout --foreground "$TEST_TIMEOUT")
else
echo "Warning: neither timeout nor gtimeout found; tests are not time-limited." >&2
fi
if [ ! -f "$COCKATRICE" ]; then
echo "Error: $COCKATRICE not found. Build first:" >&2
echo " cmake --build build/debug" >&2
exit 1
fi
VISUAL=0
if [ "${1:-}" = "--visual" ]; then
VISUAL=1
shift
fi
if [ "${1:-}" = "--all" ]; then
shift
TEST_FILES=("$TEST_DIR"/*.xml)
if [ ${#TEST_FILES[@]} -eq 0 ]; then
echo "No *.xml files found in $TEST_DIR" >&2
exit 1
fi
elif [ $# -ge 1 ]; then
TEST_FILES=("$@")
else
echo "Usage: $0 [--visual] [--all | <test-file.xml> ...]" >&2
echo " Run a single test: $0 Testing/smoke_test.xml" >&2
echo " Run all tests: $0 --all" >&2
echo " Run visually: $0 --visual Testing/smoke_test.xml" >&2
echo " Run all visually: $0 --visual --all" >&2
exit 1
fi
PASS=0
FAIL=0
FAILED_TESTS=()
run_one() {
local file="$1"
local -a cmd=()
if [ "${#TIMEOUT[@]}" -gt 0 ]; then
cmd=("${TIMEOUT[@]}")
fi
cmd+=("$COCKATRICE" --no-connect --test "$file")
if [ "$VISUAL" -eq 1 ]; then
"${cmd[@]}"
else
env QT_QPA_PLATFORM=offscreen "${cmd[@]}"
fi
}
for f in "${TEST_FILES[@]}"; do
name="$(basename "$f")"
printf "%-40s " "$name"
if run_one "$f"; then
echo "PASS"
PASS=$((PASS + 1))
else
echo "FAIL"
FAIL=$((FAIL + 1))
FAILED_TESTS+=("$name")
fi
done
TOTAL=$((PASS + FAIL))
echo ""
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "Failed:"
for t in "${FAILED_TESTS[@]}"; do
echo " $t"
done
exit 1
fi

View file

@ -1,6 +0,0 @@
<events>
<!-- Open Settings dialog via Cockatrice menu, then close with Escape -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow/settingsDialog" command="key" arguments="16777216" />
</events>

View file

@ -1,33 +0,0 @@
<events>
<!--
Settings navigation (state assertions).
Navigates the settings page tree by label, checks that the page stack
followed, then filters with the search box and verifies the text landed
in the edit — then closes with the OK button.
-->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<event widget="MainWindow" command="pause" arguments="500" />
<!-- The dialog opens on the first page (General) -->
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,0" />
<!-- Navigate to Shortcuts, the last page of the tree -->
<event widget="MainWindow/settingsDialog/settingsView/navTree" command="selectItem" arguments="Shortcuts" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,7" />
<!-- ... and back to General -->
<event widget="MainWindow/settingsDialog/settingsView/navTree" command="selectItem" arguments="General" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,0" />
<!-- Filters the settings by search term -->
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="typeText" arguments="sound" />
<event widget="MainWindow" command="pause" arguments="300" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="expectProperty" arguments="text,sound" />
<!-- Close -->
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
</events>

View file

@ -1,13 +0,0 @@
<events>
<!-- Tier 5: State verification — open Settings, search for a page, close -->
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
<!-- Type "sound" in the settings search to filter pages -->
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="115" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="111" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="117" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="110" />
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="100" />
<!-- Close settings -->
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
</events>

View file

@ -1,8 +0,0 @@
<events>
<!-- Open Help menu by activating it on the menu bar -->
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
<!-- Click About Cockatrice (opens modal QMessageBox) -->
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&amp;About Cockatrice" />
<!-- Close the modal dialog by pressing Escape (Qt::Key_Escape = 16777216) -->
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
</events>

View file

@ -450,8 +450,6 @@ set(cockatrice_SOURCES
src/interface/intents/intent_open_shared_deck.h
src/interface/intents/url_parser.cpp
src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_games_model.cpp
src/interface/widgets/server/user/user_games_model.h
src/interface/widgets/server/user/user_info_popup.cpp
src/interface/widgets/server/user/user_info_popup.h
)
@ -567,14 +565,6 @@ target_link_libraries(
cockatrice_target_enable_crash_reporting(cockatrice)
if(WITH_GUI_TEST)
# These two translate between XML test files and the QtTesting event pipeline;
# they need the QtTesting headers, so they only exist with the harness.
target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp)
target_link_libraries(cockatrice PRIVATE QtTesting)
target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST)
endif()
if(UNIX)
if(APPLE)
set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}")

View file

@ -10,7 +10,6 @@
# or set <category> = false to disable logging
#main = true
#recording = true
#qt_translator = true
#window_main.* = true
#release_channel = true

View file

@ -1,42 +0,0 @@
/**
* @file deck_analysis_url.h
* @ingroup ApiInterfaces
*/
#ifndef DECK_ANALYSIS_URL_H
#define DECK_ANALYSIS_URL_H
#include <QString>
#include <QUrl>
/**
* Validates a deck address returned by a deck analysis service.
*
* Only the expected host (optionally "www."-prefixed), HTTPS, default ports and
* URLs without user information are accepted. Legacy HTTP addresses for the same
* service are upgraded to HTTPS in place instead of being rejected.
*
* Relative addresses must be resolved against the response URL by the caller.
*
* @return true when @p url names a deck on @p serviceHost; false otherwise.
*/
inline bool normalizeAnalysisUrl(QUrl &url, const QString &serviceHost)
{
const QString host = url.host().toLower();
const bool legacyHttp = url.scheme() == QStringLiteral("http");
const int defaultPort = legacyHttp ? 80 : 443;
if (!url.isValid() || (!legacyHttp && url.scheme() != QStringLiteral("https")) || !url.userInfo().isEmpty() ||
!url.path().startsWith('/') || (url.port() != -1 && url.port() != defaultPort) ||
(host != serviceHost && host != QStringLiteral("www.") + serviceHost)) {
return false;
}
// Both services accept HTTPS. Older public metadata may still identify the
// same deck with HTTP; retain its host/path/query while using HTTPS to open it.
if (legacyHttp) {
url.setScheme(QStringLiteral("https"));
url.setPort(-1);
}
return true;
}
#endif

View file

@ -1,6 +1,5 @@
#include "deck_stats_interface.h"
#include "deck_analysis_url.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
@ -11,14 +10,12 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QSharedPointer>
#include <QString>
#include <QTextDocumentFragment>
#include <QUrl>
#include <QWidget>
#include <QUrlQuery>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
@ -32,56 +29,30 @@ DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent)
connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished);
}
void DeckStatsInterface::showError(const QString &message)
{
// The notice belongs to the originating editor, not this one-shot request.
// Retire the request before opening UI: opening a window may reenter Qt.
QWidget *owner = qobject_cast<QWidget *>(parent());
auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner);
notice->setTextFormat(Qt::PlainText);
notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
notice->setAttribute(Qt::WA_DeleteOnClose);
deleteLater();
notice->open();
}
void DeckStatsInterface::queryFinished(QNetworkReply *reply)
{
if (replyHandled) {
return;
}
replyHandled = true;
// All terminal paths release the reply before showing UI or invoking an
// external URL handler, either of which may destroy the originating editor.
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
showError(reply->errorString());
QMessageBox::critical(nullptr, tr("Error"), reply->errorString());
reply->deleteLater();
deleteLater();
return;
}
const QString data = QString::fromUtf8(reply->readAll());
static const QRegularExpression rx(
R"(<meta\b(?=[^>]*\bproperty\s*=\s*(['"])og:url\1)[^>]*\bcontent\s*=\s*(['"])(.*?)\2)",
QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption);
const auto match = rx.match(data);
QString data(reply->readAll());
reply->deleteLater();
static const QRegularExpression rx("<meta property=\"og:url\" content=\"([^\"]+)\"");
auto match = rx.match(data);
if (!match.hasMatch()) {
showError(tr("The reply from Deckstats did not contain a deck address."));
QMessageBox::critical(nullptr, tr("Error"), tr("The reply from the server could not be parsed."));
deleteLater();
return;
}
const QString address = QTextDocumentFragment::fromHtml(match.captured(3)).toPlainText();
QUrl deckUrl = reply->url().resolved(QUrl(address, QUrl::StrictMode));
if (address.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("deckstats.net"))) {
showError(tr("The reply from Deckstats contained an invalid deck address."));
return;
}
QString deckUrl = match.captured(1);
QDesktopServices::openUrl(deckUrl);
QPointer<DeckStatsInterface> guard(this);
deleteLater();
if (!QDesktopServices::openUrl(deckUrl) && guard) {
showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1")
.arg(deckUrl.toDisplayString()));
}
}
void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
@ -89,8 +60,12 @@ void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray
DeckList deckWithoutTokens;
copyDeckWithoutTokens(deck, deckWithoutTokens);
data.append("deck=" + QUrl::toPercentEncoding(deckWithoutTokens.writeToString_Plain()));
data.append("&decktitle=" + QUrl::toPercentEncoding(deck.getName()));
QUrl params;
QUrlQuery urlQuery;
urlQuery.addQueryItem("deck", deckWithoutTokens.writeToString_Plain());
urlQuery.addQueryItem("decktitle", deck.getName());
params.setQuery(urlQuery);
data.append(params.query(QUrl::EncodeReserved).toUtf8());
}
void DeckStatsInterface::analyzeDeck(const DeckList &deck)
@ -109,9 +84,7 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList
{
auto copyIfNotAToken = [&destination](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
// Missing local card data is not evidence that a card is a token. Keep
// its original name and quantity so the service analyzes the whole deck.
if ((!dbCard || !dbCard->getIsToken()) && node->getName() != DECK_ZONE_MAYBEBOARD) {
if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) {
DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1);
addedCard->setNumber(card->getNumber());
}

View file

@ -11,7 +11,6 @@
#include <qtmetamacros.h>
class QByteArray;
class QString;
class QNetworkAccessManager;
class QNetworkReply;
class DeckList;
@ -21,8 +20,6 @@ class DeckStatsInterface : public QObject
Q_OBJECT
private:
QNetworkAccessManager *manager;
bool replyHandled = false;
void showError(const QString &message);
/**
* Deckstats doesn't recognize token cards, and instead tries to find the

View file

@ -1,6 +1,5 @@
#include "tapped_out_interface.h"
#include "deck_analysis_url.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
@ -14,17 +13,15 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QRegularExpressionMatchIterator>
#include <QSharedPointer>
#include <QString>
#include <QStringList>
#include <QTextDocumentFragment>
#include <QUrl>
#include <QUrlQuery>
#include <QVariant>
#include <QWidget>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
@ -38,67 +35,54 @@ TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent)
connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished);
}
void TappedOutInterface::showError(const QString &message)
{
// The notice belongs to the originating editor, not this one-shot request.
// Retire the request before opening UI: opening a window may reenter Qt.
QWidget *owner = qobject_cast<QWidget *>(parent());
auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner);
notice->setTextFormat(Qt::PlainText);
notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
notice->setAttribute(Qt::WA_DeleteOnClose);
deleteLater();
notice->open();
}
void TappedOutInterface::queryFinished(QNetworkReply *reply)
{
if (replyHandled) {
return;
}
replyHandled = true;
// All terminal paths release the reply before showing UI or invoking an
// external URL handler, either of which may destroy the originating editor.
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
showError(reply->errorString());
return;
}
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (httpStatus >= 200 && httpStatus < 400 && reply->hasRawHeader("Location")) {
const QByteArray location = reply->rawHeader("Location");
QUrl deckUrl = reply->url().resolved(QUrl::fromEncoded(location, QUrl::StrictMode));
if (location.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("tappedout.net"))) {
showError(tr("The reply from TappedOut contained an invalid deck address."));
return;
}
QPointer<TappedOutInterface> guard(this);
QMessageBox::critical(nullptr, tr("Error"), reply->errorString());
reply->deleteLater();
deleteLater();
if (!QDesktopServices::openUrl(deckUrl) && guard) {
showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1")
.arg(deckUrl.toDisplayString()));
}
return;
}
const QString data = QString::fromUtf8(reply->readAll());
QStringList errorMessageList = {tr("Unable to analyze the deck on TappedOut.")};
static const QRegularExpression errorsRx("<div class=\"alert alert-danger.*?<ul>(.*?)</ul>",
QRegularExpression::DotMatchesEverythingOption);
const auto match = errorsRx.match(data);
if (match.hasMatch()) {
static const QRegularExpression itemRx("<li>(.*?)</li>", QRegularExpression::DotMatchesEverythingOption);
auto items = itemRx.globalMatch(match.captured(1));
while (items.hasNext()) {
const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified();
if (!error.isEmpty()) {
errorMessageList.append(error);
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->hasRawHeader("Location")) {
/*
* If the reply contains a "Location" header, a relative URL to the deck on TappedOut
* can be extracted from the header. The http status is a 302 "redirect".
*/
QString deckUrl = reply->rawHeader("Location");
qCInfo(TappedOutInterfaceLog) << "Tappedout: good reply, http status" << httpStatus << "location" << deckUrl;
QDesktopServices::openUrl("https://tappedout.net" + deckUrl);
} else {
/*
* Otherwise, the deck has not been parsed correctly. Error messages can be extracted
* from the html. Css pseudo selector for errors: $("div.alert-danger > ul > li")
*/
QString data(reply->readAll());
QStringList errorMessageList = {tr("Unable to analyze the deck.")};
static const QRegularExpression rx("<div class=\"alert alert-danger.*?<ul>(.*?)</ul>");
auto match = rx.match(data);
if (match.hasMatch()) {
QString errors = match.captured(1);
static const QRegularExpression rx2("<li>(.*?)</li>");
static const QRegularExpression rxremove("<[^>]*>");
auto matchIterator = rx2.globalMatch(errors);
while (matchIterator.hasNext()) {
auto match2 = matchIterator.next();
errorMessageList.append(match2.captured(1).remove(rxremove).simplified());
}
}
QString errorMessage = errorMessageList.join("\n");
qCWarning(TappedOutInterfaceLog) << "Tappedout: bad reply, http status" << httpStatus << "size" << data.size()
<< "message" << errorMessage;
QMessageBox::critical(nullptr, tr("Error"), errorMessage);
}
qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus;
showError(errorMessageList.join('\n'));
reply->deleteLater();
deleteLater();
}
void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
@ -106,9 +90,13 @@ void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray
DeckList mainboard, sideboard;
copyDeckSplitMainAndSide(deck, mainboard, sideboard);
data.append("name=" + QUrl::toPercentEncoding(deck.getName()));
data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true)));
data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true)));
QUrl params;
QUrlQuery urlQuery;
urlQuery.addQueryItem("name", deck.getName());
urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false, true));
urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false, true));
params.setQuery(urlQuery);
data.append(params.query(QUrl::EncodeReserved).toUtf8());
}
void TappedOutInterface::analyzeDeck(const DeckList &deck)
@ -129,9 +117,7 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi
{
auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
// Exclude confirmed tokens, but preserve cards missing from the local
// database, including newly released cards in an imported deck.
if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) {
if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}

View file

@ -14,7 +14,6 @@
inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface");
class QByteArray;
class QString;
class QNetworkAccessManager;
class QNetworkReply;
class DeckList;
@ -30,8 +29,6 @@ class TappedOutInterface : public QObject
Q_OBJECT
private:
QNetworkAccessManager *manager;
bool replyHandled = false;
void showError(const QString &message);
void copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard);
private slots:

View file

@ -71,8 +71,6 @@ void ToggleButton::setState(bool _state)
DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
: QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId)
{
setObjectName("deckViewContainer");
loadLocalButton = new QPushButton;
loadRemoteButton = new QPushButton;
loadFromClipboardButton = new QPushButton;
@ -82,14 +80,6 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
forceStartGameButton = new QPushButton;
sideboardLockButton = new ToggleButton;
loadLocalButton->setObjectName("loadLocalButton");
loadRemoteButton->setObjectName("loadRemoteButton");
loadFromClipboardButton->setObjectName("loadFromClipboardButton");
readyStartButton->setObjectName("readyStartButton");
forceStartGameButton->setObjectName("forceStartGameButton");
unloadDeckButton->setObjectName("unloadDeckButton");
sideboardLockButton->setObjectName("sideboardLockButton");
connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck);
connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck);
connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard);

View file

@ -14,7 +14,6 @@ class TabGame;
TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent)
: QTabWidget(nullptr), playerId(_playerId), parentGame(parent)
{
setObjectName(QString("tabbedDeckView_%1").arg(_playerId));
setTabsClosable(true);
connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab);

View file

@ -65,7 +65,6 @@ protected:
GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0)
{
setObjectName("gameView");
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
setViewportUpdateMode(BoundingRectViewportUpdate);

View file

@ -1,628 +0,0 @@
#include "pq_cockatrice_event_player.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "board/card_item.h"
#include "game_scene.h"
#include "game_view.h"
#include "zones/card_zone.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QAction>
#include <QApplication>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QLoggingCategory>
#include <QMenu>
#include <QMouseEvent>
#include <QTreeWidget>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer")
namespace
{
/**
* Strips Qt's mnemonic markers so a test can name a menu entry by the label the
* user sees ("Turn Over" rather than "T&urn Over").
*/
QString normalizeActionText(const QString &text)
{
QString normalized = text;
normalized.remove('&');
return normalized.simplified();
}
/**
* Maps a printable character onto the key a US keyboard would report for it, so
* typing produces the same QKeyEvents a recording would.
*/
Qt::Key keyForCharacter(QChar character)
{
if (character.isLetter()) {
return static_cast<Qt::Key>(Qt::Key_A + (character.toUpper().unicode() - 'A'));
}
if (character.isDigit()) {
return static_cast<Qt::Key>(Qt::Key_0 + (character.unicode() - '0'));
}
switch (character.unicode()) {
case ' ':
return Qt::Key_Space;
case '\n':
return Qt::Key_Return;
case '\t':
return Qt::Key_Tab;
case '-':
return Qt::Key_Minus;
case '_':
return Qt::Key_Underscore;
case '/':
return Qt::Key_Slash;
case ',':
return Qt::Key_Comma;
case '.':
return Qt::Key_Period;
case '\'':
return Qt::Key_Apostrophe;
case ';':
return Qt::Key_Semicolon;
case ':':
return Qt::Key_Colon;
case '"':
return Qt::Key_QuoteDbl;
case '&':
return Qt::Key_Ampersand;
case '(':
return Qt::Key_ParenLeft;
case ')':
return Qt::Key_ParenRight;
case '[':
return Qt::Key_BracketLeft;
case ']':
return Qt::Key_BracketRight;
default:
return Qt::Key_unknown;
}
}
/** Depth-first search for the first model index whose label matches @p text. */
QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {})
{
for (int row = 0; row < model->rowCount(parent); ++row) {
const QModelIndex index = model->index(row, 0, parent);
if (index.data(Qt::DisplayRole).toString() == text) {
return index;
}
const QModelIndex child = findModelIndexByText(model, text, index);
if (child.isValid()) {
return child;
}
}
return {};
}
bool needsShift(QChar character)
{
if (character.isUpper()) {
return true;
}
return QStringLiteral("_:\"&()").contains(character);
}
} // namespace
pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent)
{
}
bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error)
{
// A known command that fails is a handled-but-failed event: QtTesting logs
// "Unhandled event" when playEvent returns false, so report through `error`
// (the contract playExpectProperty already follows) and still return true.
const auto run = [&error](bool ok) {
if (!ok) {
error = true;
}
return true;
};
if (command == "clickCard") {
return run(playClickCard(object, arguments));
} else if (command == "flipCard") {
return run(playFlipCard(object, arguments));
} else if (command == "cardAction") {
return run(playCardAction(object, arguments));
} else if (command == "moveCard") {
return run(playMoveCard(object, arguments));
} else if (command == "drawArrow") {
return run(playDrawArrow(object, arguments));
} else if (command == "createToken") {
return run(playCreateToken(object, arguments));
} else if (command == "selectItem") {
return run(playSelectItem(object, arguments));
} else if (command == "typeText") {
return run(playTypeText(object, arguments));
} else if (command == "expectProperty") {
return playExpectProperty(object, arguments, error);
}
return false;
}
QGraphicsItem *
pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName)
{
for (QGraphicsItem *gi : scene->items()) {
if (gi->type() != typeCard) {
continue;
}
auto *card = static_cast<CardItem *>(gi);
if (!card->getZone() || card->getZone()->getName() != zoneName) {
continue;
}
QString actualName = card->getName();
if (actualName == cardName || actualName.endsWith(" " + cardName)) {
return card;
}
}
return nullptr;
}
QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName)
{
for (QGraphicsItem *gi : scene->items()) {
if (gi->type() != typeZone) {
continue;
}
auto *zone = static_cast<CardZone *>(gi);
if (zone->getLogic() && zone->getLogic()->getName() == zoneName) {
return zone;
}
}
return nullptr;
}
QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText)
{
if (!menu || actionText.isEmpty()) {
return nullptr;
}
for (QAction *action : menu->actions()) {
if (action->isSeparator()) {
continue;
}
if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) {
return action;
}
if (QMenu *subMenu = action->menu()) {
if (QAction *found = findActionInMenu(subMenu, actionText)) {
return found;
}
}
}
return nullptr;
}
QMenu *pqCockatriceEventPlayer::visiblePopupMenu()
{
for (QWidget *w : QApplication::topLevelWidgets()) {
auto *menu = qobject_cast<QMenu *>(w);
if (menu && menu->isVisible()) {
return menu;
}
}
return nullptr;
}
QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item)
{
return view->mapFromScene(item->sceneBoundingRect().center());
}
void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view,
QEvent::Type type,
const QPoint &pos,
Qt::MouseButton button,
Qt::MouseButtons buttons)
{
QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier);
qApp->notify(view->viewport(), &event);
}
bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView";
return false;
}
auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed());
if (!target) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
const QPoint pos = viewportPoint(gv, target);
// A double click is the play gesture from hand: press/release, then the
// double-click event and its release, exactly as a recording produces it.
sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
return true;
}
bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene";
return false;
}
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
if (!card) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over"));
}
bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene";
return false;
}
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
if (!card) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed());
}
bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command,
QGraphicsView *view,
CardItem *card,
const QString &actionText)
{
const QPoint pos = viewportPoint(view, card);
sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton);
qApp->processEvents();
QMenu *menu = visiblePopupMenu();
if (!menu) {
qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click";
return false;
}
QAction *action = findActionInMenu(menu, actionText);
if (!action) {
qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no context menu action matching" << actionText;
menu->close();
return false;
}
action->trigger();
menu->close();
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments;
return false;
}
const QString cardName = parts[0].trimmed();
const QString srcZoneName = parts[1].trimmed();
const QString dstZoneName = parts[2].trimmed();
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene";
return false;
}
auto *card = findCardInScene(scene, cardName, srcZoneName);
if (!card) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName;
return false;
}
auto *dstZone = findZoneInScene(scene, dstZoneName);
if (!dstZone) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: destination zone" << dstZoneName << "not found";
return false;
}
const QPoint srcPos = viewportPoint(gv, card);
const QPoint dstPos = viewportPoint(gv, dstZone);
// A drag has to travel past Qt's start distance before the scene turns it
// into a card drag item, otherwise the move is interpreted as a click.
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton);
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 4) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments;
return false;
}
const QString srcCardName = parts[0].trimmed();
const QString srcZoneName = parts[1].trimmed();
const QString dstCardName = parts[2].trimmed();
const QString dstZoneName = parts[3].trimmed();
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene";
return false;
}
auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName);
if (!srcCard) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName;
return false;
}
auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName);
if (!dstCard) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName;
return false;
}
const QPoint srcPos = viewportPoint(gv, srcCard);
const QPoint dstPos = viewportPoint(gv, dstCard);
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton);
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: arguments must be 'name,color,pt', got:" << arguments;
return false;
}
QString tokenName = parts[0].trimmed();
QString tokenColor = parts[1].trimmed();
QString tokenPt = parts[2].trimmed();
TabGame *tabGame = nullptr;
QWidget *w = gv;
while (w) {
tabGame = qobject_cast<TabGame *>(w);
if (tabGame) {
break;
}
w = w->parentWidget();
}
if (!tabGame) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found";
return false;
}
AbstractGame *game = tabGame->getGame();
if (!game) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game";
return false;
}
int activePlayerId = game->getGameState()->getActivePlayer();
PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId);
if (!player) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no active local player";
return false;
}
TokenInfo info;
info.name = tokenName;
info.color = tokenColor;
info.pt = tokenPt;
info.destroy = true;
info.faceDown = false;
player->getPlayerActions()->actCreateToken(info);
for (int i = 0; i < 10; ++i) {
qApp->processEvents();
}
return true;
}
bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments)
{
const QString text = arguments.trimmed();
if (text.isEmpty()) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select";
return false;
}
if (auto *tree = qobject_cast<QTreeWidget *>(object)) {
QTreeWidgetItemIterator item(tree);
while (*item) {
if ((*item)->text(0) == text) {
tree->setCurrentItem(*item);
qApp->processEvents();
return true;
}
++item;
}
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text;
return false;
}
if (auto *view = qobject_cast<QAbstractItemView *>(object)) {
const QModelIndex index = findModelIndexByText(view->model(), text);
if (!index.isValid()) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text;
return false;
}
view->setCurrentIndex(index);
qApp->processEvents();
return true;
}
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: target is neither a tree widget nor an item view";
return false;
}
bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments)
{
auto *widget = qobject_cast<QWidget *>(object);
if (!widget) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "typeText: target is not a QWidget";
return false;
}
for (const QChar character : arguments) {
const Qt::Key key = keyForCharacter(character);
const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier;
QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character));
QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character));
qApp->notify(widget, &press);
qApp->notify(widget, &release);
}
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error)
{
if (!object) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object";
error = true;
return true;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments;
error = true;
return true;
}
const QString propertyName = parts[0].trimmed();
const QString expected = parts.mid(1).join(',');
const QVariant actual = object->property(propertyName.toUtf8().constData());
if (!actual.isValid()) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "expectProperty:" << object->objectName() << "has no property" << propertyName;
error = true;
return true;
}
if (actual.toString() != expected) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is"
<< actual.toString() << "but the test expected" << expected;
error = true;
return true;
}
return true;
}

View file

@ -1,91 +0,0 @@
#ifndef PQ_COCKATRICE_EVENT_PLAYER_H
#define PQ_COCKATRICE_EVENT_PLAYER_H
#include "pqWidgetEventPlayer.h"
#include <QModelIndex>
class CardItem;
class QAction;
class QGraphicsItem;
class QGraphicsScene;
class QGraphicsView;
class QMenu;
/**
* Custom event player for Cockatrice game interactions.
*
* Card commands are resolution independent: the card is looked up in the
* QGraphicsScene by name and zone, then mouse events are sent at the card's
* current viewport position. That keeps recordings usable across screen sizes
* and window geometries.
*
* Commands:
* clickCard "cardName,zoneName"
* left press/release + double click — plays a card from hand, taps a
* permanent, opens the deck view entry, depending on the zone
* flipCard "cardName,zoneName"
* right-click → "Turn Over" (shorthand for cardAction)
* cardAction "cardName,zoneName,actionText"
* right-click the card and trigger the first context menu action whose
* label contains actionText; submenus are searched as well, so
* "Add counter (A)" works
* moveCard "cardName,srcZoneName,dstZoneName"
* left-drag the card onto the destination zone — plays a card, moves it
* to the graveyard/exile, puts it back into hand, ...
* drawArrow "srcCard,srcZone,dstCard,dstZone"
* right-drag an arrow from one card to another
* createToken "name,color,pt"
* create a token through the active local player, as the table context
* menu's "Create token" dialog would
* selectItem "item text"
* selects the first item of a tree/list widget whose label matches, for
* navigating the settings navigation tree or similar label-driven views
* typeText "some text"
* types the text into the widget one key event at a time — use it for
* deck lists and search boxes instead of one event per character
* expectProperty "propertyName,expectedValue"
* assertion: playback fails unless object->property(propertyName)
* equals expectedValue — for example "count,2" on the tab widget
*/
class pqCockatriceEventPlayer : public pqWidgetEventPlayer
{
Q_OBJECT
typedef pqWidgetEventPlayer Superclass;
public:
explicit pqCockatriceEventPlayer(QObject *parent = nullptr);
using Superclass::playEvent;
bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override;
private:
bool playClickCard(QObject *object, const QString &arguments);
bool playFlipCard(QObject *object, const QString &arguments);
bool playCardAction(QObject *object, const QString &arguments);
bool playMoveCard(QObject *object, const QString &arguments);
bool playDrawArrow(QObject *object, const QString &arguments);
bool playCreateToken(QObject *object, const QString &arguments);
bool playSelectItem(QObject *object, const QString &arguments);
bool playTypeText(QObject *object, const QString &arguments);
bool playExpectProperty(QObject *object, const QString &arguments, bool &error);
/** @brief Triggers the context menu entry @p actionText after right-clicking @p card. */
bool invokeCardMenuAction(const char *command, QGraphicsView *view, CardItem *card, const QString &actionText);
static QGraphicsItem *findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName);
static QGraphicsItem *findZoneInScene(QGraphicsScene *scene, const QString &zoneName);
static QAction *findActionInMenu(QMenu *menu, const QString &actionText);
static QMenu *visiblePopupMenu();
/** @brief Viewport position of the item's centre, as the mouse events have to be sent. */
static QPoint viewportPoint(QGraphicsView *view, const QGraphicsItem *item);
static void sendMouseEvent(QGraphicsView *view,
QEvent::Type type,
const QPoint &pos,
Qt::MouseButton button,
Qt::MouseButtons buttons);
};
#endif // PQ_COCKATRICE_EVENT_PLAYER_H

View file

@ -1,102 +0,0 @@
#include "recording.h"
#include "pqEventTypes.h"
#include <QFile>
#include <QTextStream>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
XMLEventObserver::XMLEventObserver(QObject *parent) : pqEventObserver(parent)
{
}
XMLEventObserver::~XMLEventObserver() = default;
void XMLEventObserver::setStream(QTextStream *stream)
{
if (xmlStream) {
xmlStream->writeEndElement();
xmlStream->writeEndDocument();
xmlStream.reset();
}
if (Stream) {
*Stream << xmlString;
}
xmlString.clear();
pqEventObserver::setStream(stream);
if (Stream) {
xmlStream.reset(new QXmlStreamWriter(&xmlString));
xmlStream->setAutoFormatting(true);
xmlStream->writeStartDocument();
xmlStream->writeStartElement(QStringLiteral("events"));
}
}
void XMLEventObserver::onRecordEvent(const QString &widget,
const QString &command,
const QString &arguments,
const int &eventType)
{
if (!xmlStream) {
return;
}
xmlStream->writeStartElement(QStringLiteral("event"));
xmlStream->writeAttribute(QStringLiteral("widget"), widget);
if (eventType == pqEventTypes::ACTION_EVENT) {
xmlStream->writeAttribute(QStringLiteral("command"), command);
} else { // pqEventTypes::CHECK_EVENT
xmlStream->writeAttribute(QStringLiteral("property"), command);
}
xmlStream->writeAttribute(QStringLiteral("arguments"), arguments);
xmlStream->writeEndElement();
}
XMLEventSource::XMLEventSource(QObject *parent) : pqEventSource(parent)
{
}
XMLEventSource::~XMLEventSource() = default;
void XMLEventSource::setContent(const QString &xmlFileName)
{
xmlStream.reset();
QFile xml(xmlFileName);
if (!xml.open(QIODevice::ReadOnly)) {
qCWarning(RecordingLog) << "Could not read test file" << xmlFileName;
return;
}
xmlStream.reset(new QXmlStreamReader(xml.readAll()));
}
int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType)
{
if (!xmlStream) {
return DONE;
}
while (!xmlStream->atEnd()) {
if (xmlStream->readNext() != QXmlStreamReader::StartElement || xmlStream->name() != QStringLiteral("event")) {
continue;
}
const QXmlStreamAttributes attributes = xmlStream->attributes();
eventType = pqEventTypes::ACTION_EVENT;
widget = attributes.value(QStringLiteral("widget")).toString();
command = attributes.value(QStringLiteral("command")).toString();
if (command.isEmpty()) {
// A recorded check event carries a property instead of a command.
command = attributes.value(QStringLiteral("property")).toString();
}
arguments = attributes.value(QStringLiteral("arguments")).toString();
return SUCCESS;
}
if (xmlStream->hasError()) {
qCWarning(RecordingLog) << "Malformed test file:" << xmlStream->errorString();
}
return DONE;
}

View file

@ -1,54 +0,0 @@
#ifndef COCKATRICE_RECORDING_H
#define COCKATRICE_RECORDING_H
#include "pqEventObserver.h"
#include "pqEventSource.h"
#include <QLoggingCategory>
#include <QScopedPointer>
inline Q_LOGGING_CATEGORY(RecordingLog, "recording");
class QXmlStreamReader;
class QXmlStreamWriter;
/**
* @brief Writes a session recording as an XML test file.
*
* The events are buffered in memory and flushed to the stream when recording
* ends, because the stream is handed over once the file name is known.
*/
class XMLEventObserver : public pqEventObserver
{
QScopedPointer<QXmlStreamWriter> xmlStream;
QString xmlString;
public:
explicit XMLEventObserver(QObject *parent);
~XMLEventObserver() override;
protected:
void setStream(QTextStream *stream) override;
void onRecordEvent(const QString &widget,
const QString &command,
const QString &arguments,
const int &eventType) override;
};
/**
* @brief Plays the events of an XML test file back, one at a time.
*/
class XMLEventSource : public pqEventSource
{
QScopedPointer<QXmlStreamReader> xmlStream;
public:
explicit XMLEventSource(QObject *parent);
~XMLEventSource() override;
protected:
void setContent(const QString &xmlFileName) override;
int getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) override;
};
#endif // COCKATRICE_RECORDING_H

View file

@ -33,7 +33,6 @@ class QWidget;
DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
{
setObjectName("connectDialog");
previousHostButton = new QRadioButton(tr("Known Hosts"), this);
previousHosts = new QComboBox(this);
@ -86,11 +85,6 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
passwordLabel->setBuddy(passwordEdit);
passwordEdit->setEchoMode(QLineEdit::Password);
hostEdit->setObjectName("connectHostEdit");
portEdit->setObjectName("connectPortEdit");
playernameEdit->setObjectName("connectPlayernameEdit");
passwordEdit->setObjectName("connectPasswordEdit");
savePasswordCheckBox = new QCheckBox(tr("&Save password"));
autoConnectCheckBox = new QCheckBox(tr("A&uto connect"));
@ -296,13 +290,11 @@ void DlgConnect::updateDisplayInfo(const QString &saveName)
playernameEdit->setFocus();
savePasswordCheckBox->setChecked(savePasswordStatus);
storedVerifier.clear();
savedVerifier.clear();
if (savePasswordStatus) {
const QString stored = _data.at(4);
if (stored.startsWith("$")) {
storedVerifier = stored;
savedVerifier = stored;
passwordEdit->clear();
passwordEdit->setPlaceholderText(tr("Password saved"));
} else {
@ -339,7 +331,6 @@ void DlgConnect::newHostSelected(bool state)
passwordEdit->clear();
passwordEdit->setPlaceholderText(QString());
storedVerifier.clear();
savedVerifier.clear();
saveEdit->clear();
saveEdit->setPlaceholderText(tr("Unique Server Name"));
saveEdit->setDisabled(false);
@ -355,19 +346,9 @@ void DlgConnect::passwordSaved(QT_STATE_CHANGED_T state)
Q_UNUSED(state);
if (savePasswordCheckBox->isChecked()) {
autoConnectCheckBox->setEnabled(true);
// Re-ticking keeps a password the user has started typing; otherwise restore the saved
// login this dialog was opened with, so unticking and changing your mind is harmless.
if (passwordEdit->text().isEmpty()) {
storedVerifier = savedVerifier;
passwordEdit->setPlaceholderText(savedVerifier.isEmpty() ? QString() : tr("Password saved"));
}
} else {
autoConnectCheckBox->setChecked(false);
autoConnectCheckBox->setEnabled(false);
// Unticking means the password typed below is sent for this connection: stop sending the
// saved verifier instead, and stop claiming a password is saved.
storedVerifier.clear();
passwordEdit->setPlaceholderText(QString());
}
}
@ -375,11 +356,10 @@ void DlgConnect::actOk()
{
ServersSettings &servers = SettingsCache::instance().servers();
// Never write a newly typed plaintext password to disk when a verifier is available: the
// typed value is used for this connection and a fresh verifier is persisted after a
// successful login. Without any verifier we keep the previous (plaintext legacy) behavior.
const QString verifierToSave = storedVerifier.isEmpty() ? savedVerifier : storedVerifier;
const QString passwordToSave = verifierToSave.isEmpty() ? passwordEdit->text() : verifierToSave;
// Never write a newly typed plaintext password to disk when a verifier is already stored:
// the typed value is used for this connection and a fresh verifier is persisted after a
// successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior.
const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier;
if (newHostButton->isChecked()) {
if (saveEdit->text().isEmpty()) {

View file

@ -98,10 +98,6 @@ private:
QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer;
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
HandlePublicServers *hps;
//! \brief Saved "$scrypt$..." verifier as loaded for the selected profile, kept so that
//! re-ticking "Save password" after unticking restores it.
QString savedVerifier;
//! \brief Verifier in effect for this connection: empty while "Save password" is unticked.
QString storedVerifier;
const QString placeHolderText = tr("Downloading...");
};

View file

@ -40,7 +40,6 @@ class QWidget;
DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr)
{
setObjectName("editTokensDialog");
nameLabel = new QLabel(tr("&Name:"));
nameEdit = new QLineEdit;
nameEdit->setMaxLength(MAX_NAME_LENGTH);

View file

@ -28,16 +28,12 @@ class QWidget;
*/
AbstractDlgDeckTextEdit::AbstractDlgDeckTextEdit(QWidget *parent) : QDialog(parent)
{
setObjectName("dlgDeckTextEdit");
contentsEdit = new QPlainTextEdit;
contentsEdit->setObjectName("contentsEdit");
refreshButton = new QPushButton(tr("&Refresh"));
connect(refreshButton, &QPushButton::clicked, this, &AbstractDlgDeckTextEdit::actRefresh);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
buttonBox->setObjectName("buttonBox");
buttonBox->addButton(refreshButton, QDialogButtonBox::ActionRole);
connect(buttonBox, &QDialogButtonBox::accepted, this, &AbstractDlgDeckTextEdit::actOK);
connect(buttonBox, &QDialogButtonBox::rejected, this, &AbstractDlgDeckTextEdit::reject);

View file

@ -16,11 +16,8 @@ class QWidget;
DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
{
setObjectName("localGameDialog");
numberPlayersLabel = new QLabel(tr("Players:"), this);
numberPlayersEdit = new QSpinBox(this);
numberPlayersEdit->setObjectName("numberPlayersEdit");
numberPlayersEdit->setMinimum(1);
numberPlayersEdit->setMaximum(8);
numberPlayersEdit->setValue(1);
@ -31,12 +28,10 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
generalGrid->addWidget(numberPlayersLabel, 0, 0);
generalGrid->addWidget(numberPlayersEdit, 0, 1);
generalGroupBox = new QGroupBox(tr("General"), this);
generalGroupBox->setObjectName("generalGroupBox");
generalGroupBox->setLayout(generalGrid);
startingLifeTotalLabel = new QLabel(tr("Starting life total:"), this);
startingLifeTotalEdit = new QSpinBox(this);
startingLifeTotalEdit->setObjectName("startingLifeTotalEdit");
startingLifeTotalEdit->setMinimum(-999999999);
startingLifeTotalEdit->setMaximum(999999999);
startingLifeTotalEdit->setValue(20);
@ -47,14 +42,11 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
gameSetupGrid->addWidget(startingLifeTotalLabel, 0, 0);
gameSetupGrid->addWidget(startingLifeTotalEdit, 0, 1);
gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"), this);
gameSetupOptionsGroupBox->setObjectName("gameSetupOptionsGroupBox");
gameSetupOptionsGroupBox->setLayout(gameSetupGrid);
rememberSettingsCheckBox = new QCheckBox(tr("Remember settings"), this);
rememberSettingsCheckBox->setObjectName("rememberSettingsCheckBox");
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttonBox->setObjectName("buttonBox");
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgLocalGameOptions::actOK);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgLocalGameOptions::reject);

View file

@ -48,7 +48,6 @@
WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
{
setObjectName("manageSetsWindow");
setOrderIsSorted = false;
// left toolbar
@ -222,7 +221,6 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
WndSets::~WndSets()
{
disconnect(view->header(), &QHeaderView::geometriesChanged, this, &WndSets::saveHeaderState);
}
void WndSets::closeEvent(QCloseEvent * /*ev*/)

View file

@ -73,8 +73,6 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
resize(calculateDialogSize(hostSize, available.size(), minimum));
setSizeGripEnabled(true);
setObjectName("settingsDialog");
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
settingsView = new SettingsView(this);
@ -84,7 +82,6 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
buttonBox->addStretch();
okButton = new QPushButton;
okButton->setDefault(true);
okButton->setObjectName("settingsOkButton");
connect(okButton, &QPushButton::clicked, this, &DlgSettings::close);
buttonBox->addWidget(okButton);

View file

@ -28,7 +28,6 @@
DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent)
{
setObjectName("tipOfTheDayDialog");
successfulInit = false;
QString xmlPath = "theme:tips/tips_of_the_day.xml";
tipDatabase = new TipsOfTheDay(xmlPath, this);

View file

@ -42,7 +42,6 @@ static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe
DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent)
{
setObjectName("updateDialog");
// Handle layout
statusLabel = new QLabel(this);

View file

@ -18,7 +18,6 @@ class QWidget;
DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent)
{
setObjectName("viewLogDialog");
logArea = new QPlainTextEdit;
logArea->setReadOnly(true);

View file

@ -120,8 +120,6 @@ QWidget *fromWidgetId(WidgetId id)
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
{
setObjectName("settingsView");
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
@ -138,14 +136,12 @@ void SettingsView::setupUi()
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
searchEdit->setObjectName("settingsSearchEdit");
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);
// Pages stacked widget
pagesWidget = new QStackedWidget;
pagesWidget->setObjectName("pagesWidget");
auto *generalPage = new GeneralSettingsPage;
auto *appearancePage = new AppearanceSettingsPage;
@ -214,7 +210,6 @@ void SettingsView::setupUi()
// Pages container (stacked widget + search results overlay)
pagesContainer = new QWidget;
pagesContainer->setObjectName("pagesContainer");
auto *containerLayout = new QStackedLayout;
containerLayout->setStackingMode(QStackedLayout::StackAll);
containerLayout->addWidget(pagesWidget);
@ -254,7 +249,6 @@ void SettingsView::setupUi()
void SettingsView::setupNavTree()
{
navTree = new QTreeWidget;
navTree->setObjectName("navTree");
navTree->setHeaderHidden(true);
navTree->setRootIsDecorated(true);
navTree->setIconSize(QSize(24, 24));

View file

@ -68,7 +68,6 @@
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
: QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home")))
{
setObjectName("homeWidget");
layout = new QGridLayout(this);
backgroundSourceCard = new CardInfoPictureArtCropWidget(this);
@ -288,7 +287,6 @@ void HomeWidget::updateButtonsToBackgroundColor()
QGroupBox *HomeWidget::createButtons()
{
QGroupBox *box = new QGroupBox(this);
box->setObjectName("homeButtonsBox");
box->setStyleSheet(R"(
QGroupBox {
color: white; /* Title text color */
@ -320,34 +318,27 @@ QGroupBox *HomeWidget::createButtons()
boxLayout->addSpacing(25);
connectButton = new HomeStyledButton(QString(), gradientColors);
connectButton->setObjectName("homeConnectButton");
boxLayout->addWidget(connectButton);
visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors);
visualDeckEditorButton->setObjectName("homeCreateNewDeckButton");
connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor,
[this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); });
boxLayout->addWidget(visualDeckEditorButton);
visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors);
visualDeckStorageButton->setObjectName("homeBrowseDecksButton");
connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor,
[this] { tabSupervisor->actTabVisualDeckStorage(true); });
boxLayout->addWidget(visualDeckStorageButton);
visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors);
visualDatabaseDisplayButton->setObjectName("homeBrowseCardDatabaseButton");
connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor,
&TabSupervisor::addVisualDatabaseDisplayTab);
boxLayout->addWidget(visualDatabaseDisplayButton);
edhrecButton = new HomeStyledButton(QString(), gradientColors);
edhrecButton->setObjectName("homeBrowseEdhrecButton");
connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab);
boxLayout->addWidget(edhrecButton);
archidektButton = new HomeStyledButton(QString(), gradientColors);
archidektButton->setObjectName("homeBrowseArchidektButton");
connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab);
boxLayout->addWidget(archidektButton);
replayButton = new HomeStyledButton(QString(), gradientColors);
replayButton->setObjectName("homeViewReplaysButton");
connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); });
boxLayout->addWidget(replayButton);
if (qobject_cast<MainWindow *>(tabSupervisor->parentWidget())) {

View file

@ -57,7 +57,7 @@ ReplayManager::ReplayManager(QObject *parent, GameReplay *replay)
rewindBufferingTimer = new QTimer(this);
rewindBufferingTimer->setSingleShot(true);
connect(rewindBufferingTimer, &QTimer::timeout, this, [this] { processRewind(); });
connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind);
}
ReplayManager::~ReplayManager()
@ -65,7 +65,7 @@ ReplayManager::~ReplayManager()
delete replay;
}
void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime)
void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
{
// check boundary conditions
if (newTime < 0) {
@ -75,17 +75,15 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool include
newTime = maxTime;
}
if (!includeEventsAtTime) {
newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
}
newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
const bool isBackwardsSkip = newTime < currentProcessedTime;
currentVisualTime = newTime;
if (isBackwardsSkip) {
handleBackwardsSkip(doRewindBuffering, includeEventsAtTime);
handleBackwardsSkip(doRewindBuffering);
} else {
processNewEvents(FORWARD_SKIP, includeEventsAtTime);
processNewEvents(FORWARD_SKIP);
}
timeChanged(currentVisualTime);
@ -97,7 +95,7 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool include
* @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind
* is processed at the end. When false, the backwards skip will always cause an immediate rewind.
*/
void ReplayManager::handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime)
void ReplayManager::handleBackwardsSkip(bool doRewindBuffering)
{
if (doRewindBuffering) {
// We use a one-shot timer to implement the rewind buffering.
@ -107,11 +105,11 @@ void ReplayManager::handleBackwardsSkip(bool doRewindBuffering, bool includeEven
rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs());
} else {
// otherwise, process the rewind immediately
processRewind(includeEventsAtTime);
processRewind();
}
}
void ReplayManager::processRewind(bool includeEventsAtTime)
void ReplayManager::processRewind()
{
// stop any queued-up rewinds
rewindBufferingTimer->stop();
@ -119,7 +117,7 @@ void ReplayManager::processRewind(bool includeEventsAtTime)
// process the rewind
currentEvent = 0;
emit rewound();
processNewEvents(BACKWARD_SKIP, includeEventsAtTime);
processNewEvents(BACKWARD_SKIP);
}
void ReplayManager::replayTimerTimeout()
@ -136,13 +134,11 @@ void ReplayManager::replayTimerTimeout()
}
/** @brief Processes all unprocessed events up to the current time. */
void ReplayManager::processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime)
void ReplayManager::processNewEvents(PlaybackMode playbackMode)
{
currentProcessedTime = currentVisualTime;
while (currentEvent < replayTimeline.size() &&
(replayTimeline[currentEvent] < currentProcessedTime ||
(includeEventsAtTime && replayTimeline[currentEvent] == currentProcessedTime))) {
while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) {
EventProcessingOptions options;
// backwards skip => always skip reveal windows
@ -240,24 +236,6 @@ void ReplayManager::setTime(int time)
skipToTime(time, false);
}
void ReplayManager::setTimeToEvent(int eventIndex)
{
if (eventIndex < 0 || eventIndex >= replayTimeline.size()) {
return;
}
// Flush a pending buffered rewind before seeking, otherwise its timer fires after this seek and rebuilds the
// replay state from an outdated currentVisualTime, dropping the event we seek to. Stopping the timer instead
// of processing it is not enough: playback ticks advance currentProcessedTime without rewinding currentEvent,
// so the board could still be ahead of the target.
if (rewindBufferingTimer->isActive()) {
processRewind();
}
// Unlike a timeline seek, jumping to an event should apply that event immediately.
skipToTime(replayTimeline.at(eventIndex), false, true);
}
void ReplayManager::skipByAmount(int amount)
{
skipToTime(currentVisualTime + amount, amount < 0);

View file

@ -40,10 +40,10 @@ class ReplayManager : public QObject
///< to rewind buffering
int currentEvent = 0; ///< current event's index
void skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime = false);
void handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime = false);
void processRewind(bool includeEventsAtTime = false);
void processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime = false);
void skipToTime(int newTime, bool doRewindBuffering);
void handleBackwardsSkip(bool doRewindBuffering);
void processRewind();
void processNewEvents(PlaybackMode playbackMode);
void handleSkipEmptySection();
private slots:
@ -73,7 +73,6 @@ public slots:
void startReplay();
void stopReplay();
void setTime(int time);
void setTimeToEvent(int eventIndex);
void skipByAmount(int amount); // use a negative amount to skip backwards
signals:

View file

@ -28,7 +28,6 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed);
connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished);
connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound);
connect(replayManager, &ReplayManager::timeChanged, this, &ReplayWidget::timeChanged);
// timeline widget
timelineWidget = new ReplayTimelineWidget;
@ -97,16 +96,6 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
refreshShortcuts();
}
const QList<int> &ReplayWidget::getReplayTimeline() const
{
return replayManager->getReplayTimeline();
}
void ReplayWidget::seekToEvent(int eventIndex)
{
replayManager->setTimeToEvent(eventIndex);
}
void ReplayWidget::replayFinished()
{
replayPlayButton->setChecked(false);

View file

@ -8,7 +8,6 @@
#ifndef REPLAY_WIDGET_H
#define REPLAY_WIDGET_H
#include <QList>
#include <QWidget>
#include <libcockatrice/game/player/event_processing_options.h>
#include <qtmetamacros.h>
@ -36,14 +35,8 @@ public:
*/
ReplayWidget(QWidget *parent, GameReplay *replay);
[[nodiscard]] const QList<int> &getReplayTimeline() const;
public slots:
void seekToEvent(int eventIndex);
signals:
void rewound();
void timeChanged(int time);
void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options);
private:

View file

@ -1,149 +0,0 @@
#include "user_games_model.h"
#include <QVariant>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <utility>
UserGamesModel::UserGamesModel(AbstractClient *_client, int requestTimeoutMs, QObject *parent)
: QAbstractListModel(parent), client(_client)
{
timeout.setSingleShot(true);
timeout.setInterval(requestTimeoutMs);
connect(&timeout, &QTimer::timeout, this, [this] {
invalidateRequest();
setState(State::Error);
});
connected = client->getStatus() == StatusLoggedIn;
connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) {
// AbstractClient::setStatus emits this signal while holding the (non-recursive)
// status mutex, which getStatus() would try to lock again. Track the emitted
// value here, and read this cache from canRefresh() for the same reason.
connected = status == StatusLoggedIn;
if (!connected) {
invalidateRequest();
replaceGames({});
setState(online ? State::Disconnected : State::Offline);
} else if (online) {
refresh();
}
});
connect(client, &QObject::destroyed, this, [this] {
client = nullptr;
connected = false;
invalidateRequest();
replaceGames({});
setState(online ? State::Disconnected : State::Offline);
});
}
int UserGamesModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : games.size();
}
QVariant UserGamesModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.model() != this || index.column() != 0 || index.row() < 0 ||
index.row() >= games.size() || role != GameRole) {
return {};
}
return QVariant::fromValue(games.at(index.row()));
}
void UserGamesModel::setUser(const QString &name, bool isOnline)
{
userName = name;
online = isOnline;
refresh();
}
UserGamesModel::State UserGamesModel::getState() const
{
return state;
}
bool UserGamesModel::canRefresh() const
{
return online && !userName.isEmpty() && client && connected && state != State::Loading;
}
void UserGamesModel::invalidateRequest()
{
++requestGeneration;
// The client owns pending commands. Detach our interest without deleting
// an object that may already have moved to the client's thread.
disconnect(responseConnection);
responseConnection = {};
timeout.stop();
}
void UserGamesModel::replaceGames(QList<ServerInfo_Game> replacement)
{
beginResetModel();
games = std::move(replacement);
endResetModel();
}
void UserGamesModel::setState(State newState)
{
state = newState;
emit stateChanged();
}
void UserGamesModel::refresh()
{
invalidateRequest();
const quint64 request = requestGeneration;
replaceGames({});
if (!online || userName.isEmpty()) {
setState(State::Offline);
return;
}
if (!client || !connected) {
setState(State::Disconnected);
return;
}
setState(State::Loading);
// State listeners may select another user or disconnect the client.
if (request != requestGeneration) {
return;
}
Command_GetGamesOfUser command;
command.set_user_name(userName.toStdString());
PendingCommand *pending = client->prepareSessionCommand(command);
responseConnection = connect(pending, &PendingCommand::finished, this,
[this, request](const Response &response) { finishRequest(response, request); });
timeout.start();
client->sendCommand(pending);
}
void UserGamesModel::finishRequest(const Response &response, quint64 request)
{
if (request != requestGeneration) {
return;
}
invalidateRequest();
const quint64 completion = requestGeneration;
if (response.response_code() != Response::RespOk || !response.HasExtension(Response_GetGamesOfUser::ext)) {
setState(State::Error);
return;
}
const auto &result = response.GetExtension(Response_GetGamesOfUser::ext);
QList<ServerInfo_Game> replacement;
replacement.reserve(result.game_list_size());
for (const auto &game : result.game_list()) {
replacement.append(game);
}
replaceGames(std::move(replacement));
if (completion != requestGeneration) {
return;
}
setState(games.isEmpty() ? State::Empty : State::Ready);
}

View file

@ -1,63 +0,0 @@
#ifndef COCKATRICE_USER_GAMES_MODEL_H
#define COCKATRICE_USER_GAMES_MODEL_H
#include <QAbstractListModel>
#include <QList>
#include <QPointer>
#include <QString>
#include <QTimer>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
class AbstractClient;
class Response;
/** Current user's games and the lifetime of the request that supplies them. */
class UserGamesModel : public QAbstractListModel
{
Q_OBJECT
public:
enum class State
{
Offline,
Disconnected,
Loading,
Ready,
Empty,
Error
};
static constexpr int DefaultRequestTimeoutMs = 15000;
static constexpr int GameRole = Qt::UserRole + 10;
explicit UserGamesModel(AbstractClient *client,
int requestTimeoutMs = DefaultRequestTimeoutMs,
QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
void setUser(const QString &name, bool isOnline);
void refresh();
State getState() const;
bool canRefresh() const;
signals:
void stateChanged();
private:
void invalidateRequest();
void replaceGames(QList<ServerInfo_Game> replacement);
void finishRequest(const Response &response, quint64 request);
void setState(State newState);
QPointer<AbstractClient> client;
QString userName;
bool online = false;
bool connected = false;
State state = State::Offline;
QList<ServerInfo_Game> games;
quint64 requestGeneration = 0;
QMetaObject::Connection responseConnection;
QTimer timeout;
};
#endif // COCKATRICE_USER_GAMES_MODEL_H

View file

@ -6,9 +6,10 @@
#include "../../interface/widgets/tabs/tab_supervisor.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_game.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "user_games_model.h"
#include "libcockatrice/protocol/pb/session_commands.pb.h"
#include "user_level.h"
#include "user_list_painter.h"
@ -33,7 +34,6 @@
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPersistentModelIndex>
#include <QPoint>
#include <QPushButton>
#include <QRect>
@ -41,6 +41,8 @@
#include <QSharedPointer>
#include <QSize>
#include <QSizePolicy>
#include <QStandardItem>
#include <QStandardItemModel>
#include <QStringLiteral>
#include <QStyle>
#include <QStyleOptionViewItem>
@ -50,6 +52,8 @@
#include <QtGlobal>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/card_ref.h>
#include <libcockatrice/utility/qt_utils.h>
#include <qnumeric.h>
@ -427,7 +431,6 @@ void UserInfoPopup::buildUi()
refreshBtn = new QPushButton(QStringLiteral("↻"), this);
refreshBtn->setFixedSize(20, 20);
refreshBtn->setFlat(true);
refreshBtn->setToolTip(tr("Refresh games"));
connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames);
gh->addWidget(refreshBtn);
root->addLayout(gh);
@ -438,8 +441,7 @@ void UserInfoPopup::buildUi()
root->addWidget(gamesStatus);
// Games list
gamesModel = new UserGamesModel(client, UserGamesModel::DefaultRequestTimeoutMs, this);
connect(gamesModel, &UserGamesModel::stateChanged, this, &UserInfoPopup::updateGamesDisplay);
gamesModel = new QStandardItemModel(this);
gamesView = new QListView(this);
gamesView->setModel(gamesModel);
gamesView->setItemDelegate(new PopupGameDelegate(gamesView));
@ -663,21 +665,13 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
{
currentUserInfo = userInfo;
currentBuddy = isBuddy;
currentIgnored = isIgnored;
if (currentOnline != online) {
setOnline(online);
} else {
refreshHeader();
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
adjustSize();
}
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
adjustSize();
}
void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
{
const QPersistentModelIndex idx = gamesView->indexAt(pos);
const QModelIndex idx = gamesView->indexAt(pos);
if (!idx.isValid()) {
return;
}
@ -705,7 +699,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
}
const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos));
if (!chosen || !idx.isValid()) {
if (!chosen) {
return;
}
@ -742,8 +736,6 @@ void UserInfoPopup::showForUser(const QString &userName,
currentUser = userName;
currentUserInfo = userInfo;
currentOnline = online;
currentBuddy = isBuddy;
currentIgnored = isIgnored;
// Header
refreshHeader();
@ -751,71 +743,88 @@ void UserInfoPopup::showForUser(const QString &userName,
// Actions
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
// Games list reset
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
// Close button, top right corner, above everything
closeBtn->move(PopupWidth - closeBtn->width() - 6, 6);
closeBtn->raise();
gamesModel->setUser(currentUser, currentOnline);
adjustSize();
fetchGames();
}
// ── Games fetch ───────────────────────────────────────────────────────────────
void UserInfoPopup::fetchGames()
{
gamesModel->refresh();
}
void UserInfoPopup::setOnline(bool online)
{
if (currentOnline == online) {
if (!client || currentUser.isEmpty()) {
return;
}
currentOnline = online;
refreshHeader();
rebuildActionButtons(currentUserInfo, online, currentBuddy, currentIgnored);
gamesModel->setUser(currentUser, online);
Command_GetGamesOfUser cmd;
cmd.set_user_name(currentUser.toStdString());
const QString snapshot = currentUser;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, snapshot](const Response &r) { onGamesReceived(r, snapshot); });
client->sendCommand(pend);
}
void UserInfoPopup::updateGamesDisplay()
void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
{
refreshBtn->setEnabled(gamesModel->canRefresh());
const auto state = gamesModel->getState();
const bool ready = state == UserGamesModel::State::Ready;
gamesView->setVisible(ready);
gamesStatus->setVisible(!ready);
switch (state) {
case UserGamesModel::State::Offline:
gamesStatus->setText(tr("User is offline."));
break;
case UserGamesModel::State::Disconnected:
gamesStatus->setText(tr("Not connected to the server."));
break;
case UserGamesModel::State::Loading:
gamesStatus->setText(tr("Loading games…"));
break;
case UserGamesModel::State::Empty:
gamesStatus->setText(tr("No active games."));
break;
case UserGamesModel::State::Error:
gamesStatus->setText(tr("Could not load games."));
break;
case UserGamesModel::State::Ready: {
// Fit exactly to the number of visible rows, scroll when more than 5.
constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint
constexpr int maxRows = 5;
const int count = gamesModel->rowCount();
gamesView->setFixedHeight(qMin(count, maxRows) * rowH + 2);
gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
break;
}
if (forUser != currentUser) {
return; // stale response, different user showing now
}
gamesModel->clear();
if (r.response_code() != Response::RespOk) {
gamesStatus->setText(tr("Could not load games."));
gamesStatus->show();
gamesView->hide();
return;
}
const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext);
if (resp.game_list_size() == 0) {
gamesStatus->setText(tr("No active games."));
gamesStatus->show();
gamesView->hide();
return;
}
for (int i = 0; i < resp.game_list_size(); ++i) {
auto *item = new QStandardItem;
item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData);
item->setEditable(false);
gamesModel->appendRow(item);
}
gamesStatus->hide();
gamesView->show();
// Fit exactly to the number of visible rows, scroll when more than 5
constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint
constexpr int maxRows = 5;
const int count = gamesModel->rowCount();
const int visible = qMin(count, maxRows);
gamesView->setFixedHeight(visible * rowH + 2);
gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
adjustSize();
}
void UserInfoPopup::refreshGames()
{
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
fetchGames();
}

View file

@ -1,7 +1,6 @@
#ifndef COCKATRICE_USER_INFO_POPUP_H
#define COCKATRICE_USER_INFO_POPUP_H
#include "user_games_model.h"
#include "user_list_painter.h"
#include <QColor>
@ -22,13 +21,15 @@ class QPushButton;
class TabSupervisor;
class QListView;
class QPoint;
class QStandardItemModel;
class Response;
template <class Key, class T> class QMap;
// ── Roles ─────────────────────────────────────────────────────────────────────
namespace PopupRoles
{
constexpr int GameData = UserGamesModel::GameRole;
constexpr int GameData = Qt::UserRole + 10;
}
// Popup theme
@ -142,7 +143,6 @@ public:
void
showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
void fetchGames();
void setOnline(bool online);
[[nodiscard]] QString getCurrentUser() const
{
@ -199,7 +199,7 @@ protected:
private slots:
void refreshGames();
void updateGamesDisplay();
void onGamesReceived(const Response &r, const QString &forUser);
void onGamesContextMenu(const QPoint &pos);
private:
@ -218,8 +218,6 @@ private:
QString currentUser;
ServerInfo_User currentUserInfo;
bool currentOnline = false;
bool currentBuddy = false;
bool currentIgnored = false;
std::function<bool(const QString &userName)> gameInviteAvailable;
UserInfoHeaderWidget *header;
@ -227,7 +225,7 @@ private:
QLabel *gamesLabel;
QFrame *separator;
QListView *gamesView;
UserGamesModel *gamesModel;
QStandardItemModel *gamesModel;
QLabel *gamesStatus;
QPushButton *closeBtn;
QPushButton *refreshBtn;

View file

@ -630,14 +630,7 @@ void UserListWidget::bind(UserListManager *mgr)
connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup);
connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen);
connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup);
connect(manager, &UserListManager::userLeftOnline, this, [this, refreshIfPopupOpen](const QString &name) {
// The online row may already have been removed; the pinned popup must
// still invalidate its games request and display the offline state.
if (userInfoPopup && userInfoPopup->getCurrentUser() == name) {
userInfoPopup->setOnline(false);
}
refreshIfPopupOpen(name);
});
connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen);
rebuild();
}

View file

@ -25,7 +25,6 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValueRef>
#include <QLabel>
#include <QLayoutItem>
@ -61,6 +60,8 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
{
// Initialize network
networkManager = NetworkManagerFactory::create(this);
networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson);
// Initialize debounce timer
searchDebounceTimer = new QTimer(this);
@ -76,48 +77,6 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
getTopDecks();
}
TabArchidekt::~TabArchidekt()
{
cancelRequest();
}
void TabArchidekt::cancelRequest()
{
const QPointer<QNetworkReply> previousReply = activeReply;
activeReply = nullptr;
isLoadingMore = false;
if (previousReply) {
disconnect(previousReply, nullptr, this, nullptr);
previousReply->abort();
previousReply->deleteLater();
}
}
void TabArchidekt::startRequest(QUrl url, RequestKind kind, int page)
{
cancelRequest();
requestUrl = url;
requestKind = kind;
requestedPage = page;
isLoadingMore = true;
retryButton->hide();
requestStatusLabel->setText(kind == RequestKind::Deck ? tr("Loading deck…") : tr("Loading decks…"));
requestStatusLabel->show();
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
auto *reply = networkManager->get(request);
activeReply = reply;
connect(reply, &QNetworkReply::finished, this, [this, reply]() { processApiJson(reply); });
}
void TabArchidekt::showRequestError(const QString &error)
{
requestStatusLabel->setText(tr("Unable to load decks: %1").arg(error));
requestStatusLabel->show();
retryButton->show();
}
void TabArchidekt::initializeUi()
{
// Main container
@ -235,18 +194,6 @@ void TabArchidekt::initializeUi()
mainLayout->addWidget(primaryToolbar);
mainLayout->addWidget(secondaryToolbar);
auto *requestStatusRow = new QHBoxLayout;
requestStatusLabel = new QLabel(container);
requestStatusLabel->setTextFormat(Qt::PlainText);
requestStatusLabel->setWordWrap(true);
retryButton = new QPushButton(tr("Retry"), container);
retryButton->hide();
connect(retryButton, &QPushButton::clicked, this,
[this]() { startRequest(requestUrl, requestKind, requestedPage); });
requestStatusRow->addWidget(requestStatusLabel, 1);
requestStatusRow->addWidget(retryButton);
mainLayout->addLayout(requestStatusRow);
mainLayout->addWidget(scrollArea);
setCentralWidget(container);
@ -457,7 +404,6 @@ void TabArchidekt::retranslateUi()
minDeckSizeLogicCombo->setItemText(0, tr("Exact"));
searchButton->setText(tr("Search"));
retryButton->setText(tr("Retry"));
settingsButton->setToolTip(tr("Display Settings"));
}
@ -571,95 +517,82 @@ QString TabArchidekt::buildSearchUrl()
void TabArchidekt::doSearch()
{
// Reset to first page on new search
currentPage = 1;
// We're searching, so we'll be in list mode
isListMode = true;
// Don't debounce - only called by explicit user actions now
doSearchImmediate();
}
void TabArchidekt::doSearchImmediate()
{
searchDebounceTimer->stop();
currentPage = 1;
startRequest(QUrl(buildSearchUrl()), RequestKind::Search);
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
}
void TabArchidekt::loadNextPage()
{
if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) {
if (isLoadingMore) {
return;
}
// Continue the accepted search, even if the user has edited filters without submitting them.
// Advance currentPage only after this response has been accepted, so failures can retry it.
startRequest(nextPageUrl, RequestKind::NextPage, currentPage + 1);
isLoadingMore = true;
currentPage++;
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
}
void TabArchidekt::actNavigatePage(QString url)
{
searchDebounceTimer->stop();
startRequest(QUrl(url), RequestKind::Deck);
QNetworkRequest request{QUrl(url)};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request);
}
void TabArchidekt::getTopDecks()
{
doSearchImmediate();
currentPage = 1;
QNetworkRequest request{QUrl(buildSearchUrl())};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request);
}
void TabArchidekt::processApiJson(QNetworkReply *reply)
{
if (reply != activeReply) {
reply->deleteLater();
return;
}
activeReply = nullptr;
isLoadingMore = false;
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
showRequestError(reply->errorString());
isLoadingMore = false;
reply->deleteLater();
return;
}
QByteArray responseData = reply->readAll();
QJsonParseError parseError;
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError);
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) {
showRequestError(tr("Invalid response from Archidekt."));
if (!jsonDoc.isObject()) {
isLoadingMore = false;
reply->deleteLater();
return;
}
QJsonObject jsonObj = jsonDoc.object();
if (requestKind != RequestKind::Deck) {
if (!jsonObj.value("results").isArray() ||
(!jsonObj.value("next").isNull() && !jsonObj.value("next").isString())) {
showRequestError(tr("Invalid deck listing from Archidekt."));
return;
}
for (const auto &value : jsonObj.value("results").toArray()) {
if (!value.isObject() || value.toObject().value("id").toInt() <= 0) {
showRequestError(tr("Invalid deck listing from Archidekt."));
return;
}
}
currentPage = requestedPage;
const QString next = jsonObj.value("next").toString();
nextPageUrl = next.isEmpty() ? QUrl() : reply->url().resolved(QUrl(next));
// An empty or self-repeating page cannot make forward progress.
if (jsonObj.value("results").toArray().isEmpty() || nextPageUrl == reply->url()) {
nextPageUrl.clear();
}
requestStatusLabel->setText(
currentPage == 1 && jsonObj.value("results").toArray().isEmpty() ? tr("No decks found.") : QString());
requestStatusLabel->setVisible(!requestStatusLabel->text().isEmpty());
QString responseUrl = reply->url().toString();
if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) {
processTopDecksResponse(jsonObj);
} else {
if (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) {
showRequestError(tr("Invalid deck from Archidekt."));
return;
}
requestStatusLabel->hide();
} else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) {
processDeckResponse(jsonObj);
} else {
prettyPrintJson(jsonObj, 4);
}
isLoadingMore = false;
reply->deleteLater();
}
void TabArchidekt::processTopDecksResponse(QJsonObject reply)
@ -667,12 +600,8 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply)
ArchidektDeckListingApiResponse deckData;
deckData.fromJson(reply);
// An accepted listing restores list mode; a failed deck navigation leaves the listing usable.
isListMode = true;
// New search → clear everything
if (requestKind == RequestKind::Search) {
listingsWidget = nullptr;
if (currentPage == 1) {
QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate);
listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider);
@ -697,10 +626,8 @@ void TabArchidekt::processDeckResponse(QJsonObject reply)
// We're in single deck mode - disable infinite scroll
isListMode = false;
nextPageUrl.clear();
// Clear existing results for single deck view
listingsWidget = nullptr;
QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate);
auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider);

View file

@ -8,10 +8,8 @@
#include <QJsonObject>
#include <QJsonValue>
#include <QList>
#include <QPointer>
#include <QSet>
#include <QString>
#include <QUrl>
#include <QVector>
#include <qtmetamacros.h>
@ -57,7 +55,6 @@ public:
* connects signals and slots, and triggers an initial fetch of top decks.
*/
explicit TabArchidekt(TabSupervisor *_tabSupervisor);
~TabArchidekt() override;
/**
* @brief Update all UI text to reflect the current language or translation
@ -193,17 +190,6 @@ protected:
bool eventFilter(QObject *obj, QEvent *event) override;
private:
enum class RequestKind
{
Search,
NextPage,
Deck
};
void startRequest(QUrl url, RequestKind kind, int page = 1);
void cancelRequest();
void showRequestError(const QString &error);
/**
* @brief Initialize the main UI layout and toolbars
*
@ -250,14 +236,7 @@ private:
int currentPage; ///< Current page number for infinite scroll
bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads
bool isListMode;
QPointer<QNetworkReply> activeReply;
RequestKind requestKind = RequestKind::Search;
QUrl requestUrl;
QUrl nextPageUrl;
int requestedPage = 1;
QPointer<ArchidektApiResponseDeckListingsDisplayWidget> listingsWidget;
QLabel *requestStatusLabel;
QPushButton *retryButton;
ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr;
// ---------------------------------------------------------------------
// Layout Containers

View file

@ -26,11 +26,6 @@ quint64 CommanderBracketService::estimateBracket(const DeckList &deck, QObject *
return CommanderSpellbookApiAccessor::instance().estimateBracket(deck, requester);
}
void CommanderBracketService::cancelEstimate(quint64 requestId)
{
CommanderSpellbookApiAccessor::instance().cancelEstimate(requestId);
}
void CommanderBracketService::onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id,
QObject *requester,
const EstimateBracketResult &result)

View file

@ -30,7 +30,6 @@ public:
static CommanderBracketService &instance();
quint64 estimateBracket(const DeckList &deck, QObject *requester);
void cancelEstimate(quint64 requestId);
signals:
void estimateFinished(quint64 requestId, QObject *requester, const CommanderBracketEstimate &estimate);

View file

@ -28,18 +28,9 @@ template <class T> class QSharedPointer;
CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent)
{
autoEstimateTimer = new QTimer(this);
autoEstimateTimer->setSingleShot(true);
connect(autoEstimateTimer, &QTimer::timeout, this, [this]() {
if (SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled() ==
commanderSpellbookIntegrationEnabledIndexAutomatic) {
requestBracketEstimate();
}
});
bracketLabel = new QLabel(tr("Bracket:"), this);
bracketValueLabel = new QLabel(this);
bracketValueLabel->setTextFormat(Qt::PlainText);
bracketValueLabel->setText("-");
bracketValueLabel->setObjectName("bracketValueLabel");
@ -74,38 +65,18 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent
this, &CommanderBracketWidget::maybeAutoEstimateBracket);
connect(&SettingsCache::instance().deckEditor(),
&DeckEditorSettings::commanderSpellbookIntegrationUseOfficialBracketNamesChanged, this,
&CommanderBracketWidget::displayEstimate);
&CommanderBracketWidget::maybeAutoEstimateBracket);
setVisible(false);
}
CommanderBracketWidget::~CommanderBracketWidget()
{
cancelEstimate();
}
void CommanderBracketWidget::cancelEstimate()
{
autoEstimateTimer->stop();
const quint64 canceledRequest = requestId;
requestId = 0;
CommanderBracketService::instance().cancelEstimate(canceledRequest);
bracketRefreshButton->setEnabled(true);
if (canceledRequest) {
bracketValueLabel->setText("-");
bracketValueLabel->setToolTip({});
}
}
void CommanderBracketWidget::setDeck(const QSharedPointer<DeckList> &_deck)
{
cancelEstimate();
deck = _deck;
hasEstimate = false;
requestId = 0; // invalidate any in-flight estimate for the previous deck
// Reset the displayed bracket
bracketValueLabel->setText("-");
bracketValueLabel->setToolTip({});
bracketInfoButton->setToolTip({});
bracketInfoButton->setEnabled(false);
bracketRefreshButton->setEnabled(true);
@ -173,7 +144,7 @@ bool CommanderBracketWidget::promptCommanderSpellbookIntegration()
dialog.exec();
// Persist naming choice (if not disabled)
if (clickedButton == enableBtn || clickedButton == automaticBtn) {
if (clickedButton != disableBtn) {
bool useOfficial = namingCombo->currentIndex() == 1;
SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(useOfficial);
}
@ -205,20 +176,9 @@ void CommanderBracketWidget::updateBracketVisibility(bool visible)
void CommanderBracketWidget::requestBracketEstimate()
{
const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled();
if (!deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0 || requestId ||
(mode != commanderSpellbookIntegrationEnabledIndexEnabled &&
mode != commanderSpellbookIntegrationEnabledIndexAutomatic)) {
return;
}
autoEstimateTimer->stop();
hasEstimate = false;
bracketRefreshButton->setEnabled(false);
bracketInfoButton->setEnabled(false);
bracketInfoButton->setToolTip({});
bracketValueLabel->setText(tr("Calculating…"));
bracketValueLabel->setToolTip({});
requestId = CommanderBracketService::instance().estimateBracket(*deck, this);
}
@ -231,35 +191,22 @@ void CommanderBracketWidget::onEstimateBracketFinished(quint64 id,
return;
}
requestId = 0;
hasEstimate = true;
lastEstimate = result;
displayEstimate();
bracketRefreshButton->setEnabled(true);
}
void CommanderBracketWidget::displayEstimate()
{
if (!hasEstimate) {
bracketInfoButton->setToolTip(tr("Why this bracket?"));
return;
}
BracketExplainer explainer;
lastBracketExplanation = explainer.explain(lastEstimate.rawResult);
lastBracketExplanation = explainer.explain(result.rawResult);
// Display bracket
bracketValueLabel->setText(
SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames()
? lastEstimate.officialName
: lastEstimate.displayName);
? result.officialName
: result.displayName);
bracketRefreshButton->setEnabled(true);
// Build tooltip
QString tooltip;
for (const auto &section : lastBracketExplanation.sections) {
tooltip += "<b>" + section.title.toHtmlEscaped() + "</b><br>";
tooltip += "<b>" + section.title + "</b><br>";
for (const auto &line : section.bulletPoints) {
tooltip += "• " + line.toHtmlEscaped() + "<br>";
tooltip += "• " + line + "<br>";
}
tooltip += "<br>";
}
@ -268,17 +215,13 @@ void CommanderBracketWidget::displayEstimate()
bracketInfoButton->setEnabled(!tooltip.isEmpty());
}
void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString &error)
void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString & /*error*/)
{
if (requester != this || id != requestId) {
return;
}
requestId = 0;
hasEstimate = false;
bracketValueLabel->setText("-");
bracketValueLabel->setToolTip(
QStringLiteral("<qt>%1</qt>").arg(tr("Unable to estimate bracket: %1").arg(error).toHtmlEscaped()));
bracketRefreshButton->setEnabled(true);
bracketInfoButton->setToolTip({});
bracketInfoButton->setEnabled(false);
@ -286,15 +229,6 @@ void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *request
void CommanderBracketWidget::maybeAutoEstimateBracket()
{
if (prompting) {
return;
}
autoEstimateTimer->stop();
if (!deck) {
cancelEstimate();
updateBracketVisibility(false);
return;
}
const QString formatKey = deck->getGameFormat();
const bool isCommander = (formatKey.compare("commander", Qt::CaseInsensitive) == 0);
@ -302,20 +236,17 @@ void CommanderBracketWidget::maybeAutoEstimateBracket()
int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled();
if (!isCommander || mode == commanderSpellbookIntegrationEnabledIndexDisabled) {
cancelEstimate();
updateBracketVisibility(false);
return;
}
if (mode == commanderSpellbookIntegrationEnabledIndexUnprompted) {
const auto promptedDeck = deck;
if (prompting) {
return;
}
prompting = true;
const bool accepted = promptCommanderSpellbookIntegration();
prompting = false;
if (deck != promptedDeck || !deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0) {
maybeAutoEstimateBracket();
return;
}
if (!accepted) {
updateBracketVisibility(false);
return;
@ -330,17 +261,17 @@ void CommanderBracketWidget::maybeAutoEstimateBracket()
}
// Avoid firing if we already have a result or a request in flight
if (requestId || hasEstimate) {
if (!bracketRefreshButton->isEnabled()) {
return;
}
// Defer to avoid races during init / model rebuild
autoEstimateTimer->start(0);
QTimer::singleShot(0, this, &CommanderBracketWidget::requestBracketEstimate);
}
void CommanderBracketWidget::retranslateUi()
{
bracketLabel->setText(tr("Bracket:"));
displayEstimate();
bracketInfoButton->setToolTip(tr("Why this bracket?"));
bracketRefreshButton->setToolTip(tr("Recalculate bracket"));
}

View file

@ -1,7 +1,6 @@
#ifndef COCKATRICE_COMMANDER_BRACKET_WIDGET_H
#define COCKATRICE_COMMANDER_BRACKET_WIDGET_H
#include "commander_bracket_service.h"
#include "commander_spellbook_bracket_explainer.h"
#include <QSharedPointer>
@ -12,7 +11,6 @@
class QLabel;
class QToolButton;
class QTimer;
struct CommanderBracketEstimate;
class DeckList;
class QObject;
@ -24,7 +22,6 @@ class CommanderBracketWidget : public QWidget
public:
explicit CommanderBracketWidget(QWidget *parent = nullptr);
~CommanderBracketWidget() override;
void setDeck(const QSharedPointer<DeckList> &_deck);
void retranslateUi();
@ -38,15 +35,10 @@ private slots:
private:
bool promptCommanderSpellbookIntegration();
void updateBracketVisibility(bool visible);
void cancelEstimate();
void displayEstimate();
QSharedPointer<DeckList> deck;
bool prompting = false;
quint64 requestId = 0;
QTimer *autoEstimateTimer;
bool hasEstimate = false;
CommanderBracketEstimate lastEstimate;
QLabel *bracketLabel;
QLabel *bracketValueLabel;

View file

@ -9,10 +9,9 @@
#include <QJsonParseError>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QStringLiteral>
#include <QUrl>
#include <libcockatrice/network/network_manager.h>
#include <QVariant>
#include <version_string.h>
class DeckList;
@ -25,18 +24,13 @@ CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance()
return instance;
}
CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent)
: QObject(parent), network(NetworkManagerFactory::create(this))
CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) : QObject(parent)
{
}
CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimateBracket(const DeckList &deck,
QObject *requester)
{
if (!requester) {
return 0;
}
CommanderSpellbookDeckRequest deckRequest = CommanderSpellbookDeckRequest::fromDeckList(deck);
QJsonDocument doc(deckRequest.toJson());
@ -46,39 +40,30 @@ CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimate
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
QNetworkReply *reply = network->post(req, body);
QNetworkReply *reply = network.post(req, body);
const RequestId id = nextRequestId++;
pendingRequests.insert(id, reply);
const QPointer<QObject> guardedRequester(requester);
connect(requester, &QObject::destroyed, reply, [this, id]() { cancelEstimate(id); });
connect(reply, &QNetworkReply::finished, this, [this, reply, id, guardedRequester]() {
if (pendingRequests.take(id) != reply) {
return;
}
reply->deleteLater();
if (guardedRequester) {
onEstimateReplyFinished(reply, id, guardedRequester);
}
});
reply->setProperty("requestId", QVariant::fromValue(id));
reply->setProperty("requester", QVariant::fromValue(requester));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { onEstimateReplyFinished(reply); });
return id;
}
void CommanderSpellbookApiAccessor::cancelEstimate(RequestId id)
void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply)
{
const QPointer<QNetworkReply> reply = pendingRequests.take(id);
if (!reply) {
reply->deleteLater();
const RequestId id = reply->property("requestId").toULongLong();
QObject *requester = reply->property("requester").value<QObject *>();
if (!requester) {
// Requester died — silently drop
return;
}
disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester)
{
if (reply->error() != QNetworkReply::NoError) {
emit estimateBracketError(id, requester, reply->errorString());
return;
@ -93,10 +78,6 @@ void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply
}
EstimateBracketResult result = EstimateBracketResult::fromJson(doc.object());
if (result.bracketTag.trimmed().isEmpty()) {
emit estimateBracketError(id, requester, QStringLiteral("Missing bracket in response"));
return;
}
emit estimateBracketFinished(id, requester, result);
}

View file

@ -1,16 +1,14 @@
#ifndef COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H
#define COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H
#include <QHash>
#include <QNetworkAccessManager>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QtGlobal>
#include <qtmetamacros.h>
class DeckList;
class QNetworkReply;
class QNetworkAccessManager;
struct EstimateBracketResult;
class CommanderSpellbookApiAccessor final : public QObject
@ -23,7 +21,6 @@ public:
using RequestId = quint64;
RequestId estimateBracket(const DeckList &deck, QObject *requester);
void cancelEstimate(RequestId id);
signals:
void estimateBracketFinished(RequestId id, QObject *requester, const EstimateBracketResult &result);
@ -34,10 +31,9 @@ private:
explicit CommanderSpellbookApiAccessor(QObject *parent = nullptr);
Q_DISABLE_COPY_MOVE(CommanderSpellbookApiAccessor)
void onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester);
void onEstimateReplyFinished(QNetworkReply *reply);
QNetworkAccessManager *network;
QHash<RequestId, QPointer<QNetworkReply>> pendingRequests;
QNetworkAccessManager network;
RequestId nextRequestId = 1;
};

View file

@ -3,14 +3,11 @@
#include "../../../../../client/settings/cache_settings.h"
#include <QByteArray>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QMap>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QSet>
#include <QString>
#include <QStringLiteral>
#include <QUrl>
@ -25,14 +22,6 @@ HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent)
{
}
HandleCommanderBrackets::~HandleCommanderBrackets()
{
if (reply) {
disconnect(reply, nullptr, this, nullptr);
reply->abort();
}
}
void HandleCommanderBrackets::downloadBracketDefinitions()
{
if (reply) {
@ -46,49 +35,31 @@ void HandleCommanderBrackets::downloadBracketDefinitions()
void HandleCommanderBrackets::actFinishParsingDownloadedData()
{
auto *finishedReply = qobject_cast<QNetworkReply *>(sender());
if (!finishedReply || finishedReply != reply) {
return;
}
reply = qobject_cast<QNetworkReply *>(sender());
// Clear the active request before notifying listeners, which may immediately retry.
reply = nullptr;
finishedReply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit sigBracketDefinitionsDownloadFailed(reply->error());
if (finishedReply->error() != QNetworkReply::NoError) {
emit sigBracketDefinitionsDownloadFailed(finishedReply->error());
reply->deleteLater();
return;
}
QJsonParseError parseError;
auto document = QJsonDocument::fromJson(finishedReply->readAll(), &parseError);
auto document = QJsonDocument::fromJson(reply->readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject() ||
!document.object().value("brackets").isArray() || document.object().value("brackets").toArray().isEmpty()) {
if (parseError.error != QJsonParseError::NoError) {
emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError);
return;
}
// Validate the complete replacement before touching the last usable definitions.
// Tags are QSettings group names, so path separators must not create nested groups.
QSet<QString> tags;
for (const auto &value : document.object().value("brackets").toArray()) {
const auto definition = value.toObject();
const auto tag = definition.value("tag").toString();
if (!value.isObject() || tag.trimmed().isEmpty() || tag.contains('/') || tag.contains('\\') ||
tags.contains(tag) || definition.value("officialName").toString().trimmed().isEmpty() ||
definition.value("displayName").toString().trimmed().isEmpty() ||
!definition.value("explanation").isString()) {
emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError);
return;
}
tags.insert(tag);
reply->deleteLater();
return;
}
updateBracketDefinitions(document.toVariant().toMap());
emit sigBracketDefinitionsDownloaded();
reply->deleteLater();
}
void HandleCommanderBrackets::updateBracketDefinitions(const QVariantMap &jsonMap)

View file

@ -3,7 +3,6 @@
#include <QNetworkReply>
#include <QObject>
#include <QPointer>
#include <QVariantMap>
#include <qtmetamacros.h>
@ -15,7 +14,6 @@ class HandleCommanderBrackets : public QObject
public:
explicit HandleCommanderBrackets(QObject *parent = nullptr);
~HandleCommanderBrackets() override;
void downloadBracketDefinitions();
@ -30,7 +28,7 @@ private:
void updateBracketDefinitions(const QVariantMap &jsonMap);
QNetworkAccessManager *nam;
QPointer<QNetworkReply> reply;
QNetworkReply *reply;
};
#endif // COCKATRICE_HANDLE_COMMANDER_BRACKETS_H

View file

@ -131,16 +131,10 @@ void TabDeckEditor::loadLayout()
LayoutsSettings &layouts = SettingsCache::instance().layouts();
auto layoutState = layouts.getDeckEditorLayoutState();
// Start with every current dock placed. Saved layouts from older versions
// cannot describe docks introduced later; restoreState leaves those docks
// in their existing positions while restoring the saved docks, including
// their visibility and floating state.
restartLayout();
if (!layoutState.isEmpty()) {
if (!restoreState(layoutState)) {
// A corrupt or incompatible state must not leave a partial layout.
restartLayout();
}
if (layoutState.isNull()) {
restartLayout();
} else {
restoreState(layoutState);
restoreGeometry(layouts.getDeckEditorGeometry());
}
}

View file

@ -99,7 +99,6 @@ class Message;
TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay)
: Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr)
{
setObjectName("TabGame");
// THIS CTOR IS USED ON REPLAY
game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(),
new SettingsCardPreferenceProvider(this));
@ -117,7 +116,6 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay)
addDockWidget(Qt::BottomDockWidgetArea, replayDock);
mainWidget = new QStackedWidget(this);
mainWidget->setObjectName("mainWidget");
mainWidget->addWidget(deckViewContainerWidget);
mainWidget->addWidget(gamePlayAreaWidget);
setCentralWidget(mainWidget);
@ -146,7 +144,6 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
const QMap<int, QString> &_roomGameTypes)
: Tab(_tabSupervisor), userListProxy(_tabSupervisor->getUserListManager())
{
setObjectName("TabGame");
// THIS CTOR IS USED ON GAMES
game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes,
&SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this));
@ -163,7 +160,6 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock);
mainWidget = new QStackedWidget(this);
mainWidget->setObjectName("mainWidget");
mainWidget->addWidget(deckViewContainerWidget);
mainWidget->addWidget(gamePlayAreaWidget);
mainWidget->setContentsMargins(0, 0, 0, 0);
@ -1122,7 +1118,6 @@ void TabGame::createMenuItems()
connect(aFocusChat, &QAction::triggered, sayEdit, qOverload<>(&LineEditCompleter::setFocus));
phasesMenu = new TearOffMenu(this);
phasesMenu->setObjectName("phasesMenu");
for (int i = 0; i < phasesToolbar->phaseCount(); ++i) {
auto *temp = new QAction(QString(), this);
@ -1133,16 +1128,12 @@ void TabGame::createMenuItems()
phasesMenu->addSeparator();
phasesMenu->addAction(aNextPhase);
aNextPhase->setObjectName("aNextPhase");
phasesMenu->addAction(aNextPhaseAction);
aNextPhaseAction->setObjectName("aNextPhaseAction");
gameMenu = new QMenu(this);
gameMenu->setObjectName("gameMenu");
playersSeparator = gameMenu->addSeparator();
gameMenu->addMenu(phasesMenu);
gameMenu->addAction(aNextTurn);
aNextTurn->setObjectName("aNextTurn");
gameMenu->addAction(aReverseTurn);
gameMenu->addSeparator();
gameMenu->addAction(aRemoveLocalArrows);

View file

@ -6,7 +6,6 @@ class TabSupervisor;
TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client)
{
setObjectName("TabHome");
homeWidget = new HomeWidget(this, tabSupervisor);
setCentralWidget(homeWidget);
}

View file

@ -54,7 +54,6 @@
#include <QPainter>
#include <QRect>
#include <QSharedPointer>
#include <QStackedWidget>
#include <QStringLiteral>
#include <QStyle>
#include <QStyleOption>
@ -158,15 +157,6 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{
setObjectName("tabSupervisor");
// QTabWidget keeps tab pages in an internal stacked widget. Give it a stable
// name so the GUI tests can address the pages by a readable path
// (tabSupervisor/stackedWidget/<TabName>).
if (auto *stackedWidget = findChild<QStackedWidget *>()) {
stackedWidget->setObjectName("stackedWidget");
}
setElideMode(Qt::ElideRight);
setMovable(true);
setIconSize(QSize(15, 15));
@ -472,9 +462,6 @@ void TabSupervisor::addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager
auto closeSide = static_cast<QTabBar::ButtonPosition>(
tabBar()->style()->styleHint(QStyle::SH_TabBar_CloseButtonPosition, nullptr, tabBar()));
auto *closeButton = new CloseButton(tab);
// No object name: the tab bar index shifts when tabs move or close, so an
// index-derived name would go stale (or collide). Callers find the button
// through the tab widget instead.
if (manager) {
// If managed, all close requests should go through the menu action
connect(closeButton, &CloseButton::clicked, this, [manager] { checkAndTrigger(manager, false); });

View file

@ -55,14 +55,6 @@
#include "widgets/server/handle_public_servers.h"
#include "widgets/settings_page/abstract_settings_page.h"
#include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h"
#ifdef WITH_GUI_TEST
#include "../game_graphics/pq_cockatrice_event_player.h"
#include "pqEventObserver.h"
#include "pqEventSource.h"
#include "pqEventTypes.h"
#include "pqTestUtility.h"
#include "recording.h"
#endif
#include <QAction>
#include <QApplication>
@ -318,7 +310,6 @@ void MainWindow::actAbout()
mb.setIconPixmap(
themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation));
mb.setTextInteractionFlags(Qt::TextBrowserInteraction);
mb.setObjectName("aboutDialog");
mb.exec();
}
@ -357,24 +348,6 @@ void MainWindow::actOpenSettingsFolder()
QtUtils::openPathInFileManager(dir);
}
#ifdef WITH_GUI_TEST
void MainWindow::actRecord()
{
QString filename = QFileDialog::getSaveFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)"));
if (!filename.isEmpty()) {
testUtility->recordTests(filename);
}
}
void MainWindow::actPlayRecording()
{
QString filename = QFileDialog::getOpenFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)"));
if (!filename.isEmpty()) {
testUtility->playTests(filename);
}
}
#endif
void MainWindow::retranslateUi()
{
connectionController->refreshWindowTitle();
@ -419,10 +392,6 @@ void MainWindow::retranslateUi()
aViewLog->setText(tr("View &Debug Log"));
aOpenSettingsFolder->setText(tr("Open Settings Folder"));
aFirstRunWizard->setText(tr("Re-run Onboarding Wizard..."));
#ifdef WITH_GUI_TEST
aRecord->setText(tr("&Record Test..."));
aPlayRecording->setText(tr("&Play Test..."));
#endif
aShow->setText(tr("Show/Hide"));
@ -432,33 +401,24 @@ void MainWindow::retranslateUi()
void MainWindow::createActions()
{
aConnect = new QAction(this);
aConnect->setObjectName("actionConnect");
connect(aConnect, &QAction::triggered, this, &MainWindow::actConnect);
aDisconnect = new QAction(this);
aDisconnect->setObjectName("actionDisconnect");
aDisconnect->setEnabled(false);
connect(aDisconnect, &QAction::triggered, this, &MainWindow::actDisconnect);
aSinglePlayer = new QAction(this);
aSinglePlayer->setObjectName("actionSinglePlayer");
connect(aSinglePlayer, &QAction::triggered, this, &MainWindow::actSinglePlayer);
aWatchReplay = new QAction(this);
aWatchReplay->setObjectName("actionWatchReplay");
connect(aWatchReplay, &QAction::triggered, this, &MainWindow::actWatchReplay);
aFullScreen = new QAction(this);
aFullScreen->setObjectName("actionFullScreen");
aFullScreen->setCheckable(true);
connect(aFullScreen, &QAction::toggled, this, &MainWindow::actFullScreen);
aRegister = new QAction(this);
aRegister->setObjectName("actionRegister");
connect(aRegister, &QAction::triggered, connectionController, &ConnectionController::registerToServer);
aForgotPassword = new QAction(this);
aForgotPassword->setObjectName("actionForgotPassword");
connect(aForgotPassword, &QAction::triggered, connectionController, &ConnectionController::forgotPasswordRequest);
aSettings = new QAction(this);
aSettings->setObjectName("actionSettings");
connect(aSettings, &QAction::triggered, this, &MainWindow::actSettings);
aExit = new QAction(this);
aExit->setObjectName("actionExit");
connect(aExit, &QAction::triggered, this, &MainWindow::actExit);
aManageSets = new QAction(QString(), this);
@ -495,12 +455,6 @@ void MainWindow::createActions()
connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder);
aFirstRunWizard = new QAction(this);
connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); });
#ifdef WITH_GUI_TEST
aRecord = new QAction(this);
connect(aRecord, &QAction::triggered, this, &MainWindow::actRecord);
aPlayRecording = new QAction(this);
connect(aPlayRecording, &QAction::triggered, this, &MainWindow::actPlayRecording);
#endif
aShow = new QAction(this);
connect(aShow, &QAction::triggered, this, &MainWindow::actShow);
@ -541,9 +495,7 @@ void MainWindow::createActions()
void MainWindow::createMenus()
{
menuBar()->setObjectName("menubar");
cockatriceMenu = menuBar()->addMenu(QString());
cockatriceMenu->setObjectName("cockatriceMenu");
cockatriceMenu->addAction(aConnect);
cockatriceMenu->addAction(aDisconnect);
cockatriceMenu->addAction(aRegister);
@ -559,7 +511,6 @@ void MainWindow::createMenus()
cockatriceMenu->addAction(aExit);
dbMenu = menuBar()->addMenu(QString());
dbMenu->setObjectName("dbMenu");
dbMenu->addAction(aManageSets);
dbMenu->addAction(aEditTokens);
dbMenu->addSeparator();
@ -570,10 +521,8 @@ void MainWindow::createMenus()
dbMenu->addAction(aReloadCardDatabase);
tabsMenu = menuBar()->addMenu(QString());
tabsMenu->setObjectName("tabsMenu");
helpMenu = menuBar()->addMenu(QString());
helpMenu->setObjectName("helpMenu");
helpMenu->addAction(aAbout);
helpMenu->addAction(aTips);
helpMenu->addSeparator();
@ -586,19 +535,12 @@ void MainWindow::createMenus()
helpMenu->addAction(aOpenSettingsFolder);
helpMenu->addSeparator();
helpMenu->addAction(aFirstRunWizard);
#ifdef WITH_GUI_TEST
helpMenu->addSeparator();
helpMenu->addAction(aRecord);
helpMenu->addAction(aPlayRecording);
#endif
}
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), noConnect(false),
: QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false),
cardUpdateProcess(nullptr), logviewDialog(nullptr)
{
setObjectName("MainWindow");
connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this,
&MainWindow::pixmapCacheSizeChanged);
pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize());
@ -684,14 +626,6 @@ MainWindow::MainWindow(QWidget *parent)
// run startup check async
QTimer::singleShot(0, this, &MainWindow::startupConfigCheck);
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
#ifdef WITH_GUI_TEST
testUtility = new pqTestUtility(this);
testUtility->setRecordWithDialog(true);
testUtility->addEventObserver("xml", new XMLEventObserver(this));
testUtility->addEventSource("xml", new XMLEventSource(this));
testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this));
#endif
}
void MainWindow::startupConfigCheck()
@ -846,11 +780,6 @@ void MainWindow::runFirstRunWizard(bool firstRun)
*/
void MainWindow::applyStartupDestination()
{
if (noConnect) {
qCInfo(WindowMainStartupAutoconnectLog) << "Startup destination suppressed (--no-connect)";
return;
}
// An explicit command-line connect takes precedence over the startup destination.
if (!connectTo.isEmpty()) {
return;
@ -1120,10 +1049,6 @@ void MainWindow::handleCockatriceLink(const QString &url)
void MainWindow::attemptStartupAutoConnect()
{
if (noConnect) {
qCInfo(WindowMainStartupAutoconnectLog) << "Auto-connect suppressed (--no-connect)";
return;
}
if (startupAutoConnectAttempted || skipStartupAutoConnect) {
return;
}

View file

@ -48,7 +48,6 @@
class QAction;
class QToolButton;
class QWidget;
class pqTestUtility;
struct LocalGameOptions;
inline Q_LOGGING_CATEGORY(WindowMainLog, "window_main");
@ -105,11 +104,6 @@ private slots:
void actUpdate();
void actViewLog();
void actOpenSettingsFolder();
#ifdef WITH_GUI_TEST
void actRecord();
void actPlayRecording();
#endif
void actShow();
void showWindowIfHidden();
void onUrlChainFinished(bool connected);
@ -191,9 +185,6 @@ private:
*aReloadCardDatabase;
QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar,
*aViewLog, *aOpenSettingsFolder;
#ifdef WITH_GUI_TEST
QAction *aRecord, *aPlayRecording;
#endif
TabSupervisor *tabSupervisor;
IntentUrlParser *urlParser;
@ -217,7 +208,6 @@ private:
bool skipStartupAutoConnect = false;
bool startupAutoConnectAttempted = false;
bool firstRunWizardActive = false;
bool noConnect = false; ///< --no-connect: skip every startup connection
QProcess *cardUpdateProcess;
QByteArray cardUpdateOutputBuffer;
QByteArray cardUpdateErrorOutput; ///< diagnostic output collected from the updater's stderr
@ -229,9 +219,6 @@ private:
GameReplay *replay;
DlgTipOfTheDay *tip;
QUrl connectTo;
#ifdef WITH_GUI_TEST
pqTestUtility *testUtility;
#endif
public:
explicit MainWindow(QWidget *parent = nullptr);
@ -239,16 +226,6 @@ public:
{
connectTo = QUrl(QString("cockatrice://%1").arg(url));
}
/**
* @brief Suppresses every startup connection: --connect, auto-connect and the
* configured startup destination. Used by the GUI test harness so a
* playback run never dials out.
*/
void setNoConnect(bool value)
{
noConnect = value;
}
// When set, the window's own startup connection (--connect or auto-connect
// on first activation) is skipped. Used for activation launches: the intent
// chain triggered by a cockatrice:// URL owns the connection, and letting
@ -289,13 +266,6 @@ public:
*/
void requestInstallerClose();
#ifdef WITH_GUI_TEST
pqTestUtility *getTestUtility() const
{
return testUtility;
}
#endif
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;

View file

@ -39,10 +39,6 @@
#include "single_instance_manager.h"
#include "version_string.h"
#ifdef WITH_GUI_TEST
#include "pqTestUtility.h"
#endif
#include <QApplication>
#include <QByteArray>
#include <QByteArrayView>
@ -66,12 +62,10 @@
#include <QString>
#include <QStringList>
#include <QStringLiteral>
#include <QThread>
#include <QTranslator>
#include <QUrl>
#include <QtGlobal>
#include <algorithm>
#include <cstdlib>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/rng/rng_sfmt.h>
#include <libcockatrice/settings/card_database_settings.h>
@ -349,16 +343,9 @@ int main(int argc, char *argv[])
parser.addOptions(
{{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"},
{{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")},
{"simulate-crash", QCoreApplication::translate(
"main", "Show the crash-report dialog (saves a mock file when no DSN is configured)")},
{"no-connect", QCoreApplication::translate("main", "Do not connect to any server on startup")}
#ifdef WITH_GUI_TEST
,
{{"t", "test"},
QCoreApplication::translate("main", "Play back a QtTesting XML test file and exit"),
"test-file.xml"}
#endif
});
{"simulate-crash",
QCoreApplication::translate("main",
"Show the crash-report dialog (saves a mock file when no DSN is configured)")}});
parser.process(app);
@ -496,9 +483,6 @@ int main(int argc, char *argv[])
if (parser.isSet("connect")) {
ui.setConnectTo(parser.value("connect"));
}
if (parser.isSet("no-connect")) {
ui.setNoConnect(true);
}
qCInfo(MainLog) << "MainWindow constructor finished";
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
@ -539,27 +523,6 @@ int main(int argc, char *argv[])
handleActivation(file);
}
#ifdef WITH_GUI_TEST
if (parser.isSet("test")) {
QString testFile = parser.value("test");
qCInfo(MainLog) << "Playing test file:" << testFile;
QTimer::singleShot(0, &ui, [&ui, testFile]() {
bool success = ui.getTestUtility()->playTests(testFile);
qCInfo(MainLog) << "Test playback" << (success ? "succeeded" : "FAILED");
qApp->exit(success ? 0 : 1);
// An event that fails while a modal dialog is open aborts the dispatcher,
// but the dialog's nested event loop can then keep the application alive
// forever. Hard-exit after a grace period so a red test always terminates.
auto *watchdog = QThread::create([success] {
QThread::sleep(10);
::_Exit(success ? 0 : 1);
});
watchdog->start();
});
}
#endif
// Connect to future file/URL events from other instances
QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) {
for (const QString &file : files) {

1
external/qttesting vendored

@ -1 +0,0 @@
Subproject commit c7bb5d372133e94c75cbdc70b04e039b864ea379

View file

@ -50,12 +50,11 @@ namespace
// The server warns that this account still uses the legacy password hash while this client
// holds a scrypt verifier. Sending the verifier would downgrade the proof, so the client
// stops; the login only succeeds after the saved verifier is gone and the user types the
// password, which also upgrades the account. Unticking "Save password" drops the verifier
// for the next connection.
// password, which also upgrades the account.
constexpr const char *const LOGIN_ABORT_AUTH_DOWNGRADE = QT_TRANSLATE_NOOP(
"RemoteClient",
"The server still uses an old password format for this account, so the saved login on this computer cannot be "
"used. Untick 'Save password' in the Connect window and log in with your password.");
"used. Untick 'Save password' in the Connect window and connect; when it opens again, type your password.");
// The account is still on the legacy hash and only the plaintext password can produce it.
constexpr const char *const LOGIN_ABORT_PASSWORD_REQUIRED =
QT_TRANSLATE_NOOP("RemoteClient",

View file

@ -178,18 +178,6 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp
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
user_games_model_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp
user_games_model_test.cpp
LIBS
libcockatrice_network_client_abstract
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
${CMAKE_SOURCE_DIR}/libcockatrice_network
)
# The installer's holder probe is Windows-only, but the wording it produces and the
# question which programs are the payload's own are plain C++ and are what a stuck user
# has to act on, so they are tested everywhere.

View file

@ -1,248 +0,0 @@
#include "interface/widgets/server/user/user_games_model.h"
#include <QAbstractItemModelTester>
#include <QCoreApplication>
#include <QPersistentModelIndex>
#include <QSignalSpy>
#include <gtest/gtest.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/server_message.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <memory>
RNG_Abstract *rng = nullptr; // required by the transitive server library; this suite sends no game commands
namespace
{
class GamesClient : public AbstractClient
{
public:
QList<CommandContainer> sent;
GamesClient()
{
setStatus(StatusLoggedIn);
}
~GamesClient() override
{
qDeleteAll(pendingCommands);
pendingCommands.clear();
}
void changeStatus(ClientStatus status)
{
setStatus(status);
}
void answer(int request, Response response)
{
response.set_cmd_id(sent.at(request).cmd_id());
ServerMessage message;
message.set_message_type(ServerMessage::RESPONSE);
*message.mutable_response() = response;
processProtocolItem(message);
}
protected:
void sendCommandContainer(const CommandContainer &command) override
{
sent.append(command);
}
};
Response gamesResponse(std::initializer_list<int> ids)
{
Response response;
response.set_response_code(Response::RespOk);
auto *games = response.MutableExtension(Response_GetGamesOfUser::ext);
for (int id : ids) {
auto *game = games->add_game_list();
game->set_game_id(id);
game->set_room_id(7);
}
return response;
}
using State = UserGamesModel::State;
TEST(UserGamesModel, OfflineUserDoesNotSendARequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", false);
model.refresh();
EXPECT_TRUE(client.sent.isEmpty());
EXPECT_EQ(State::Offline, model.getState());
EXPECT_FALSE(model.canRefresh());
EXPECT_EQ(0, model.rowCount());
}
TEST(UserGamesModel, PresenceLossInvalidatesTheOutstandingResponse)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
EXPECT_EQ(State::Loading, model.getState());
EXPECT_FALSE(model.canRefresh());
model.setUser("Alice", false);
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Offline, model.getState());
EXPECT_EQ(0, model.rowCount());
model.setUser("Alice", true);
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_TRUE(model.canRefresh());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, SwitchingBackToTheSameUserDoesNotAcceptTheirOldRequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
model.setUser("Bob", true);
model.setUser("Alice", true);
client.answer(2, gamesResponse({3}));
client.answer(0, gamesResponse({1}));
client.answer(1, gamesResponse({2}));
EXPECT_EQ(1, model.rowCount());
EXPECT_EQ(3, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, RefreshSupersedesThePreviousRequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
model.refresh();
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Loading, model.getState());
EXPECT_EQ(0, model.rowCount());
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, EmptySuccessAndMissingPayloadAreDifferentStates)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
client.answer(0, gamesResponse({}));
EXPECT_EQ(State::Empty, model.getState());
EXPECT_TRUE(model.canRefresh());
model.refresh();
Response missingPayload;
missingPayload.set_response_code(Response::RespOk);
client.answer(1, missingPayload);
EXPECT_EQ(State::Error, model.getState());
EXPECT_TRUE(model.canRefresh());
}
TEST(UserGamesModel, FailedRequestsCanBeRetried)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
Response error;
error.set_response_code(Response::RespNameNotFound);
client.answer(0, error);
EXPECT_EQ(State::Error, model.getState());
model.refresh();
client.answer(1, gamesResponse({4}));
EXPECT_EQ(State::Ready, model.getState());
}
TEST(UserGamesModel, DisconnectRejectsLateRepliesAndReconnectFetchesAgain)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
client.changeStatus(StatusDisconnecting);
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Disconnected, model.getState());
EXPECT_FALSE(model.canRefresh());
model.refresh();
EXPECT_EQ(1, client.sent.size());
client.changeStatus(StatusLoggedIn);
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
}
TEST(UserGamesModel, ClientDestructionClearsGamesAndDisablesRefresh)
{
auto client = std::make_unique<GamesClient>();
UserGamesModel model(client.get());
model.setUser("Alice", true);
client->answer(0, gamesResponse({1}));
client.reset();
EXPECT_EQ(State::Disconnected, model.getState());
EXPECT_EQ(0, model.rowCount());
EXPECT_FALSE(model.canRefresh());
model.refresh();
EXPECT_EQ(State::Disconnected, model.getState());
}
TEST(UserGamesModel, ResetInvalidatesAnOpenGameActionAndPreservesTheModelContract)
{
GamesClient client;
UserGamesModel model(&client);
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal);
model.setUser("Alice", true);
client.answer(0, gamesResponse({1, 2, 3}));
QPersistentModelIndex selected(model.index(1));
EXPECT_TRUE(selected.isValid());
model.refresh();
EXPECT_FALSE(selected.isValid());
EXPECT_EQ(0, model.rowCount());
client.answer(1, gamesResponse({4}));
EXPECT_EQ(1, model.rowCount());
}
TEST(UserGamesModel, StateListenerCanSelectAnotherUserBeforeTheRequestIsSent)
{
GamesClient client;
UserGamesModel model(&client);
bool switched = false;
QObject::connect(&model, &UserGamesModel::stateChanged, &model, [&] {
if (!switched && model.getState() == State::Loading) {
switched = true;
model.setUser("Bob", false);
}
});
model.setUser("Alice", true);
EXPECT_TRUE(client.sent.isEmpty());
EXPECT_EQ(State::Offline, model.getState());
}
TEST(UserGamesModel, TimedOutResponseCannotReplaceARetry)
{
GamesClient client;
UserGamesModel model(&client, 50);
model.setUser("Alice", true);
QSignalSpy changes(&model, &UserGamesModel::stateChanged);
ASSERT_TRUE(changes.wait(1000));
ASSERT_EQ(State::Error, model.getState());
EXPECT_TRUE(model.canRefresh());
model.refresh();
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Loading, model.getState());
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}